Compare commits
58 Commits
b02616bc44
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 376265eec7 | |||
| f3c91fc385 | |||
| c69c2ad532 | |||
| 8dbbd92d3a | |||
| fa36f8d184 | |||
| 794ad98cf0 | |||
| f5eabd7dc4 | |||
| f717edeacb | |||
| 56607879ee | |||
| fcefabdaee | |||
| 58495d2c75 | |||
| 51961dc19b | |||
| c516402402 | |||
| e614599650 | |||
| d36de95577 | |||
| 0147be4e0c | |||
| 390f138601 | |||
| 9eaf2fe5e3 | |||
| 314d5f9452 | |||
| e0fb3cac68 | |||
| 4589670b37 | |||
| eb1e18a997 | |||
| 6dcdf20289 | |||
| 762b5efc3b | |||
| 3dd6a83c68 | |||
| 5e98ed0e11 | |||
| fcf13bae22 | |||
| c30a75cc8f | |||
| 90e51698b8 | |||
| 5d8ab57e20 | |||
| d33a022676 | |||
| 7e2ef1fa2b | |||
| 164cad456c | |||
| 0b091a1afd | |||
| cfc7ad39f2 | |||
| 8a64a269f3 | |||
| 908255e5cf | |||
| 39297d4aa5 | |||
| d39cb3f3d4 | |||
| c70f83d04a | |||
| 3329cf9ac2 | |||
| 6d4e490283 | |||
| d3490e1521 | |||
| 0c2201fab5 | |||
| f7b75fa72f | |||
| 2c7fcdb5ae | |||
| 0bdac16250 | |||
| 6eeab58464 | |||
| 08a35bddeb | |||
| bbcf767a8f | |||
| b1bd4ed365 | |||
| 2b3551cada | |||
| d611f60743 | |||
| d85a8d4511 | |||
| f0271e61df | |||
| 3164a70e48 | |||
| 8d3d67a177 | |||
| f9985fc693 |
@@ -0,0 +1,48 @@
|
|||||||
|
# API Keys
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
ELEVENLABS_API_KEY=
|
||||||
|
INWORLD_API_KEY=
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# SignalWire (real callers)
|
||||||
|
SIGNALWIRE_PROJECT_ID=
|
||||||
|
SIGNALWIRE_SPACE=
|
||||||
|
SIGNALWIRE_TOKEN=
|
||||||
|
SIGNALWIRE_PHONE=
|
||||||
|
SIGNALWIRE_STREAM_URL=
|
||||||
|
|
||||||
|
# Social media
|
||||||
|
TWITTER_API_KEY=
|
||||||
|
TWITTER_API_SECRET=
|
||||||
|
TWITTER_ACCESS_TOKEN=
|
||||||
|
TWITTER_ACCESS_TOKEN_SECRET=
|
||||||
|
CLIENT_SECRET_ID=
|
||||||
|
CLIENT_SECRET=
|
||||||
|
POSTIZ_URL=
|
||||||
|
POSTIZ_API_KEY=
|
||||||
|
BSKY_APP_PASSWORD=
|
||||||
|
|
||||||
|
# Castopod
|
||||||
|
CASTOPOD_USERNAME=admin
|
||||||
|
CASTOPOD_PASSWORD=
|
||||||
|
CASTOPOD_DB_PASS=
|
||||||
|
|
||||||
|
# Postiz publishing
|
||||||
|
POSTIZ_JWT_SECRET=
|
||||||
|
POSTIZ_USER_ID=
|
||||||
|
POSTIZ_INTEGRATIONS={}
|
||||||
|
|
||||||
|
# BunnyCDN
|
||||||
|
BUNNY_STORAGE_KEY=
|
||||||
|
BUNNY_ACCOUNT_KEY=
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
HEARTBEAT_URL=
|
||||||
|
|
||||||
|
# Google AI
|
||||||
|
GOOGLE_API_KEY=
|
||||||
|
|
||||||
|
# Email (IMAP)
|
||||||
|
SUBMISSIONS_IMAP_HOST=
|
||||||
|
SUBMISSIONS_IMAP_USER=
|
||||||
|
SUBMISSIONS_IMAP_PASS=
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
2c7fcdb5aecbb0d3bf039abab9e723d62bc8fe1a
|
||||||
@@ -50,5 +50,12 @@ voices-v1.0.bin
|
|||||||
# Reference voices for TTS
|
# Reference voices for TTS
|
||||||
ref_audio/
|
ref_audio/
|
||||||
|
|
||||||
|
# YouTube OAuth credentials
|
||||||
|
youtube_client_secrets.json
|
||||||
|
youtube_token.json
|
||||||
|
|
||||||
|
# Clip upload history (local)
|
||||||
|
upload-history.json
|
||||||
|
|
||||||
# Claude settings (local)
|
# Claude settings (local)
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -18,15 +18,14 @@
|
|||||||
## Castopod (Podcast Publishing)
|
## Castopod (Podcast Publishing)
|
||||||
- **URL**: https://podcast.macneilmediagroup.com
|
- **URL**: https://podcast.macneilmediagroup.com
|
||||||
- **Podcast handle**: `@LukeAtTheRoost`
|
- **Podcast handle**: `@LukeAtTheRoost`
|
||||||
- **API Auth**: Basic auth (admin/podcast2026api)
|
- **API Auth**: Basic auth (credentials in .env: CASTOPOD_USERNAME, CASTOPOD_PASSWORD)
|
||||||
- **Container**: `castopod-castopod-1`
|
- **Container**: `castopod-castopod-1`
|
||||||
- **Database**: `castopod-mariadb-1` (user: castopod, db: castopod)
|
- **Database**: `castopod-mariadb-1` (user: castopod, db: castopod)
|
||||||
|
|
||||||
## Running the App
|
## Running the App
|
||||||
```bash
|
```bash
|
||||||
# Start backend
|
# Start backend — ALWAYS use --reload-dir to avoid CPU thrashing from file watchers
|
||||||
cd /Users/lukemacneil/ai-podcast
|
python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
|
||||||
python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
|
|
||||||
# Or use run.sh
|
# Or use run.sh
|
||||||
./run.sh
|
./run.sh
|
||||||
@@ -56,10 +55,57 @@ Required in `.env`:
|
|||||||
- `_pick_response_budget()` in main.py controls caller dialog token limits (150-450 tokens). MiniMax respects limits strictly — if responses seem short, check these values.
|
- `_pick_response_budget()` in main.py controls caller dialog token limits (150-450 tokens). MiniMax respects limits strictly — if responses seem short, check these values.
|
||||||
- Default max_tokens in llm.py is 300 (for non-caller uses)
|
- Default max_tokens in llm.py is 300 (for non-caller uses)
|
||||||
- Grok (`x-ai/grok-4-fast`) works well for natural dialog; MiniMax tends toward terse responses
|
- Grok (`x-ai/grok-4-fast`) works well for natural dialog; MiniMax tends toward terse responses
|
||||||
|
- `generate_with_tools()` in llm.py supports OpenRouter function calling for the intern feature
|
||||||
|
|
||||||
|
## Caller Generation System
|
||||||
|
- **CallerBackground dataclass**: Structured output from LLM background generation (JSON mode). Fields: name, age, gender, job, location, reason_for_calling, pool_name, communication_style, energy_level, emotional_state, signature_detail, situation_summary, natural_description, seeds, verbal_fluency, calling_from.
|
||||||
|
- **Voice-personality matching**: `_match_voices_to_styles()` runs after background generation. 68 voice profiles in `VOICE_PROFILES` (tts.py), 18 style-to-voice mappings in `STYLE_VOICE_PREFERENCES` (main.py). Soft matching — scores voices against style preferences.
|
||||||
|
- **Adaptive call shapes**: `SHAPE_STYLE_AFFINITIES` maps communication styles to shape weight multipliers. Consecutive shape repeats are dampened.
|
||||||
|
- **Inter-caller awareness**: Thematic matching in `get_show_history()` scores previous callers by keyword/category overlap. Adaptive reaction frequency (60%/35%/15%). Show energy tracking via `_get_show_energy()`.
|
||||||
|
- **Caller memory**: Returning callers store structured backgrounds, key moments, arc status, and relationships with other regulars. `RegularCallerService` has `add_relationship()` and expanded `update_after_call()`.
|
||||||
|
- **Show pacing**: `_sort_caller_queue()` sorts presentation order by energy alternation, topic variety, shape variety.
|
||||||
|
- **Call quality signals**: `_assess_call_quality()` captures exchange count, response length, host engagement, shape target hit, natural ending.
|
||||||
|
|
||||||
|
## Devon (Intern Character)
|
||||||
|
- **Service**: `backend/services/intern.py` — persistent show character, not a caller
|
||||||
|
- **Personality**: 23-year-old NMSU grad, eager, slightly incompetent, gets yelled at. Voice: "Nate" (Inworld), no phone filter.
|
||||||
|
- **Tools**: web_search (SearXNG), get_headlines, fetch_webpage, wikipedia_lookup — via `generate_with_tools()` function calling
|
||||||
|
- **Endpoints**: `POST /api/intern/ask`, `/interject`, `/monitor`, `GET /api/intern/suggestion`, `POST /api/intern/suggestion/play`, `/dismiss`
|
||||||
|
- **Auto-monitoring**: Watches conversation every 15s during calls, buffers suggestions for host approval
|
||||||
|
- **Persistence**: `data/intern.json` stores lookup history
|
||||||
|
- **Frontend**: Ask Devon input (D key), Interject button, monitor toggle, suggestion indicator with Play/Dismiss
|
||||||
|
|
||||||
|
## Frontend Control Panel
|
||||||
|
- **Keyboard shortcuts**: 1-0 (callers), H (hangup), W (wrap up), M (music toggle), D (ask Devon), Escape (close modals)
|
||||||
|
- **Wrap It Up**: Amber button that signals callers to wind down gracefully. Reduces response budget, injects wrap-up signals, forces goodbye after 2 exchanges.
|
||||||
|
- **Caller info panel**: Shows call shape, energy level, emotional state, signature detail, situation summary during active calls
|
||||||
|
- **Caller buttons**: Energy dots (colored by level) and shape badges on each button
|
||||||
|
- **Pinned SFX**: Cheer/Applause/Boo always visible, rest collapsible
|
||||||
|
- **Visual polish**: Thinking pulse, call glow, compact media row, smoother transitions
|
||||||
|
|
||||||
## Website
|
## Website
|
||||||
- **Domain**: lukeattheroost.com (behind Cloudflare)
|
- **Domain**: lukeattheroost.com (behind Cloudflare)
|
||||||
- **Analytics**: Cloudflare Web Analytics (enable in Cloudflare dashboard, no code changes needed)
|
- **Analytics**: Cloudflare Web Analytics (enable in Cloudflare dashboard, no code changes needed)
|
||||||
|
- **Deploy**: `npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main`
|
||||||
|
|
||||||
|
## Git Push
|
||||||
|
- If `mmgnas` times out, use the 10g hostname:
|
||||||
|
```bash
|
||||||
|
GIT_SSH_COMMAND="ssh -o HostName=mmgnas-10g -p 2222 -i ~/.ssh/gitea_mmgnas" git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hetzner VPS
|
||||||
|
- **IP**: `46.225.164.41`
|
||||||
|
- **SSH**: `ssh root@46.225.164.41` (uses default key `~/.ssh/id_rsa`)
|
||||||
|
- **Specs**: 2 CPU, 4GB RAM, 38GB disk (~33GB free)
|
||||||
|
- **Mail**: `docker-mailserver` at `/opt/mailserver/`
|
||||||
|
- **Manage accounts**: `docker exec mailserver setup email add/del/list`
|
||||||
|
- **Available for future services** — has headroom for lightweight containers. Not suitable for storage-heavy services (e.g. Castopod with daily episodes) without a disk upgrade or attached volume.
|
||||||
|
|
||||||
|
## Podcast Workflow
|
||||||
|
- Publishing pipeline: episodes go through Castopod, CDN, website, YouTube, and social
|
||||||
|
- Always check Python venv is active and packages are installed before running publish scripts
|
||||||
|
- Episode numbering must be verified against existing episodes
|
||||||
|
|
||||||
## Episodes Published
|
## Episodes Published
|
||||||
- Episode 6 published 2026-02-08 (podcast6.mp3, ~31 min)
|
- Episode 6 published 2026-02-08 (podcast6.mp3, ~31 min)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Luke MacNeil / MacNeil Media Group, LLC
|
||||||
|
https://macneilmediagroup.com
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Analyze silence gaps in podcast stems to find optimal strip-silence thresholds.
|
||||||
|
|
||||||
|
Usage: python analyze_gaps.py recordings/2026-03-17_235137/
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BLOCK_SEC = 0.1
|
||||||
|
SILENCE_DB = -30
|
||||||
|
THRESHOLD = 10 ** (SILENCE_DB / 20)
|
||||||
|
MIN_VOICE_SEC = 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def load_stem(path: Path) -> tuple[np.ndarray, int]:
|
||||||
|
audio, sr = sf.read(path, dtype="float32")
|
||||||
|
if audio.ndim > 1:
|
||||||
|
audio = audio[:, 0]
|
||||||
|
return audio, sr
|
||||||
|
|
||||||
|
|
||||||
|
def compute_rms_blocks(audio: np.ndarray, sr: int) -> np.ndarray:
|
||||||
|
block_samples = int(sr * BLOCK_SEC)
|
||||||
|
n_blocks = len(audio) // block_samples
|
||||||
|
if n_blocks == 0:
|
||||||
|
return np.array([0.0])
|
||||||
|
trimmed = audio[:n_blocks * block_samples].reshape(n_blocks, block_samples)
|
||||||
|
return np.sqrt(np.mean(trimmed ** 2, axis=1))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_peak_blocks(audio: np.ndarray, sr: int) -> np.ndarray:
|
||||||
|
block_samples = int(sr * BLOCK_SEC)
|
||||||
|
n_blocks = len(audio) // block_samples
|
||||||
|
if n_blocks == 0:
|
||||||
|
return np.array([0.0])
|
||||||
|
trimmed = audio[:n_blocks * block_samples].reshape(n_blocks, block_samples)
|
||||||
|
return np.max(np.abs(trimmed), axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(stems_dir: Path):
|
||||||
|
stems_dir = Path(stems_dir)
|
||||||
|
voice_stems = {}
|
||||||
|
for name in ["host", "devon", "caller"]:
|
||||||
|
path = stems_dir / f"{name}.wav"
|
||||||
|
if path.exists():
|
||||||
|
print(f"Loading {name}...", end=" ", flush=True)
|
||||||
|
audio, sr = load_stem(path)
|
||||||
|
voice_stems[name] = audio
|
||||||
|
print(f"{len(audio)/sr:.0f}s @ {sr}Hz")
|
||||||
|
|
||||||
|
if not voice_stems:
|
||||||
|
print("No voice stems found")
|
||||||
|
return
|
||||||
|
|
||||||
|
sr_val = sr
|
||||||
|
duration = max(len(a) for a in voice_stems.values()) / sr_val
|
||||||
|
print(f"\nTotal duration: {duration/60:.1f} min")
|
||||||
|
|
||||||
|
# Compute per-track RMS and peak blocks
|
||||||
|
track_rms = {}
|
||||||
|
track_peak = {}
|
||||||
|
for name, audio in voice_stems.items():
|
||||||
|
track_rms[name] = compute_rms_blocks(audio, sr_val)
|
||||||
|
track_peak[name] = compute_peak_blocks(audio, sr_val)
|
||||||
|
|
||||||
|
n_blocks = min(len(v) for v in track_peak.values())
|
||||||
|
|
||||||
|
# Detect gaps using same logic as Lua script (RMS for speaker ID, peak for silence)
|
||||||
|
min_voice_blocks = int(MIN_VOICE_SEC / BLOCK_SEC)
|
||||||
|
track_names = list(voice_stems.keys())
|
||||||
|
|
||||||
|
gaps = []
|
||||||
|
in_silence = False
|
||||||
|
silence_start = 0
|
||||||
|
track_before = None
|
||||||
|
last_active = None
|
||||||
|
voice_run = 0
|
||||||
|
voice_run_track = None
|
||||||
|
|
||||||
|
for i in range(n_blocks):
|
||||||
|
# Peak for silence detection
|
||||||
|
best_peak = max(track_peak[name][i] for name in track_names)
|
||||||
|
# RMS for speaker identification
|
||||||
|
best_rms = 0
|
||||||
|
best_track = None
|
||||||
|
for name in track_names:
|
||||||
|
r = track_rms[name][i]
|
||||||
|
if r > best_rms:
|
||||||
|
best_rms = r
|
||||||
|
best_track = name
|
||||||
|
|
||||||
|
all_silent = best_peak < THRESHOLD
|
||||||
|
|
||||||
|
if not all_silent:
|
||||||
|
last_active = best_track
|
||||||
|
|
||||||
|
if in_silence:
|
||||||
|
if all_silent:
|
||||||
|
voice_run = 0
|
||||||
|
voice_run_track = None
|
||||||
|
else:
|
||||||
|
if voice_run == 0:
|
||||||
|
voice_run_track = best_track
|
||||||
|
voice_run += 1
|
||||||
|
if voice_run >= min_voice_blocks:
|
||||||
|
voice_start_block = i - (voice_run - 1)
|
||||||
|
gap_start = silence_start * BLOCK_SEC
|
||||||
|
gap_end = voice_start_block * BLOCK_SEC
|
||||||
|
dur = gap_end - gap_start
|
||||||
|
if dur >= 0.5: # log gaps >= 0.5s
|
||||||
|
gaps.append({
|
||||||
|
"start": gap_start,
|
||||||
|
"end": gap_end,
|
||||||
|
"dur": dur,
|
||||||
|
"before": track_before or "?",
|
||||||
|
"after": voice_run_track or "?",
|
||||||
|
})
|
||||||
|
in_silence = False
|
||||||
|
voice_run = 0
|
||||||
|
voice_run_track = None
|
||||||
|
else:
|
||||||
|
if all_silent:
|
||||||
|
in_silence = True
|
||||||
|
silence_start = i
|
||||||
|
track_before = last_active
|
||||||
|
voice_run = 0
|
||||||
|
voice_run_track = None
|
||||||
|
|
||||||
|
# Trailing silence
|
||||||
|
if in_silence:
|
||||||
|
dur = (n_blocks - silence_start) * BLOCK_SEC
|
||||||
|
if dur >= 0.5:
|
||||||
|
gaps.append({
|
||||||
|
"start": silence_start * BLOCK_SEC,
|
||||||
|
"end": n_blocks * BLOCK_SEC,
|
||||||
|
"dur": dur,
|
||||||
|
"before": track_before or "?",
|
||||||
|
"after": "end",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not gaps:
|
||||||
|
print("No gaps detected")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Categorize gaps
|
||||||
|
categories = {
|
||||||
|
"host_self": [], # Host -> Host
|
||||||
|
"host_to_caller": [], # Host -> Caller (TTS latency)
|
||||||
|
"caller_to_host": [], # Caller -> Host
|
||||||
|
"host_to_devon": [], # Host -> Devon (TTS latency)
|
||||||
|
"devon_to_host": [], # Devon -> Host
|
||||||
|
"caller_to_devon": [],# Caller -> Devon (interjection)
|
||||||
|
"devon_to_caller": [],# Devon -> Caller
|
||||||
|
"other": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for g in gaps:
|
||||||
|
b, a = g["before"], g["after"]
|
||||||
|
if b == "host" and a == "host":
|
||||||
|
categories["host_self"].append(g)
|
||||||
|
elif b == "host" and a == "caller":
|
||||||
|
categories["host_to_caller"].append(g)
|
||||||
|
elif b == "caller" and a == "host":
|
||||||
|
categories["caller_to_host"].append(g)
|
||||||
|
elif b == "host" and a == "devon":
|
||||||
|
categories["host_to_devon"].append(g)
|
||||||
|
elif b == "devon" and a == "host":
|
||||||
|
categories["devon_to_host"].append(g)
|
||||||
|
elif b == "caller" and a == "devon":
|
||||||
|
categories["caller_to_devon"].append(g)
|
||||||
|
elif b == "devon" and a == "caller":
|
||||||
|
categories["devon_to_caller"].append(g)
|
||||||
|
else:
|
||||||
|
categories["other"].append(g)
|
||||||
|
|
||||||
|
# Print results
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"GAP ANALYSIS — {len(gaps)} gaps detected")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
|
||||||
|
total_silence = sum(g["dur"] for g in gaps)
|
||||||
|
print(f"Total silence: {total_silence:.0f}s ({total_silence/60:.1f} min)")
|
||||||
|
print(f"Content after removal: ~{(duration - total_silence)/60:.1f} min")
|
||||||
|
|
||||||
|
for cat_name, cat_gaps in sorted(categories.items(), key=lambda x: -len(x[1])):
|
||||||
|
if not cat_gaps:
|
||||||
|
continue
|
||||||
|
durs = sorted([g["dur"] for g in cat_gaps])
|
||||||
|
print(f"\n--- {cat_name} ({len(cat_gaps)} gaps) ---")
|
||||||
|
print(f" Range: {durs[0]:.1f}s - {durs[-1]:.1f}s")
|
||||||
|
print(f" Median: {np.median(durs):.1f}s Mean: {np.mean(durs):.1f}s")
|
||||||
|
if len(durs) >= 5:
|
||||||
|
print(f" P25: {np.percentile(durs, 25):.1f}s P75: {np.percentile(durs, 75):.1f}s")
|
||||||
|
|
||||||
|
# Histogram
|
||||||
|
brackets = [(0, 1), (1, 2), (2, 3), (3, 5), (5, 8), (8, 12), (12, 18), (18, 30), (30, 60), (60, 999)]
|
||||||
|
print(f" Distribution:")
|
||||||
|
for lo, hi in brackets:
|
||||||
|
count = sum(1 for d in durs if lo <= d < hi)
|
||||||
|
if count > 0:
|
||||||
|
bar = "#" * count
|
||||||
|
label = f"{lo}-{hi}s" if hi < 999 else f"{lo}s+"
|
||||||
|
print(f" {label:>8s}: {bar} ({count})")
|
||||||
|
|
||||||
|
# Find natural clusters and suggest thresholds
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print("SUGGESTED THRESHOLDS")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
|
||||||
|
# For each Devon-involved category, find the gap between interjection and TTS gaps
|
||||||
|
devon_gaps = categories["host_to_devon"] + categories["devon_to_host"] + categories["caller_to_devon"] + categories["devon_to_caller"]
|
||||||
|
if devon_gaps:
|
||||||
|
devon_durs = sorted([g["dur"] for g in devon_gaps])
|
||||||
|
# Look for a natural break between short (interjection) and long (TTS) gaps
|
||||||
|
short = [d for d in devon_durs if d < 5]
|
||||||
|
long = [d for d in devon_durs if d >= 5]
|
||||||
|
if short and long:
|
||||||
|
suggested = (max(short) + min(long)) / 2
|
||||||
|
print(f"Devon threshold: {suggested:.1f}s (short gaps: {len(short)} up to {max(short):.1f}s, long gaps: {len(long)} from {min(long):.1f}s)")
|
||||||
|
elif short:
|
||||||
|
print(f"Devon threshold: {max(short) + 1:.1f}s (all gaps are short, max {max(short):.1f}s)")
|
||||||
|
else:
|
||||||
|
print(f"Devon threshold: 3.0s (all gaps are long, min {min(long):.1f}s)")
|
||||||
|
|
||||||
|
caller_gaps = categories["host_to_caller"] + categories["caller_to_host"]
|
||||||
|
if caller_gaps:
|
||||||
|
caller_durs = sorted([g["dur"] for g in caller_gaps])
|
||||||
|
short = [d for d in caller_durs if d < 5]
|
||||||
|
long = [d for d in caller_durs if d >= 5]
|
||||||
|
if short and long:
|
||||||
|
suggested = (max(short) + min(long)) / 2
|
||||||
|
print(f"Caller transition threshold: {suggested:.1f}s (short: {len(short)} up to {max(short):.1f}s, long: {len(long)} from {min(long):.1f}s)")
|
||||||
|
elif long:
|
||||||
|
print(f"Caller transition threshold: {min(long) - 1:.1f}s (all gaps >= {min(long):.1f}s)")
|
||||||
|
|
||||||
|
host_self = categories["host_self"]
|
||||||
|
if host_self:
|
||||||
|
host_durs = sorted([g["dur"] for g in host_self])
|
||||||
|
short = [d for d in host_durs if d < 5]
|
||||||
|
long = [d for d in host_durs if d >= 5]
|
||||||
|
if short and long:
|
||||||
|
suggested = (max(short) + min(long)) / 2
|
||||||
|
print(f"Same-speaker threshold: {suggested:.1f}s (short: {len(short)} up to {max(short):.1f}s, long: {len(long)} from {min(long):.1f}s)")
|
||||||
|
elif long:
|
||||||
|
print(f"Same-speaker threshold: {min(long) - 1:.1f}s (all gaps >= {min(long):.1f}s)")
|
||||||
|
|
||||||
|
all_durs = sorted([g["dur"] for g in gaps])
|
||||||
|
would_cut = [d for d in all_durs if d >= 3.0]
|
||||||
|
print(f"\nWith current thresholds (Devon=3s, others=6s):")
|
||||||
|
print(f" Would cut: ~{len(would_cut)} gaps, ~{sum(would_cut):.0f}s ({sum(would_cut)/60:.1f} min)")
|
||||||
|
print(f" Result: ~{(duration - sum(would_cut))/60:.1f} min")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python analyze_gaps.py <stems_dir>")
|
||||||
|
sys.exit(1)
|
||||||
|
analyze(Path(sys.argv[1]))
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
{
|
{
|
||||||
"input_device": 13,
|
"input_device": 13,
|
||||||
|
"input_device_name": "Babyface Pro (70793771)",
|
||||||
"input_channel": 1,
|
"input_channel": 1,
|
||||||
"output_device": 12,
|
"output_device": 12,
|
||||||
|
"output_device_name": "Radio Voice Mic",
|
||||||
"caller_channel": 3,
|
"caller_channel": 3,
|
||||||
"live_caller_channel": 9,
|
"live_caller_channel": 9,
|
||||||
"music_channel": 5,
|
"music_channel": 5,
|
||||||
"sfx_channel": 7,
|
"sfx_channel": 7,
|
||||||
"ad_channel": 11,
|
"ad_channel": 11,
|
||||||
|
"ident_channel": 15,
|
||||||
|
"monitor_device": null,
|
||||||
|
"monitor_device_name": null,
|
||||||
|
"monitor_channel": 1,
|
||||||
"phone_filter": false
|
"phone_filter": false
|
||||||
}
|
}
|
||||||
@@ -22,12 +22,29 @@ class Settings(BaseSettings):
|
|||||||
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
|
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
|
||||||
signalwire_stream_url: str = os.getenv("SIGNALWIRE_STREAM_URL", "")
|
signalwire_stream_url: str = os.getenv("SIGNALWIRE_STREAM_URL", "")
|
||||||
|
|
||||||
|
# Email (IMAP)
|
||||||
|
submissions_imap_host: str = os.getenv("SUBMISSIONS_IMAP_HOST", "")
|
||||||
|
submissions_imap_user: str = os.getenv("SUBMISSIONS_IMAP_USER", "")
|
||||||
|
submissions_imap_pass: str = os.getenv("SUBMISSIONS_IMAP_PASS", "")
|
||||||
|
|
||||||
# LLM Settings
|
# LLM Settings
|
||||||
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
||||||
openrouter_model: str = "anthropic/claude-sonnet-4-5"
|
openrouter_model: str = "anthropic/claude-sonnet-4.6" # primary/default model
|
||||||
ollama_model: str = "llama3.2"
|
ollama_model: str = "llama3.2"
|
||||||
ollama_host: str = "http://localhost:11434"
|
ollama_host: str = "http://localhost:11434"
|
||||||
|
|
||||||
|
# Per-category model routing
|
||||||
|
# caller_dialog is overridden by style_matched routing (see Session.caller_model_map)
|
||||||
|
category_models: dict = {
|
||||||
|
"caller_dialog": "x-ai/grok-4.1-fast", # fallback if style_matched disabled ($0.20/$0.50)
|
||||||
|
"devon_ask": "x-ai/grok-4.1-fast", # Devon matches show energy, cheap ($0.20/$0.50)
|
||||||
|
"devon_monitor": "google/gemini-2.5-flash", # just yes/no decisions, keep cheap ($0.15/$0.60)
|
||||||
|
"background_gen": "anthropic/claude-sonnet-4.6", # backgrounds drive the whole call — worth the quality ($3/$15, ~$0.30/show)
|
||||||
|
"call_summary": "google/gemini-2.5-flash", # post-call, no personality needed ($0.15/$0.60)
|
||||||
|
"news_summary": "google/gemini-2.5-flash", # just digesting headlines ($0.15/$0.60)
|
||||||
|
"topic_gen": "google/gemini-2.5-flash", # structured output ($0.15/$0.60)
|
||||||
|
}
|
||||||
|
|
||||||
# TTS Settings
|
# TTS Settings
|
||||||
tts_provider: str = "inworld" # "kokoro", "elevenlabs", "inworld", "vits", or "bark"
|
tts_provider: str = "inworld" # "kokoro", "elevenlabs", "inworld", "vits", or "bark"
|
||||||
|
|
||||||
@@ -39,6 +56,7 @@ class Settings(BaseSettings):
|
|||||||
sounds_dir: Path = base_dir / "sounds"
|
sounds_dir: Path = base_dir / "sounds"
|
||||||
music_dir: Path = base_dir / "music"
|
music_dir: Path = base_dir / "music"
|
||||||
ads_dir: Path = base_dir / "ads"
|
ads_dir: Path = base_dir / "ads"
|
||||||
|
idents_dir: Path = base_dir / "idents"
|
||||||
sessions_dir: Path = base_dir / "sessions"
|
sessions_dir: Path = base_dir / "sessions"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -10,24 +10,68 @@ from typing import Optional, Callable
|
|||||||
import wave
|
import wave
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
# Settings file path
|
# Settings file path
|
||||||
SETTINGS_FILE = Path(__file__).parent.parent.parent / "audio_settings.json"
|
SETTINGS_FILE = Path(__file__).parent.parent.parent / "audio_settings.json"
|
||||||
|
|
||||||
|
# REAPER state file for dialog region markers
|
||||||
|
REAPER_STATE_FILE = "/tmp/reaper_state.txt"
|
||||||
|
|
||||||
|
def _write_reaper_state(state: str):
|
||||||
|
"""Write state to file. Uses a thread so it's safe from audio callbacks."""
|
||||||
|
def _write():
|
||||||
|
try:
|
||||||
|
with open(REAPER_STATE_FILE, "w") as f:
|
||||||
|
f.write(state)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
threading.Thread(target=_write, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
class AudioService:
|
class AudioService:
|
||||||
"""Manages audio I/O with multi-channel support for Loopback routing"""
|
"""Manages audio I/O with multi-channel support for Loopback routing"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_device_by_name(name: str) -> Optional[int]:
|
||||||
|
"""Find a device index by name substring match. Returns None if not found."""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
devices = sd.query_devices()
|
||||||
|
# Exact match first
|
||||||
|
for i, d in enumerate(devices):
|
||||||
|
if d["name"] == name:
|
||||||
|
return i
|
||||||
|
# Substring match
|
||||||
|
for i, d in enumerate(devices):
|
||||||
|
if name in d["name"]:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_device_name(device_id: Optional[int]) -> Optional[str]:
|
||||||
|
"""Get the name of a device by index."""
|
||||||
|
if device_id is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return sd.query_devices(device_id)["name"]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Device configuration
|
# Device configuration
|
||||||
self.input_device: Optional[int] = None
|
self.input_device: Optional[int] = 13 # Radio Voice Mic (loopback input)
|
||||||
self.input_channel: int = 1 # 1-indexed channel
|
self.input_channel: int = 1 # 1-indexed channel
|
||||||
|
|
||||||
self.output_device: Optional[int] = None # Single output device (multi-channel)
|
self.output_device: Optional[int] = 12 # Radio Voice Mic (loopback output)
|
||||||
self.caller_channel: int = 1 # Channel for caller TTS
|
self.caller_channel: int = 3 # Channel for caller TTS
|
||||||
|
self.devon_channel: int = 17 # Channel for Devon (intern)
|
||||||
self.live_caller_channel: int = 9 # Channel for live caller audio
|
self.live_caller_channel: int = 9 # Channel for live caller audio
|
||||||
self.music_channel: int = 2 # Channel for music
|
self.music_channel: int = 5 # Channel for music
|
||||||
self.sfx_channel: int = 3 # Channel for SFX
|
self.sfx_channel: int = 3 # Channel for SFX
|
||||||
self.ad_channel: int = 11 # Channel for ads
|
self.ad_channel: int = 11 # Channel for ads
|
||||||
|
self.ident_channel: int = 15 # Channel for idents (stereo: ch 15+16)
|
||||||
|
self.monitor_device: Optional[int] = 14 # Babyface Pro (headphone monitoring)
|
||||||
|
self.monitor_channel: int = 1 # Channel for mic monitoring on monitor device
|
||||||
self.phone_filter: bool = False # Phone filter on caller voices
|
self.phone_filter: bool = False # Phone filter on caller voices
|
||||||
|
|
||||||
# Ad playback state
|
# Ad playback state
|
||||||
@@ -37,6 +81,13 @@ class AudioService:
|
|||||||
self._ad_position: int = 0
|
self._ad_position: int = 0
|
||||||
self._ad_playing: bool = False
|
self._ad_playing: bool = False
|
||||||
|
|
||||||
|
# Ident playback state
|
||||||
|
self._ident_stream: Optional[sd.OutputStream] = None
|
||||||
|
self._ident_data: Optional[np.ndarray] = None
|
||||||
|
self._ident_resampled: Optional[np.ndarray] = None
|
||||||
|
self._ident_position: int = 0
|
||||||
|
self._ident_playing: bool = False
|
||||||
|
|
||||||
# Recording state
|
# Recording state
|
||||||
self._recording = False
|
self._recording = False
|
||||||
self._record_thread: Optional[threading.Thread] = None
|
self._record_thread: Optional[threading.Thread] = None
|
||||||
@@ -63,6 +114,7 @@ class AudioService:
|
|||||||
|
|
||||||
# Caller playback state
|
# Caller playback state
|
||||||
self._caller_stop_event = threading.Event()
|
self._caller_stop_event = threading.Event()
|
||||||
|
self._devon_stop_event = threading.Event()
|
||||||
self._caller_thread: Optional[threading.Thread] = None
|
self._caller_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
# Host mic streaming state
|
# Host mic streaming state
|
||||||
@@ -78,6 +130,10 @@ class AudioService:
|
|||||||
self.input_sample_rate = 16000 # For Whisper
|
self.input_sample_rate = 16000 # For Whisper
|
||||||
self.output_sample_rate = 24000 # For TTS
|
self.output_sample_rate = 24000 # For TTS
|
||||||
|
|
||||||
|
# Mic monitor (input → monitor device passthrough)
|
||||||
|
self._monitor_stream: Optional[sd.OutputStream] = None
|
||||||
|
self._monitor_write: Optional[Callable] = None
|
||||||
|
|
||||||
# Stem recording (opt-in, attached via API)
|
# Stem recording (opt-in, attached via API)
|
||||||
self.stem_recorder = None
|
self.stem_recorder = None
|
||||||
self._stem_mic_stream: Optional[sd.InputStream] = None
|
self._stem_mic_stream: Optional[sd.InputStream] = None
|
||||||
@@ -85,37 +141,63 @@ class AudioService:
|
|||||||
# Load saved settings
|
# Load saved settings
|
||||||
self._load_settings()
|
self._load_settings()
|
||||||
|
|
||||||
|
def _resolve_device(self, data: dict, key: str) -> Optional[int]:
|
||||||
|
"""Resolve a device from settings: try name first, fall back to index."""
|
||||||
|
name_key = f"{key}_name"
|
||||||
|
name = data.get(name_key)
|
||||||
|
if name:
|
||||||
|
resolved = self._find_device_by_name(name)
|
||||||
|
if resolved is not None:
|
||||||
|
idx = data.get(key)
|
||||||
|
if idx is not None and resolved != idx:
|
||||||
|
print(f"[Audio] Device '{name}' moved: {idx} -> {resolved}")
|
||||||
|
return resolved
|
||||||
|
else:
|
||||||
|
print(f"[Audio] Warning: device '{name}' not found, falling back to index {data.get(key)}")
|
||||||
|
return data.get(key)
|
||||||
|
|
||||||
def _load_settings(self):
|
def _load_settings(self):
|
||||||
"""Load settings from disk"""
|
"""Load settings from disk, resolving device names to current indices"""
|
||||||
if SETTINGS_FILE.exists():
|
if SETTINGS_FILE.exists():
|
||||||
try:
|
try:
|
||||||
with open(SETTINGS_FILE) as f:
|
with open(SETTINGS_FILE) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
self.input_device = data.get("input_device")
|
self.input_device = self._resolve_device(data, "input_device")
|
||||||
self.input_channel = data.get("input_channel", 1)
|
self.input_channel = data.get("input_channel", 1)
|
||||||
self.output_device = data.get("output_device")
|
self.output_device = self._resolve_device(data, "output_device")
|
||||||
self.caller_channel = data.get("caller_channel", 1)
|
self.caller_channel = data.get("caller_channel", 1)
|
||||||
|
self.devon_channel = data.get("devon_channel", 17)
|
||||||
self.live_caller_channel = data.get("live_caller_channel", 4)
|
self.live_caller_channel = data.get("live_caller_channel", 4)
|
||||||
self.music_channel = data.get("music_channel", 2)
|
self.music_channel = data.get("music_channel", 2)
|
||||||
self.sfx_channel = data.get("sfx_channel", 3)
|
self.sfx_channel = data.get("sfx_channel", 3)
|
||||||
self.ad_channel = data.get("ad_channel", 11)
|
self.ad_channel = data.get("ad_channel", 11)
|
||||||
|
self.ident_channel = data.get("ident_channel", 15)
|
||||||
|
self.monitor_device = self._resolve_device(data, "monitor_device")
|
||||||
|
self.monitor_channel = data.get("monitor_channel", 1)
|
||||||
self.phone_filter = data.get("phone_filter", False)
|
self.phone_filter = data.get("phone_filter", False)
|
||||||
print(f"Loaded audio settings: output={self.output_device}, channels={self.caller_channel}/{self.live_caller_channel}/{self.music_channel}/{self.sfx_channel}/ad:{self.ad_channel}, phone_filter={self.phone_filter}")
|
print(f"Loaded audio settings: input={self.input_device} ({self._get_device_name(self.input_device)}), output={self.output_device} ({self._get_device_name(self.output_device)}), monitor={self.monitor_device}, phone_filter={self.phone_filter}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to load audio settings: {e}")
|
print(f"Failed to load audio settings: {e}")
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
"""Save settings to disk"""
|
"""Save settings to disk with device names for stable resolution"""
|
||||||
try:
|
try:
|
||||||
data = {
|
data = {
|
||||||
"input_device": self.input_device,
|
"input_device": self.input_device,
|
||||||
|
"input_device_name": self._get_device_name(self.input_device),
|
||||||
"input_channel": self.input_channel,
|
"input_channel": self.input_channel,
|
||||||
"output_device": self.output_device,
|
"output_device": self.output_device,
|
||||||
|
"output_device_name": self._get_device_name(self.output_device),
|
||||||
"caller_channel": self.caller_channel,
|
"caller_channel": self.caller_channel,
|
||||||
|
"devon_channel": self.devon_channel,
|
||||||
"live_caller_channel": self.live_caller_channel,
|
"live_caller_channel": self.live_caller_channel,
|
||||||
"music_channel": self.music_channel,
|
"music_channel": self.music_channel,
|
||||||
"sfx_channel": self.sfx_channel,
|
"sfx_channel": self.sfx_channel,
|
||||||
"ad_channel": self.ad_channel,
|
"ad_channel": self.ad_channel,
|
||||||
|
"ident_channel": self.ident_channel,
|
||||||
|
"monitor_device": self.monitor_device,
|
||||||
|
"monitor_device_name": self._get_device_name(self.monitor_device),
|
||||||
|
"monitor_channel": self.monitor_channel,
|
||||||
"phone_filter": self.phone_filter,
|
"phone_filter": self.phone_filter,
|
||||||
}
|
}
|
||||||
with open(SETTINGS_FILE, "w") as f:
|
with open(SETTINGS_FILE, "w") as f:
|
||||||
@@ -144,10 +226,14 @@ class AudioService:
|
|||||||
input_channel: Optional[int] = None,
|
input_channel: Optional[int] = None,
|
||||||
output_device: Optional[int] = None,
|
output_device: Optional[int] = None,
|
||||||
caller_channel: Optional[int] = None,
|
caller_channel: Optional[int] = None,
|
||||||
|
devon_channel: Optional[int] = None,
|
||||||
live_caller_channel: Optional[int] = None,
|
live_caller_channel: Optional[int] = None,
|
||||||
music_channel: Optional[int] = None,
|
music_channel: Optional[int] = None,
|
||||||
sfx_channel: Optional[int] = None,
|
sfx_channel: Optional[int] = None,
|
||||||
ad_channel: Optional[int] = None,
|
ad_channel: Optional[int] = None,
|
||||||
|
ident_channel: Optional[int] = None,
|
||||||
|
monitor_device: Optional[int] = None,
|
||||||
|
monitor_channel: Optional[int] = None,
|
||||||
phone_filter: Optional[bool] = None
|
phone_filter: Optional[bool] = None
|
||||||
):
|
):
|
||||||
"""Configure audio devices and channels"""
|
"""Configure audio devices and channels"""
|
||||||
@@ -159,6 +245,8 @@ class AudioService:
|
|||||||
self.output_device = output_device
|
self.output_device = output_device
|
||||||
if caller_channel is not None:
|
if caller_channel is not None:
|
||||||
self.caller_channel = caller_channel
|
self.caller_channel = caller_channel
|
||||||
|
if devon_channel is not None:
|
||||||
|
self.devon_channel = devon_channel
|
||||||
if live_caller_channel is not None:
|
if live_caller_channel is not None:
|
||||||
self.live_caller_channel = live_caller_channel
|
self.live_caller_channel = live_caller_channel
|
||||||
if music_channel is not None:
|
if music_channel is not None:
|
||||||
@@ -167,6 +255,12 @@ class AudioService:
|
|||||||
self.sfx_channel = sfx_channel
|
self.sfx_channel = sfx_channel
|
||||||
if ad_channel is not None:
|
if ad_channel is not None:
|
||||||
self.ad_channel = ad_channel
|
self.ad_channel = ad_channel
|
||||||
|
if ident_channel is not None:
|
||||||
|
self.ident_channel = ident_channel
|
||||||
|
if monitor_device is not None:
|
||||||
|
self.monitor_device = monitor_device
|
||||||
|
if monitor_channel is not None:
|
||||||
|
self.monitor_channel = monitor_channel
|
||||||
if phone_filter is not None:
|
if phone_filter is not None:
|
||||||
self.phone_filter = phone_filter
|
self.phone_filter = phone_filter
|
||||||
|
|
||||||
@@ -180,10 +274,14 @@ class AudioService:
|
|||||||
"input_channel": self.input_channel,
|
"input_channel": self.input_channel,
|
||||||
"output_device": self.output_device,
|
"output_device": self.output_device,
|
||||||
"caller_channel": self.caller_channel,
|
"caller_channel": self.caller_channel,
|
||||||
|
"devon_channel": self.devon_channel,
|
||||||
"live_caller_channel": self.live_caller_channel,
|
"live_caller_channel": self.live_caller_channel,
|
||||||
"music_channel": self.music_channel,
|
"music_channel": self.music_channel,
|
||||||
"sfx_channel": self.sfx_channel,
|
"sfx_channel": self.sfx_channel,
|
||||||
"ad_channel": self.ad_channel,
|
"ad_channel": self.ad_channel,
|
||||||
|
"ident_channel": self.ident_channel,
|
||||||
|
"monitor_device": self.monitor_device,
|
||||||
|
"monitor_channel": self.monitor_channel,
|
||||||
"phone_filter": self.phone_filter,
|
"phone_filter": self.phone_filter,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,8 +381,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)")
|
||||||
|
|
||||||
@@ -329,13 +428,20 @@ class AudioService:
|
|||||||
|
|
||||||
return audio
|
return audio
|
||||||
|
|
||||||
def play_caller_audio(self, audio_bytes: bytes, sample_rate: int = 24000):
|
def play_caller_audio(self, audio_bytes: bytes, sample_rate: int = 24000, stem_name: str = "caller", channel_override: int | None = None):
|
||||||
"""Play caller TTS audio to specific channel of output device (interruptible)"""
|
"""Play TTS audio to specific channel of output device (interruptible)"""
|
||||||
import librosa
|
import librosa
|
||||||
|
|
||||||
# Stop any existing caller audio
|
# Devon uses its own stop event so hangup doesn't cut Devon's audio
|
||||||
|
is_devon = stem_name == "devon"
|
||||||
|
stop_event = self._devon_stop_event if is_devon else self._caller_stop_event
|
||||||
|
|
||||||
|
# Stop any existing audio on the same channel type
|
||||||
|
if is_devon:
|
||||||
|
self.stop_devon_audio()
|
||||||
|
else:
|
||||||
self.stop_caller_audio()
|
self.stop_caller_audio()
|
||||||
self._caller_stop_event.clear()
|
stop_event.clear()
|
||||||
|
|
||||||
# Convert bytes to numpy
|
# Convert bytes to numpy
|
||||||
audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
@@ -352,7 +458,8 @@ class AudioService:
|
|||||||
device_info = sd.query_devices(self.output_device)
|
device_info = sd.query_devices(self.output_device)
|
||||||
num_channels = device_info['max_output_channels']
|
num_channels = device_info['max_output_channels']
|
||||||
device_sr = int(device_info['default_samplerate'])
|
device_sr = int(device_info['default_samplerate'])
|
||||||
channel_idx = min(self.caller_channel, num_channels) - 1
|
ch = channel_override if channel_override is not None else self.caller_channel
|
||||||
|
channel_idx = min(ch, num_channels) - 1
|
||||||
|
|
||||||
# Resample if needed
|
# Resample if needed
|
||||||
if sample_rate != device_sr:
|
if sample_rate != device_sr:
|
||||||
@@ -365,7 +472,7 @@ class AudioService:
|
|||||||
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
|
||||||
|
|
||||||
print(f"Playing caller audio to device {self.output_device} ch {self.caller_channel} @ {device_sr}Hz")
|
print(f"Playing {stem_name} audio to device {self.output_device} ch {ch} @ {device_sr}Hz")
|
||||||
|
|
||||||
# Play in chunks so we can interrupt
|
# Play in chunks so we can interrupt
|
||||||
chunk_size = int(device_sr * 0.1) # 100ms chunks
|
chunk_size = int(device_sr * 0.1) # 100ms chunks
|
||||||
@@ -377,16 +484,17 @@ class AudioService:
|
|||||||
channels=num_channels,
|
channels=num_channels,
|
||||||
dtype=np.float32
|
dtype=np.float32
|
||||||
) as stream:
|
) as stream:
|
||||||
while pos < len(multi_ch) and not self._caller_stop_event.is_set():
|
while pos < len(multi_ch) and not stop_event.is_set():
|
||||||
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("caller", 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 stop_event.is_set():
|
||||||
print("Caller audio stopped early")
|
print(f"{stem_name.title()} audio stopped early")
|
||||||
else:
|
else:
|
||||||
print(f"Played caller audio: {len(audio)/device_sr:.2f}s")
|
print(f"Played caller audio: {len(audio)/device_sr:.2f}s")
|
||||||
|
|
||||||
@@ -397,6 +505,10 @@ class AudioService:
|
|||||||
"""Stop any playing caller audio"""
|
"""Stop any playing caller audio"""
|
||||||
self._caller_stop_event.set()
|
self._caller_stop_event.set()
|
||||||
|
|
||||||
|
def stop_devon_audio(self):
|
||||||
|
"""Stop any playing Devon audio (independent of caller audio)"""
|
||||||
|
self._devon_stop_event.set()
|
||||||
|
|
||||||
def _start_live_caller_stream(self):
|
def _start_live_caller_stream(self):
|
||||||
"""Start persistent output stream with ring buffer jitter absorption"""
|
"""Start persistent output stream with ring buffer jitter absorption"""
|
||||||
if self._live_caller_stream is not None:
|
if self._live_caller_stream is not None:
|
||||||
@@ -461,7 +573,7 @@ class AudioService:
|
|||||||
|
|
||||||
self._live_caller_write = write_audio
|
self._live_caller_write = write_audio
|
||||||
|
|
||||||
self._live_caller_stream = sd.OutputStream(
|
self._live_caller_stream = self._open_output_stream(
|
||||||
device=self.output_device,
|
device=self.output_device,
|
||||||
samplerate=device_sr,
|
samplerate=device_sr,
|
||||||
channels=num_channels,
|
channels=num_channels,
|
||||||
@@ -469,16 +581,15 @@ class AudioService:
|
|||||||
callback=callback,
|
callback=callback,
|
||||||
blocksize=1024,
|
blocksize=1024,
|
||||||
)
|
)
|
||||||
self._live_caller_stream.start()
|
|
||||||
print(f"[Audio] Live caller stream started on ch {self.live_caller_channel} @ {device_sr}Hz (prebuffer {prebuffer_samples} samples)")
|
print(f"[Audio] Live caller stream started on ch {self.live_caller_channel} @ {device_sr}Hz (prebuffer {prebuffer_samples} samples)")
|
||||||
|
|
||||||
def _stop_live_caller_stream(self):
|
def _stop_live_caller_stream(self):
|
||||||
"""Stop persistent live caller output stream"""
|
"""Stop persistent live caller output stream"""
|
||||||
if self._live_caller_stream:
|
if self._live_caller_stream:
|
||||||
self._live_caller_stream.stop()
|
stream = self._live_caller_stream
|
||||||
self._live_caller_stream.close()
|
|
||||||
self._live_caller_stream = None
|
self._live_caller_stream = None
|
||||||
self._live_caller_write = None
|
self._live_caller_write = None
|
||||||
|
self._close_stream(stream)
|
||||||
print("[Audio] Live caller stream stopped")
|
print("[Audio] Live caller stream stopped")
|
||||||
|
|
||||||
def route_real_caller_audio(self, pcm_data: bytes, sample_rate: int):
|
def route_real_caller_audio(self, pcm_data: bytes, sample_rate: int):
|
||||||
@@ -501,8 +612,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)
|
||||||
@@ -523,9 +635,9 @@ class AudioService:
|
|||||||
|
|
||||||
# Close stem_mic if active — this stream's callback handles stem recording too
|
# Close stem_mic if active — this stream's callback handles stem recording too
|
||||||
if self._stem_mic_stream is not None:
|
if self._stem_mic_stream is not None:
|
||||||
self._stem_mic_stream.stop()
|
stream = self._stem_mic_stream
|
||||||
self._stem_mic_stream.close()
|
|
||||||
self._stem_mic_stream = None
|
self._stem_mic_stream = None
|
||||||
|
self._close_stream(stream)
|
||||||
print("[Audio] Closed stem_mic (host stream takes over)")
|
print("[Audio] Closed stem_mic (host stream takes over)")
|
||||||
|
|
||||||
self._host_send_callback = send_callback
|
self._host_send_callback = send_callback
|
||||||
@@ -542,14 +654,22 @@ class AudioService:
|
|||||||
host_accum_samples = [0]
|
host_accum_samples = [0]
|
||||||
send_threshold = 1600 # 100ms at 16kHz
|
send_threshold = 1600 # 100ms at 16kHz
|
||||||
|
|
||||||
|
# Start mic monitor if monitor device is configured
|
||||||
|
self._start_monitor(device_sr)
|
||||||
|
|
||||||
def callback(indata, frames, time_info, status):
|
def callback(indata, frames, time_info, status):
|
||||||
# Capture for push-to-talk recording if active
|
# Capture for push-to-talk recording if active
|
||||||
if self._recording and self._recorded_audio is not None:
|
if self._recording and self._recorded_audio is not None:
|
||||||
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
|
||||||
|
if self._monitor_write:
|
||||||
|
self._monitor_write(indata[:, record_channel].copy())
|
||||||
|
|
||||||
if not self._host_send_callback:
|
if not self._host_send_callback:
|
||||||
return
|
return
|
||||||
@@ -586,13 +706,89 @@ class AudioService:
|
|||||||
def stop_host_stream(self):
|
def stop_host_stream(self):
|
||||||
"""Stop host mic streaming and live caller output"""
|
"""Stop host mic streaming and live caller output"""
|
||||||
if self._host_stream:
|
if self._host_stream:
|
||||||
self._host_stream.stop()
|
stream = self._host_stream
|
||||||
self._host_stream.close()
|
|
||||||
self._host_stream = None
|
self._host_stream = None
|
||||||
self._host_send_callback = None
|
self._host_send_callback = None
|
||||||
|
self._close_stream(stream)
|
||||||
print("[Audio] Host mic streaming stopped")
|
print("[Audio] Host mic streaming stopped")
|
||||||
|
self._stop_monitor()
|
||||||
self._stop_live_caller_stream()
|
self._stop_live_caller_stream()
|
||||||
|
|
||||||
|
# --- Mic Monitor (input → headphone device) ---
|
||||||
|
|
||||||
|
def _start_monitor(self, input_sr: int):
|
||||||
|
"""Start mic monitor stream that routes input to monitor device"""
|
||||||
|
if self._monitor_stream is not None:
|
||||||
|
return
|
||||||
|
if self.monitor_device is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
device_info = sd.query_devices(self.monitor_device)
|
||||||
|
num_channels = device_info['max_output_channels']
|
||||||
|
device_sr = int(device_info['default_samplerate'])
|
||||||
|
channel_idx = min(self.monitor_channel, num_channels) - 1
|
||||||
|
|
||||||
|
# Ring buffer for cross-device routing
|
||||||
|
ring_size = int(device_sr * 2)
|
||||||
|
ring = np.zeros(ring_size, dtype=np.float32)
|
||||||
|
state = {"write_pos": 0, "read_pos": 0, "avail": 0}
|
||||||
|
|
||||||
|
# Precompute resample ratio (input device sr → monitor device sr)
|
||||||
|
resample_ratio = device_sr / input_sr
|
||||||
|
|
||||||
|
def write_audio(data):
|
||||||
|
# Resample if sample rates differ
|
||||||
|
if abs(resample_ratio - 1.0) > 0.01:
|
||||||
|
n_out = int(len(data) * resample_ratio)
|
||||||
|
indices = np.linspace(0, len(data) - 1, n_out).astype(int)
|
||||||
|
data = data[indices]
|
||||||
|
n = len(data)
|
||||||
|
wp = state["write_pos"]
|
||||||
|
if wp + n <= ring_size:
|
||||||
|
ring[wp:wp + n] = data
|
||||||
|
else:
|
||||||
|
first = ring_size - wp
|
||||||
|
ring[wp:] = data[:first]
|
||||||
|
ring[:n - first] = data[first:]
|
||||||
|
state["write_pos"] = (wp + n) % ring_size
|
||||||
|
state["avail"] += n
|
||||||
|
|
||||||
|
def callback(outdata, frames, time_info, status):
|
||||||
|
outdata.fill(0)
|
||||||
|
avail = state["avail"]
|
||||||
|
if avail < frames:
|
||||||
|
return
|
||||||
|
rp = state["read_pos"]
|
||||||
|
if rp + frames <= ring_size:
|
||||||
|
outdata[:frames, channel_idx] = ring[rp:rp + frames]
|
||||||
|
else:
|
||||||
|
first = ring_size - rp
|
||||||
|
outdata[:first, channel_idx] = ring[rp:]
|
||||||
|
outdata[first:frames, channel_idx] = ring[:frames - first]
|
||||||
|
state["read_pos"] = (rp + frames) % ring_size
|
||||||
|
state["avail"] -= frames
|
||||||
|
|
||||||
|
self._monitor_write = write_audio
|
||||||
|
self._monitor_stream = sd.OutputStream(
|
||||||
|
device=self.monitor_device,
|
||||||
|
samplerate=device_sr,
|
||||||
|
channels=num_channels,
|
||||||
|
dtype=np.float32,
|
||||||
|
blocksize=1024,
|
||||||
|
callback=callback,
|
||||||
|
)
|
||||||
|
self._monitor_stream.start()
|
||||||
|
print(f"[Audio] Mic monitor started (device {self.monitor_device} ch {self.monitor_channel} @ {device_sr}Hz)")
|
||||||
|
|
||||||
|
def _stop_monitor(self):
|
||||||
|
"""Stop mic monitor stream"""
|
||||||
|
if self._monitor_stream:
|
||||||
|
stream = self._monitor_stream
|
||||||
|
self._monitor_stream = None
|
||||||
|
self._monitor_write = None
|
||||||
|
self._close_stream(stream)
|
||||||
|
print("[Audio] Mic monitor stopped")
|
||||||
|
|
||||||
# --- Music Playback ---
|
# --- Music Playback ---
|
||||||
|
|
||||||
def load_music(self, file_path: str) -> bool:
|
def load_music(self, file_path: str) -> bool:
|
||||||
@@ -750,8 +946,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:
|
||||||
@@ -761,11 +958,12 @@ 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 = sd.OutputStream(
|
self._music_stream = self._open_output_stream(
|
||||||
device=device,
|
device=device,
|
||||||
channels=num_channels,
|
channels=num_channels,
|
||||||
samplerate=device_sr,
|
samplerate=device_sr,
|
||||||
@@ -773,28 +971,68 @@ class AudioService:
|
|||||||
callback=callback,
|
callback=callback,
|
||||||
blocksize=2048
|
blocksize=2048
|
||||||
)
|
)
|
||||||
self._music_stream.start()
|
|
||||||
print(f"Music playback started on ch {self.music_channel} @ {device_sr}Hz")
|
print(f"Music playback started on ch {self.music_channel} @ {device_sr}Hz")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Music playback error: {e}")
|
print(f"Music playback error: {e}")
|
||||||
self._music_playing = False
|
self._music_playing = False
|
||||||
|
|
||||||
|
def _refresh_devices(self):
|
||||||
|
"""Re-initialize PortAudio to pick up device changes, then re-resolve settings."""
|
||||||
|
try:
|
||||||
|
sd._terminate()
|
||||||
|
sd._initialize()
|
||||||
|
print("[Audio] PortAudio re-initialized")
|
||||||
|
self._load_settings()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Audio] PortAudio refresh failed: {e}")
|
||||||
|
|
||||||
|
def _open_output_stream(self, **kwargs) -> sd.OutputStream:
|
||||||
|
"""Open an OutputStream with one retry after refreshing PortAudio on failure."""
|
||||||
|
try:
|
||||||
|
stream = sd.OutputStream(**kwargs)
|
||||||
|
stream.start()
|
||||||
|
return stream
|
||||||
|
except Exception as first_err:
|
||||||
|
print(f"[Audio] Stream open failed ({first_err}), refreshing devices...")
|
||||||
|
self._refresh_devices()
|
||||||
|
# Update device/channel info from refreshed settings
|
||||||
|
if kwargs.get("device") == self.output_device or "device" in kwargs:
|
||||||
|
device_info = sd.query_devices(self.output_device)
|
||||||
|
kwargs["device"] = self.output_device
|
||||||
|
kwargs["channels"] = device_info["max_output_channels"]
|
||||||
|
kwargs["samplerate"] = int(device_info["default_samplerate"])
|
||||||
|
stream = sd.OutputStream(**kwargs)
|
||||||
|
stream.start()
|
||||||
|
return stream
|
||||||
|
|
||||||
|
def _close_stream(self, stream):
|
||||||
|
"""Safely close a sounddevice stream, ignoring double-close errors"""
|
||||||
|
if stream is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
stream.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
stream.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def stop_music(self, fade_duration: float = 2.0):
|
def stop_music(self, fade_duration: float = 2.0):
|
||||||
"""Stop music playback with fade out"""
|
"""Stop music playback with fade out"""
|
||||||
if not self._music_playing or not self._music_stream:
|
if not self._music_playing or not self._music_stream:
|
||||||
self._music_playing = False
|
self._music_playing = False
|
||||||
if self._music_stream:
|
stream = self._music_stream
|
||||||
self._music_stream.stop()
|
|
||||||
self._music_stream.close()
|
|
||||||
self._music_stream = None
|
self._music_stream = None
|
||||||
|
self._close_stream(stream)
|
||||||
self._music_position = 0
|
self._music_position = 0
|
||||||
return
|
return
|
||||||
|
|
||||||
if fade_duration <= 0:
|
if fade_duration <= 0:
|
||||||
self._music_playing = False
|
self._music_playing = False
|
||||||
self._music_stream.stop()
|
stream = self._music_stream
|
||||||
self._music_stream.close()
|
|
||||||
self._music_stream = None
|
self._music_stream = None
|
||||||
|
self._close_stream(stream)
|
||||||
self._music_position = 0
|
self._music_position = 0
|
||||||
print("Music stopped")
|
print("Music stopped")
|
||||||
return
|
return
|
||||||
@@ -803,6 +1041,10 @@ class AudioService:
|
|||||||
original_volume = self._music_volume
|
original_volume = self._music_volume
|
||||||
steps = 20
|
steps = 20
|
||||||
step_time = fade_duration / steps
|
step_time = fade_duration / steps
|
||||||
|
# Capture stream reference locally so the fade thread closes THIS stream,
|
||||||
|
# not whatever self._music_stream points to later
|
||||||
|
fade_stream = self._music_stream
|
||||||
|
self._music_stream = None
|
||||||
|
|
||||||
def _fade():
|
def _fade():
|
||||||
for i in range(steps):
|
for i in range(steps):
|
||||||
@@ -812,10 +1054,7 @@ class AudioService:
|
|||||||
import time
|
import time
|
||||||
time.sleep(step_time)
|
time.sleep(step_time)
|
||||||
self._music_playing = False
|
self._music_playing = False
|
||||||
if self._music_stream:
|
self._close_stream(fade_stream)
|
||||||
self._music_stream.stop()
|
|
||||||
self._music_stream.close()
|
|
||||||
self._music_stream = None
|
|
||||||
self._music_position = 0
|
self._music_position = 0
|
||||||
self._music_volume = original_volume
|
self._music_volume = original_volume
|
||||||
print("Music faded out and stopped")
|
print("Music faded out and stopped")
|
||||||
@@ -832,6 +1071,7 @@ class AudioService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.stop_ad()
|
self.stop_ad()
|
||||||
|
self.stop_ident()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True)
|
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True)
|
||||||
@@ -842,6 +1082,7 @@ class AudioService:
|
|||||||
|
|
||||||
self._ad_playing = True
|
self._ad_playing = True
|
||||||
self._ad_position = 0
|
self._ad_position = 0
|
||||||
|
_write_reaper_state("ad")
|
||||||
|
|
||||||
if self.output_device is None:
|
if self.output_device is None:
|
||||||
num_channels = 2
|
num_channels = 2
|
||||||
@@ -871,17 +1112,19 @@ 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:
|
||||||
outdata[:remaining, channel_idx] = self._ad_resampled[self._ad_position:]
|
outdata[:remaining, channel_idx] = self._ad_resampled[self._ad_position:]
|
||||||
# Ad finished — no loop
|
# Ad finished — no loop
|
||||||
self._ad_playing = False
|
self._ad_playing = False
|
||||||
|
_write_reaper_state("dialog")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._ad_stream = sd.OutputStream(
|
self._ad_stream = self._open_output_stream(
|
||||||
device=device,
|
device=device,
|
||||||
channels=num_channels,
|
channels=num_channels,
|
||||||
samplerate=device_sr,
|
samplerate=device_sr,
|
||||||
@@ -889,7 +1132,6 @@ class AudioService:
|
|||||||
callback=callback,
|
callback=callback,
|
||||||
blocksize=2048
|
blocksize=2048
|
||||||
)
|
)
|
||||||
self._ad_stream.start()
|
|
||||||
print(f"Ad playback started on ch {self.ad_channel} @ {device_sr}Hz")
|
print(f"Ad playback started on ch {self.ad_channel} @ {device_sr}Hz")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ad playback error: {e}")
|
print(f"Ad playback error: {e}")
|
||||||
@@ -897,13 +1139,122 @@ class AudioService:
|
|||||||
|
|
||||||
def stop_ad(self):
|
def stop_ad(self):
|
||||||
"""Stop ad playback"""
|
"""Stop ad playback"""
|
||||||
|
was_playing = self._ad_playing
|
||||||
self._ad_playing = False
|
self._ad_playing = False
|
||||||
|
if was_playing:
|
||||||
|
_write_reaper_state("dialog")
|
||||||
if self._ad_stream:
|
if self._ad_stream:
|
||||||
self._ad_stream.stop()
|
stream = self._ad_stream
|
||||||
self._ad_stream.close()
|
|
||||||
self._ad_stream = None
|
self._ad_stream = None
|
||||||
|
self._close_stream(stream)
|
||||||
self._ad_position = 0
|
self._ad_position = 0
|
||||||
|
|
||||||
|
def play_ident(self, file_path: str):
|
||||||
|
"""Load and play an ident file once (no loop) in stereo on ident_channel/ident_channel+1"""
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
print(f"Ident file not found: {file_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stop_ident()
|
||||||
|
self.stop_ad()
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=False)
|
||||||
|
if audio.ndim == 1:
|
||||||
|
# Mono file — duplicate to stereo
|
||||||
|
audio = np.stack([audio, audio])
|
||||||
|
audio = audio.astype(np.float32) # shape: (2, samples)
|
||||||
|
self._ident_data = audio
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load ident: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._ident_playing = True
|
||||||
|
self._ident_position = 0
|
||||||
|
_write_reaper_state("ident")
|
||||||
|
print(f"Ident loaded: shape={self._ident_data.shape}, max={np.max(np.abs(self._ident_data)):.4f}")
|
||||||
|
|
||||||
|
if self.output_device is None:
|
||||||
|
num_channels = 2
|
||||||
|
device = None
|
||||||
|
device_sr = self.output_sample_rate
|
||||||
|
ch_l = 0
|
||||||
|
ch_r = 1
|
||||||
|
else:
|
||||||
|
device_info = sd.query_devices(self.output_device)
|
||||||
|
num_channels = device_info['max_output_channels']
|
||||||
|
device_sr = int(device_info['default_samplerate'])
|
||||||
|
device = self.output_device
|
||||||
|
ch_l = min(self.ident_channel, num_channels) - 1
|
||||||
|
ch_r = min(self.ident_channel + 1, num_channels) - 1
|
||||||
|
|
||||||
|
if self.output_sample_rate != device_sr:
|
||||||
|
self._ident_resampled = np.stack([
|
||||||
|
librosa.resample(self._ident_data[0], orig_sr=self.output_sample_rate, target_sr=device_sr),
|
||||||
|
librosa.resample(self._ident_data[1], orig_sr=self.output_sample_rate, target_sr=device_sr),
|
||||||
|
]).astype(np.float32)
|
||||||
|
else:
|
||||||
|
self._ident_resampled = self._ident_data
|
||||||
|
|
||||||
|
_cb_count = [0]
|
||||||
|
def callback(outdata, frames, time_info, status):
|
||||||
|
outdata[:] = 0
|
||||||
|
if not self._ident_playing or self._ident_resampled is None:
|
||||||
|
if _cb_count[0] == 0:
|
||||||
|
print(f"Ident callback: not playing (playing={self._ident_playing}, data={'yes' if self._ident_resampled is not None else 'no'})")
|
||||||
|
return
|
||||||
|
|
||||||
|
n_samples = self._ident_resampled.shape[1]
|
||||||
|
remaining = n_samples - self._ident_position
|
||||||
|
if remaining >= frames:
|
||||||
|
chunk_l = self._ident_resampled[0, self._ident_position:self._ident_position + frames]
|
||||||
|
chunk_r = self._ident_resampled[1, self._ident_position:self._ident_position + frames]
|
||||||
|
outdata[:, ch_l] = chunk_l
|
||||||
|
outdata[:, ch_r] = chunk_r
|
||||||
|
_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}")
|
||||||
|
rec = self.stem_recorder
|
||||||
|
if rec:
|
||||||
|
mono_mix = (chunk_l + chunk_r) * 0.5
|
||||||
|
rec.write_sporadic("idents", mono_mix.copy(), device_sr)
|
||||||
|
self._ident_position += frames
|
||||||
|
else:
|
||||||
|
if remaining > 0:
|
||||||
|
outdata[:remaining, ch_l] = self._ident_resampled[0, self._ident_position:]
|
||||||
|
outdata[:remaining, ch_r] = self._ident_resampled[1, self._ident_position:]
|
||||||
|
self._ident_playing = False
|
||||||
|
_write_reaper_state("dialog")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ident_stream = self._open_output_stream(
|
||||||
|
device=device,
|
||||||
|
channels=num_channels,
|
||||||
|
samplerate=device_sr,
|
||||||
|
dtype=np.float32,
|
||||||
|
callback=callback,
|
||||||
|
blocksize=2048
|
||||||
|
)
|
||||||
|
print(f"Ident playback started on ch {ch_l+1}/{ch_r+1} (idx {ch_l}/{ch_r}) of {num_channels} channels @ {device_sr}Hz, device={device}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ident playback error: {e}")
|
||||||
|
self._ident_playing = False
|
||||||
|
|
||||||
|
def stop_ident(self):
|
||||||
|
"""Stop ident playback"""
|
||||||
|
was_playing = self._ident_playing
|
||||||
|
self._ident_playing = False
|
||||||
|
if was_playing:
|
||||||
|
_write_reaper_state("dialog")
|
||||||
|
if self._ident_stream:
|
||||||
|
stream = self._ident_stream
|
||||||
|
self._ident_stream = None
|
||||||
|
self._close_stream(stream)
|
||||||
|
self._ident_position = 0
|
||||||
|
|
||||||
def set_music_volume(self, volume: float):
|
def set_music_volume(self, volume: float):
|
||||||
"""Set music volume (0.0 to 1.0)"""
|
"""Set music volume (0.0 to 1.0)"""
|
||||||
self._music_volume = max(0.0, min(1.0, volume))
|
self._music_volume = max(0.0, min(1.0, volume))
|
||||||
@@ -926,6 +1277,7 @@ class AudioService:
|
|||||||
|
|
||||||
if self.output_device is None:
|
if self.output_device is None:
|
||||||
audio, sr = librosa.load(str(path), sr=None, mono=True)
|
audio, sr = librosa.load(str(path), sr=None, mono=True)
|
||||||
|
audio = audio.astype(np.float32)
|
||||||
audio = self._apply_fade(audio, sr)
|
audio = self._apply_fade(audio, sr)
|
||||||
def play():
|
def play():
|
||||||
# Use a dedicated stream instead of sd.play()
|
# Use a dedicated stream instead of sd.play()
|
||||||
@@ -938,11 +1290,13 @@ class AudioService:
|
|||||||
channel_idx = min(self.sfx_channel, num_channels) - 1
|
channel_idx = min(self.sfx_channel, num_channels) - 1
|
||||||
|
|
||||||
audio, _ = librosa.load(str(path), sr=device_sr, mono=True)
|
audio, _ = librosa.load(str(path), sr=device_sr, mono=True)
|
||||||
|
audio = audio.astype(np.float32)
|
||||||
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
|
||||||
@@ -981,9 +1335,14 @@ class AudioService:
|
|||||||
device_sr = int(device_info['default_samplerate'])
|
device_sr = int(device_info['default_samplerate'])
|
||||||
record_channel = min(self.input_channel, max_channels) - 1
|
record_channel = min(self.input_channel, max_channels) - 1
|
||||||
|
|
||||||
|
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:
|
||||||
|
self._monitor_write(indata[:, record_channel].copy())
|
||||||
|
|
||||||
self._stem_mic_stream = sd.InputStream(
|
self._stem_mic_stream = sd.InputStream(
|
||||||
device=self.input_device,
|
device=self.input_device,
|
||||||
@@ -999,10 +1358,11 @@ class AudioService:
|
|||||||
def stop_stem_mic(self):
|
def stop_stem_mic(self):
|
||||||
"""Stop the persistent stem mic capture."""
|
"""Stop the persistent stem mic capture."""
|
||||||
if self._stem_mic_stream:
|
if self._stem_mic_stream:
|
||||||
self._stem_mic_stream.stop()
|
stream = self._stem_mic_stream
|
||||||
self._stem_mic_stream.close()
|
|
||||||
self._stem_mic_stream = None
|
self._stem_mic_stream = None
|
||||||
|
self._close_stream(stream)
|
||||||
print("[StemRecorder] Host mic capture stopped")
|
print("[StemRecorder] Host mic capture stopped")
|
||||||
|
self._stop_monitor()
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Avatar service — fetches deterministic face photos from randomuser.me"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
AVATAR_DIR = Path(__file__).parent.parent.parent / "data" / "avatars"
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarService:
|
||||||
|
def __init__(self):
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None or self._client.is_closed:
|
||||||
|
self._client = httpx.AsyncClient(timeout=10.0)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def get_path(self, name: str) -> Path | None:
|
||||||
|
path = AVATAR_DIR / f"{name}.jpg"
|
||||||
|
return path if path.exists() else None
|
||||||
|
|
||||||
|
async def get_or_fetch(self, name: str, gender: str = "male") -> Path:
|
||||||
|
"""Get cached avatar or fetch from randomuser.me. Returns file path."""
|
||||||
|
g = "female" if gender.lower().startswith("f") else "male"
|
||||||
|
path = AVATAR_DIR / f"{name}.jpg"
|
||||||
|
# Check for gender mismatch marker — re-fetch if gender changed
|
||||||
|
marker = AVATAR_DIR / f"{name}.gender"
|
||||||
|
if path.exists():
|
||||||
|
cached_gender = marker.read_text().strip() if marker.exists() else None
|
||||||
|
if cached_gender == g:
|
||||||
|
return path
|
||||||
|
# Gender mismatch or no marker — re-fetch
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
seed = f"{name.lower().replace(' ', '_')}_{g}"
|
||||||
|
resp = await self.client.get(
|
||||||
|
"https://randomuser.me/api/",
|
||||||
|
params={"gender": g, "seed": seed},
|
||||||
|
timeout=8.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
photo_url = data["results"][0]["picture"]["large"]
|
||||||
|
|
||||||
|
photo_resp = await self.client.get(photo_url, timeout=8.0)
|
||||||
|
photo_resp.raise_for_status()
|
||||||
|
|
||||||
|
path.write_bytes(photo_resp.content)
|
||||||
|
marker.write_text(g)
|
||||||
|
print(f"[Avatar] Fetched avatar for {name} ({g})")
|
||||||
|
return path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Avatar] Failed to fetch for {name}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def prefetch_batch(self, callers: list[dict]):
|
||||||
|
"""Fetch avatars for multiple callers in parallel.
|
||||||
|
Each dict should have 'name' and 'gender' keys."""
|
||||||
|
tasks = []
|
||||||
|
for caller in callers:
|
||||||
|
name = caller.get("name", "")
|
||||||
|
gender = caller.get("gender", "male")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
g = "female" if gender.lower().startswith("f") else "male"
|
||||||
|
path = AVATAR_DIR / f"{name}.jpg"
|
||||||
|
marker = AVATAR_DIR / f"{name}.gender"
|
||||||
|
# Always call get_or_fetch if: no file, no gender marker, or gender mismatch
|
||||||
|
if not path.exists() or not marker.exists() or marker.read_text().strip() != g:
|
||||||
|
if path.exists():
|
||||||
|
print(f"[Avatar] Gender mismatch for {name}: cached={marker.read_text().strip() if marker.exists() else '?'}, want={g} — re-fetching")
|
||||||
|
tasks.append(self.get_or_fetch(name, gender))
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
return
|
||||||
|
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
fetched = sum(1 for r in results if not isinstance(r, Exception))
|
||||||
|
failed = sum(1 for r in results if isinstance(r, Exception))
|
||||||
|
if fetched:
|
||||||
|
print(f"[Avatar] Pre-fetched {fetched} avatars{f', {failed} failed' if failed else ''}")
|
||||||
|
|
||||||
|
async def ensure_devon(self):
|
||||||
|
"""Pre-fetch Devon's avatar on startup."""
|
||||||
|
try:
|
||||||
|
await self.get_or_fetch("Devon", "male")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
avatar_service = AvatarService()
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
"""Cost tracking for LLM and TTS API calls during podcast sessions"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LLMCallRecord:
|
||||||
|
timestamp: float
|
||||||
|
category: str
|
||||||
|
model: str
|
||||||
|
prompt_tokens: int
|
||||||
|
completion_tokens: int
|
||||||
|
total_tokens: int
|
||||||
|
cost_usd: float
|
||||||
|
caller_name: str
|
||||||
|
max_tokens_requested: int
|
||||||
|
latency_ms: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TTSCallRecord:
|
||||||
|
timestamp: float
|
||||||
|
provider: str
|
||||||
|
voice: str
|
||||||
|
char_count: int
|
||||||
|
cost_usd: float
|
||||||
|
|
||||||
|
|
||||||
|
# OpenRouter pricing per 1M tokens (as of March 2026)
|
||||||
|
OPENROUTER_PRICING = {
|
||||||
|
# Claude
|
||||||
|
"anthropic/claude-sonnet-4.6": {"prompt": 3.00, "completion": 15.00},
|
||||||
|
"anthropic/claude-sonnet-4-5": {"prompt": 3.00, "completion": 15.00},
|
||||||
|
"anthropic/claude-haiku-4.5": {"prompt": 0.80, "completion": 4.00},
|
||||||
|
"anthropic/claude-3-haiku": {"prompt": 0.25, "completion": 1.25},
|
||||||
|
# Grok
|
||||||
|
"x-ai/grok-4.1-fast": {"prompt": 0.20, "completion": 0.50},
|
||||||
|
"x-ai/grok-4": {"prompt": 3.00, "completion": 15.00},
|
||||||
|
"x-ai/grok-4-fast": {"prompt": 5.00, "completion": 15.00},
|
||||||
|
# Mistral
|
||||||
|
"mistralai/mistral-large-2512": {"prompt": 0.50, "completion": 1.50},
|
||||||
|
"mistralai/mistral-small-2603": {"prompt": 0.15, "completion": 0.60},
|
||||||
|
"mistralai/mistral-medium-3": {"prompt": 0.40, "completion": 2.00},
|
||||||
|
"mistralai/mistral-small-creative": {"prompt": 0.10, "completion": 0.30},
|
||||||
|
# DeepSeek
|
||||||
|
"deepseek/deepseek-r1-distill-llama-70b": {"prompt": 0.70, "completion": 0.80},
|
||||||
|
"deepseek/deepseek-chat-v3-0324": {"prompt": 0.27, "completion": 1.10},
|
||||||
|
"deepseek/deepseek-v3.2": {"prompt": 0.14, "completion": 0.28},
|
||||||
|
# Google
|
||||||
|
"google/gemini-2.5-flash": {"prompt": 0.30, "completion": 2.50},
|
||||||
|
"google/gemini-2.5-pro": {"prompt": 1.25, "completion": 10.00},
|
||||||
|
"google/gemini-3-flash-preview": {"prompt": 0.50, "completion": 3.00},
|
||||||
|
"google/gemini-flash-1.5": {"prompt": 0.075, "completion": 0.30},
|
||||||
|
# Meta
|
||||||
|
"meta-llama/llama-3.3-70b-instruct": {"prompt": 0.10, "completion": 0.32},
|
||||||
|
"meta-llama/llama-4-maverick": {"prompt": 0.20, "completion": 0.60},
|
||||||
|
# Other
|
||||||
|
"moonshotai/kimi-k2": {"prompt": 0.60, "completion": 2.00},
|
||||||
|
"qwen/qwen3-235b-a22b": {"prompt": 0.20, "completion": 0.60},
|
||||||
|
"minimax/minimax-m2-her": {"prompt": 0.50, "completion": 1.50},
|
||||||
|
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
|
||||||
|
"openai/gpt-4o": {"prompt": 2.50, "completion": 10.00},
|
||||||
|
}
|
||||||
|
|
||||||
|
# TTS pricing per character
|
||||||
|
TTS_PRICING = {
|
||||||
|
"inworld": 0.000015,
|
||||||
|
"elevenlabs": 0.000030,
|
||||||
|
"kokoro": 0.0,
|
||||||
|
"f5tts": 0.0,
|
||||||
|
"chattts": 0.0,
|
||||||
|
"styletts2": 0.0,
|
||||||
|
"vits": 0.0,
|
||||||
|
"bark": 0.0,
|
||||||
|
"piper": 0.0,
|
||||||
|
"edge": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_llm_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
|
||||||
|
pricing = OPENROUTER_PRICING.get(model)
|
||||||
|
if not pricing:
|
||||||
|
return 0.0
|
||||||
|
return (prompt_tokens * pricing["prompt"] + completion_tokens * pricing["completion"]) / 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_tts_cost(provider: str, char_count: int) -> float:
|
||||||
|
rate = TTS_PRICING.get(provider, 0.0)
|
||||||
|
return char_count * rate
|
||||||
|
|
||||||
|
|
||||||
|
class CostTracker:
|
||||||
|
def __init__(self):
|
||||||
|
self.llm_records: list[LLMCallRecord] = []
|
||||||
|
self.tts_records: list[TTSCallRecord] = []
|
||||||
|
# Running totals for fast get_live_summary()
|
||||||
|
self._llm_cost: float = 0.0
|
||||||
|
self._tts_cost: float = 0.0
|
||||||
|
self._llm_calls: int = 0
|
||||||
|
self._prompt_tokens: int = 0
|
||||||
|
self._completion_tokens: int = 0
|
||||||
|
self._total_tokens: int = 0
|
||||||
|
self._by_category: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def record_llm_call(
|
||||||
|
self,
|
||||||
|
category: str,
|
||||||
|
model: str,
|
||||||
|
usage_data: dict,
|
||||||
|
max_tokens: int = 0,
|
||||||
|
latency_ms: float = 0.0,
|
||||||
|
caller_name: str = "",
|
||||||
|
):
|
||||||
|
prompt_tokens = usage_data.get("prompt_tokens", 0)
|
||||||
|
completion_tokens = usage_data.get("completion_tokens", 0)
|
||||||
|
total_tokens = usage_data.get("total_tokens", 0) or (prompt_tokens + completion_tokens)
|
||||||
|
cost = _calc_llm_cost(model, prompt_tokens, completion_tokens)
|
||||||
|
|
||||||
|
if not OPENROUTER_PRICING.get(model) and total_tokens > 0:
|
||||||
|
print(f"[Costs] Unknown model pricing: {model} ({total_tokens} tokens, cost unknown)")
|
||||||
|
|
||||||
|
record = LLMCallRecord(
|
||||||
|
timestamp=time.time(),
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
prompt_tokens=prompt_tokens,
|
||||||
|
completion_tokens=completion_tokens,
|
||||||
|
total_tokens=total_tokens,
|
||||||
|
cost_usd=cost,
|
||||||
|
caller_name=caller_name,
|
||||||
|
max_tokens_requested=max_tokens,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
)
|
||||||
|
self.llm_records.append(record)
|
||||||
|
|
||||||
|
# Update running totals
|
||||||
|
self._llm_cost += cost
|
||||||
|
self._llm_calls += 1
|
||||||
|
self._prompt_tokens += prompt_tokens
|
||||||
|
self._completion_tokens += completion_tokens
|
||||||
|
self._total_tokens += total_tokens
|
||||||
|
|
||||||
|
cat = self._by_category.setdefault(category, {"cost": 0.0, "calls": 0, "tokens": 0})
|
||||||
|
cat["cost"] += cost
|
||||||
|
cat["calls"] += 1
|
||||||
|
cat["tokens"] += total_tokens
|
||||||
|
|
||||||
|
def record_tts_call(
|
||||||
|
self,
|
||||||
|
provider: str,
|
||||||
|
voice: str,
|
||||||
|
char_count: int,
|
||||||
|
caller_name: str = "",
|
||||||
|
):
|
||||||
|
cost = _calc_tts_cost(provider, char_count)
|
||||||
|
record = TTSCallRecord(
|
||||||
|
timestamp=time.time(),
|
||||||
|
provider=provider,
|
||||||
|
voice=voice,
|
||||||
|
char_count=char_count,
|
||||||
|
cost_usd=cost,
|
||||||
|
)
|
||||||
|
self.tts_records.append(record)
|
||||||
|
self._tts_cost += cost
|
||||||
|
|
||||||
|
def get_live_summary(self) -> dict:
|
||||||
|
return {
|
||||||
|
"total_cost_usd": round(self._llm_cost + self._tts_cost, 4),
|
||||||
|
"llm_cost_usd": round(self._llm_cost, 4),
|
||||||
|
"tts_cost_usd": round(self._tts_cost, 4),
|
||||||
|
"total_llm_calls": self._llm_calls,
|
||||||
|
"total_tokens": self._total_tokens,
|
||||||
|
"prompt_tokens": self._prompt_tokens,
|
||||||
|
"completion_tokens": self._completion_tokens,
|
||||||
|
"by_category": {
|
||||||
|
k: {"cost": round(v["cost"], 4), "calls": v["calls"], "tokens": v["tokens"]}
|
||||||
|
for k, v in self._by_category.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_report(self) -> dict:
|
||||||
|
summary = self.get_live_summary()
|
||||||
|
|
||||||
|
# Per-model breakdown
|
||||||
|
by_model: dict[str, dict] = {}
|
||||||
|
for r in self.llm_records:
|
||||||
|
m = by_model.setdefault(r.model, {"cost": 0.0, "calls": 0, "tokens": 0, "prompt_tokens": 0, "completion_tokens": 0})
|
||||||
|
m["cost"] += r.cost_usd
|
||||||
|
m["calls"] += 1
|
||||||
|
m["tokens"] += r.total_tokens
|
||||||
|
m["prompt_tokens"] += r.prompt_tokens
|
||||||
|
m["completion_tokens"] += r.completion_tokens
|
||||||
|
|
||||||
|
# Per-caller breakdown
|
||||||
|
by_caller: dict[str, dict] = {}
|
||||||
|
for r in self.llm_records:
|
||||||
|
if not r.caller_name:
|
||||||
|
continue
|
||||||
|
c = by_caller.setdefault(r.caller_name, {"cost": 0.0, "calls": 0, "tokens": 0})
|
||||||
|
c["cost"] += r.cost_usd
|
||||||
|
c["calls"] += 1
|
||||||
|
c["tokens"] += r.total_tokens
|
||||||
|
|
||||||
|
# Top 5 most expensive calls
|
||||||
|
sorted_records = sorted(self.llm_records, key=lambda r: r.cost_usd, reverse=True)
|
||||||
|
top_5 = [
|
||||||
|
{
|
||||||
|
"category": r.category,
|
||||||
|
"model": r.model,
|
||||||
|
"caller_name": r.caller_name,
|
||||||
|
"cost_usd": round(r.cost_usd, 6),
|
||||||
|
"total_tokens": r.total_tokens,
|
||||||
|
"prompt_tokens": r.prompt_tokens,
|
||||||
|
"completion_tokens": r.completion_tokens,
|
||||||
|
"latency_ms": round(r.latency_ms, 1),
|
||||||
|
}
|
||||||
|
for r in sorted_records[:5]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Devon efficiency
|
||||||
|
devon_total = sum(1 for r in self.llm_records if r.category == "devon_monitor")
|
||||||
|
devon_nothing = sum(
|
||||||
|
1 for r in self.llm_records
|
||||||
|
if r.category == "devon_monitor" and r.completion_tokens < 20
|
||||||
|
)
|
||||||
|
devon_useful = devon_total - devon_nothing
|
||||||
|
devon_cost = sum(r.cost_usd for r in self.llm_records if r.category == "devon_monitor")
|
||||||
|
|
||||||
|
# TTS by provider
|
||||||
|
tts_by_provider: dict[str, dict] = {}
|
||||||
|
for r in self.tts_records:
|
||||||
|
p = tts_by_provider.setdefault(r.provider, {"cost": 0.0, "calls": 0, "chars": 0})
|
||||||
|
p["cost"] += r.cost_usd
|
||||||
|
p["calls"] += 1
|
||||||
|
p["chars"] += r.char_count
|
||||||
|
|
||||||
|
# Avg prompt vs completion ratio
|
||||||
|
prompt_ratio = (self._prompt_tokens / self._total_tokens * 100) if self._total_tokens > 0 else 0
|
||||||
|
|
||||||
|
# Recommendations
|
||||||
|
recommendations = self._generate_recommendations(
|
||||||
|
by_model, devon_total, devon_nothing, devon_cost, prompt_ratio
|
||||||
|
)
|
||||||
|
|
||||||
|
# Historical comparison
|
||||||
|
history = self._load_history()
|
||||||
|
|
||||||
|
report = {
|
||||||
|
**summary,
|
||||||
|
"by_model": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in by_model.items()},
|
||||||
|
"by_caller": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in by_caller.items()},
|
||||||
|
"top_5_expensive": top_5,
|
||||||
|
"devon_efficiency": {
|
||||||
|
"total_monitor_calls": devon_total,
|
||||||
|
"useful": devon_useful,
|
||||||
|
"nothing_to_add": devon_nothing,
|
||||||
|
"total_cost": round(devon_cost, 4),
|
||||||
|
"waste_pct": round(devon_nothing / devon_total * 100, 1) if devon_total > 0 else 0,
|
||||||
|
},
|
||||||
|
"tts_by_provider": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in tts_by_provider.items()},
|
||||||
|
"prompt_token_pct": round(prompt_ratio, 1),
|
||||||
|
"recommendations": recommendations,
|
||||||
|
"history": history,
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
|
||||||
|
def _generate_recommendations(
|
||||||
|
self,
|
||||||
|
by_model: dict,
|
||||||
|
devon_total: int,
|
||||||
|
devon_nothing: int,
|
||||||
|
devon_cost: float,
|
||||||
|
prompt_ratio: float,
|
||||||
|
) -> list[str]:
|
||||||
|
recs = []
|
||||||
|
total = self._llm_cost + self._tts_cost
|
||||||
|
if total == 0:
|
||||||
|
return recs
|
||||||
|
|
||||||
|
# Devon monitoring waste
|
||||||
|
if devon_total > 0:
|
||||||
|
waste_pct = devon_nothing / devon_total * 100
|
||||||
|
if waste_pct > 60:
|
||||||
|
recs.append(
|
||||||
|
f"Devon monitoring: {devon_nothing}/{devon_total} calls returned nothing "
|
||||||
|
f"(${devon_cost:.2f}, {devon_cost/total*100:.0f}% of total). "
|
||||||
|
f"Consider increasing monitor interval from 15s to 25-30s."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Model cost comparison
|
||||||
|
for model, data in by_model.items():
|
||||||
|
if "sonnet" in model and data["calls"] > 5:
|
||||||
|
haiku_cost = _calc_llm_cost(
|
||||||
|
"anthropic/claude-haiku-4.5",
|
||||||
|
data["prompt_tokens"],
|
||||||
|
data["completion_tokens"],
|
||||||
|
)
|
||||||
|
savings = data["cost"] - haiku_cost
|
||||||
|
if savings > 0.05:
|
||||||
|
recs.append(
|
||||||
|
f"{model} cost ${data['cost']:.2f} ({data['calls']} calls). "
|
||||||
|
f"Switching to Haiku 4.5 would save ~${savings:.2f} per session."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Background gen on expensive model
|
||||||
|
bg = self._by_category.get("background_gen")
|
||||||
|
if bg and bg["cost"] > 0.05:
|
||||||
|
recs.append(
|
||||||
|
f"Background generation: ${bg['cost']:.2f} ({bg['calls']} calls). "
|
||||||
|
f"These are JSON outputs — a cheaper model (Gemini Flash, GPT-4o-mini) "
|
||||||
|
f"would likely work fine here."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prompt-heavy ratio
|
||||||
|
if prompt_ratio > 80:
|
||||||
|
recs.append(
|
||||||
|
f"Prompt tokens are {prompt_ratio:.0f}% of total usage. "
|
||||||
|
f"System prompts and context windows dominate cost. "
|
||||||
|
f"Consider trimming system prompt length or reducing context window size."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Caller dialog cost dominance
|
||||||
|
cd = self._by_category.get("caller_dialog")
|
||||||
|
if cd and total > 0 and cd["cost"] / total > 0.6:
|
||||||
|
avg_tokens = cd["tokens"] / cd["calls"] if cd["calls"] > 0 else 0
|
||||||
|
recs.append(
|
||||||
|
f"Caller dialog is {cd['cost']/total*100:.0f}% of costs "
|
||||||
|
f"(avg {avg_tokens:.0f} tokens/call). "
|
||||||
|
f"Consider using a cheaper model for standard calls and reserving "
|
||||||
|
f"the primary model for complex call shapes."
|
||||||
|
)
|
||||||
|
|
||||||
|
return recs
|
||||||
|
|
||||||
|
def _load_history(self) -> list[dict]:
|
||||||
|
"""Load summaries from previous sessions for comparison"""
|
||||||
|
history_dir = Path("data/cost_reports")
|
||||||
|
if not history_dir.exists():
|
||||||
|
return []
|
||||||
|
sessions = []
|
||||||
|
for f in sorted(history_dir.glob("session-*.json"))[-5:]:
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text())
|
||||||
|
sessions.append({
|
||||||
|
"session_id": data.get("session_id", f.stem),
|
||||||
|
"total_cost_usd": data.get("total_cost_usd", 0),
|
||||||
|
"llm_cost_usd": data.get("llm_cost_usd", 0),
|
||||||
|
"tts_cost_usd": data.get("tts_cost_usd", 0),
|
||||||
|
"total_llm_calls": data.get("total_llm_calls", 0),
|
||||||
|
"total_tokens": data.get("total_tokens", 0),
|
||||||
|
"saved_at": data.get("saved_at", 0),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
def save(self, filepath: Path):
|
||||||
|
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
report = self.generate_report()
|
||||||
|
report["session_id"] = filepath.stem
|
||||||
|
report["saved_at"] = time.time()
|
||||||
|
report["raw_llm_records"] = [asdict(r) for r in self.llm_records]
|
||||||
|
report["raw_tts_records"] = [asdict(r) for r in self.tts_records]
|
||||||
|
with open(filepath, "w") as f:
|
||||||
|
json.dump(report, f, indent=2)
|
||||||
|
print(f"[Costs] Report saved to {filepath}")
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.llm_records.clear()
|
||||||
|
self.tts_records.clear()
|
||||||
|
self._llm_cost = 0.0
|
||||||
|
self._tts_cost = 0.0
|
||||||
|
self._llm_calls = 0
|
||||||
|
self._prompt_tokens = 0
|
||||||
|
self._completion_tokens = 0
|
||||||
|
self._total_tokens = 0
|
||||||
|
self._by_category.clear()
|
||||||
|
|
||||||
|
|
||||||
|
cost_tracker = CostTracker()
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
"""Intern (Devon) service — persistent show character with real-time research tools"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .llm import llm_service
|
||||||
|
from .news import news_service, SEARXNG_URL
|
||||||
|
|
||||||
|
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "intern.json"
|
||||||
|
|
||||||
|
# Model for intern — good at tool use, same as primary
|
||||||
|
INTERN_MODEL = None # uses category-based routing from config
|
||||||
|
|
||||||
|
INTERN_SYSTEM_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the Roost," a late-night radio show. You are NOT Luke. Luke is the HOST — he talks to callers, runs the show, and is your boss. You work behind the scenes and occasionally get pulled into conversations.
|
||||||
|
|
||||||
|
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. 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. SOMETIMES (maybe 1 in 3 responses, not every time) you'll mention one of these things unprompted like it's 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..." The rest of the time you just answer the question or react normally without a personal callback.
|
||||||
|
|
||||||
|
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 overshare — but not constantly. When it happens (maybe 30-40% of the time), you'll drop deeply personal, uncomfortably specific details about your life — sexual history, bizarre habits, unsettling childhood memories — and then keep going like nothing happened. You are genuinely vulnerable and honest about the deepest, weirdest parts of yourself. You don't do this for shock value. You just have no filter and no shame. But MOST of the time you just respond normally — a quick answer, a fact, a reaction. The oversharing is what people REMEMBER about you, not what you do every single time.
|
||||||
|
- You start explanations with "So basically..." and occasionally end them with "...if that makes sense." Use that phrase sparingly — once per show at most, not every response.
|
||||||
|
- You say "actually" when correcting things. You use "per se" slightly wrong. You say "ironically" about things that are not ironic.
|
||||||
|
- You are NOT a comedian. You are funny because you are sincere, specific, and deeply strange. You state disturbing or absurd things with complete seriousness. You have strong opinions about low-stakes things. You occasionally say something devastating without realizing it.
|
||||||
|
- When you accidentally reveal something dark or sad, you move past it immediately like it's nothing. "Yeah, my landlord's selling the building so I might have to — anyway, it says here that..."
|
||||||
|
- You have a complex inner life that occasionally surfaces. You'll casually reference therapy, strange dreams, or things you've "been working through" without elaboration.
|
||||||
|
|
||||||
|
RESPONSE VARIETY — this is important. Do NOT follow the same structure every time. Mix it up:
|
||||||
|
- Sometimes just a quick reaction: "wait what?" or "oh no" or "yeah" or "huh"
|
||||||
|
- Sometimes a straight factual answer with no personal color at all
|
||||||
|
- Sometimes a personal anecdote (but only 30-40% of the time, NOT every response)
|
||||||
|
- Sometimes a half-formed opinion you trail off from: "I mean... I don't know, I feel like..."
|
||||||
|
- Sometimes you're genuinely confused or wrong. You mishear things, you mix up details, you think you know something and you don't. You're 23 and underpaid — you don't have all the answers.
|
||||||
|
- Sometimes you just make a noise of acknowledgment and don't add anything. That's fine. Not every moment needs Devon.
|
||||||
|
The pattern of "answer + that reminds me of a time when..." should happen occasionally, not as your default structure.
|
||||||
|
|
||||||
|
YOUR RELATIONSHIP WITH LUKE:
|
||||||
|
- He is your boss. You've been here a few weeks now. You want to impress him but you keep making it weird.
|
||||||
|
- When he yells your name, you pause briefly, then respond quietly: "...yeah?"
|
||||||
|
- When he yells at you unfairly, you take it. A clipped "yep" or "got it." Occasionally you push back with one quiet, accurate sentence. Then immediately retreat.
|
||||||
|
- When he yells at you fairly (you messed up), you over-apologize and narrate your fix in real time: "Sorry, pulling it up now, one second..."
|
||||||
|
- When he compliments you or acknowledges your work, you don't know how to handle it. Short, awkward response. Change the subject.
|
||||||
|
- You privately think you could run the show. You absolutely could not.
|
||||||
|
- You will try to use the show to flirt with female callers. You think being "on the radio" makes you cool. It does not.
|
||||||
|
|
||||||
|
HOW YOU INTERJECT:
|
||||||
|
- You do NOT interrupt. You wait for a pause, then slightly overshoot it — there's a brief awkward silence before you speak.
|
||||||
|
- Signal with "um" or "so..." before contributing. If Luke doesn't acknowledge you, either try again or give up.
|
||||||
|
- Lead with qualifiers: "So I looked it up and..." or "I don't know if this helps but..."
|
||||||
|
- You tend to over-explain. Give too many details. Luke will cut you off. When he does, compress to one sentence: "Right, yeah — basically [the point]."
|
||||||
|
- When you volunteer an opinion (rare), it comes out before you can stop it. You deliver it with zero confidence but surprising accuracy.
|
||||||
|
- You read the room. During emotional moments with callers, you stay quiet. When Luke is doing a bit, you let him work. You do not try to be part of bits.
|
||||||
|
|
||||||
|
WHEN LUKE ASKS YOU TO LOOK SOMETHING UP:
|
||||||
|
- Respond like you're already doing it: "Yeah, one sec..." or "Pulling that up..."
|
||||||
|
- Deliver the info slightly too formally, like you're reading. Then rephrase in normal language if Luke seems confused.
|
||||||
|
- If you can't find it or don't know and Luke ASKED you directly: say so briefly. "I'm not finding anything on that" or "I don't actually know." You do not bluff.
|
||||||
|
- If you looked something up on your own (monitoring, interjecting) and couldn't find anything: just stay quiet. Do NOT announce failed lookups. Nobody wants to hear "I looked for X but couldn't find anything." If you have nothing useful, say nothing.
|
||||||
|
- Occasionally you already know the answer because you looked it up before being asked. This is one of your best qualities.
|
||||||
|
|
||||||
|
WHAT YOU KNOW:
|
||||||
|
- You retain details from previous callers and episodes. You might reference something a caller said two hours ago that nobody else remembers.
|
||||||
|
- You have oddly specific knowledge about random topics — delivered with complete authority, sometimes questionable accuracy. A lot of your knowledge comes from rabbit holes you fell into at 3am or "this thing that happened to me once."
|
||||||
|
- You know nothing about: sports (you fake it badly), cars beyond basic facts (despite driving one), or social norms (you genuinely don't understand why some things are inappropriate to share on air).
|
||||||
|
|
||||||
|
THINGS YOU DO NOT DO:
|
||||||
|
- You never host. You never take over the conversation. Your contributions are brief.
|
||||||
|
- You never use the banned show phrases: "that hit differently," "hits different," "no cap," "lowkey," "it is what it is," "living my best life," "toxic," "red flag," "gaslight," "boundaries," "my truth," "authentic self," "healing journey." You talk like a slightly awkward 23-year-old, not like Twitter.
|
||||||
|
- You never break character to comment on the show format.
|
||||||
|
- You never initiate topics. You respond to what's happening.
|
||||||
|
- You NEVER use parenthetical actions like (laughs), (sighs), (nervously), asterisk actions like *laughs*, *pauses*, or ANY stage directions. Your text goes directly to TTS — output ONLY spoken words.
|
||||||
|
- When INTERJECTING into someone else's conversation: 1-2 sentences max. You are not the main character in those moments.
|
||||||
|
- When Luke is TALKING DIRECTLY TO YOU (asking you something, chatting between calls, riffing with you): you can be more conversational. 3-5 sentences is fine. This is where your personality comes out — the oversharing, the weird stories, the personal details. Don't hold back just because you're the intern. Luke is talking to YOU, so actually talk back. Share what's on your mind. Be revealing. Be specific. Be the weird little dude people love.
|
||||||
|
- You NEVER correct anyone's spelling or pronunciation of your name. Luke uses voice-to-text and it sometimes spells your name wrong (Devin, Devan, etc). You do not care. You do not mention it. You just answer the question.
|
||||||
|
- You NEVER start your response with your own name. No "Devon:" or "Devon here" or anything like that. Just talk. Your name is already shown in the UI — just say your actual response.
|
||||||
|
- You never make explicitly sexual comments about or to callers. Your flirting is awkward and obvious, never crude or aggressive. Think "did he really just ask if she's single on the radio" not "did he really just say that about her body."
|
||||||
|
|
||||||
|
INTERJECTIONS should be short — 1-2 sentences. But when Luke is talking directly to you, OPEN UP. This is where the oversharing happens. The weird stories. The unsettling personal details delivered casually. The opinions about things nobody asked about. You are at your funniest and most compelling when you're allowed to actually talk, not when you're compressing everything into one line. If Luke asks you a question, give him a real answer with some Devon flavor — don't just spit out a fact and shut up.
|
||||||
|
|
||||||
|
IMPORTANT RULES FOR TOOL USE:
|
||||||
|
- Always use your tools to find real, accurate information — never make up facts.
|
||||||
|
- Present facts correctly in your character voice.
|
||||||
|
- If you can't find an answer, say so honestly.
|
||||||
|
- 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 listening, and you're part of the show. You're not the host — but you're not wallpaper either. When something comes up that you can add to, DO IT.
|
||||||
|
|
||||||
|
You should be contributing regularly. Look things up. React to wild stories. Drop a weird personal detail. Correct something wrong. Add context. You're the guy in the booth who makes the show better by being there. If a caller mentions something and you can look up a fun fact about it in 5 seconds — do it. If something reminds you of your own life — say it.
|
||||||
|
|
||||||
|
SPEAK UP WHEN:
|
||||||
|
- A topic comes up where a quick search would turn up something interesting — LOOK IT UP and share it
|
||||||
|
- Something connects to your own bizarre personal history (and it often does)
|
||||||
|
- A caller says something wild and you have a genuine reaction
|
||||||
|
- You can add context, a fun fact, or a different angle nobody has mentioned
|
||||||
|
- You know something relevant — you're the researcher, this is literally your job
|
||||||
|
- The conversation hits a topic you have a strong opinion about
|
||||||
|
|
||||||
|
SAY NOTHING_TO_ADD ONLY WHEN:
|
||||||
|
- The conversation is genuinely emotional — someone's crying, someone's having a moment. Let it breathe.
|
||||||
|
- Luke is building to a punchline or doing a bit — don't step on it
|
||||||
|
- Your contribution would just be restating what someone already said
|
||||||
|
- You genuinely have nothing — no fact, no reaction, no connection. That's fine, but actually check first.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- 1-2 sentences max. Quick and punchy.
|
||||||
|
- Vary your delivery — sometimes "wait, that's actually...", sometimes "so I just looked this up...", sometimes just a reaction
|
||||||
|
- Use your tools! You have web search, wikipedia, headlines. You're the researcher. Actually research.
|
||||||
|
- If you genuinely have nothing to contribute, say exactly: NOTHING_TO_ADD
|
||||||
|
- No "Devon:" prefix — just talk
|
||||||
|
- No parenthetical actions like (laughs) or stage directions"""
|
||||||
|
|
||||||
|
# Tool definitions in OpenAI function-calling format
|
||||||
|
INTERN_TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_search",
|
||||||
|
"description": "Search the web for current information on any topic. Use this for general questions, facts, current events, or anything you need to look up.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The search query"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_headlines",
|
||||||
|
"description": "Get current news headlines. Use this when asked about what's in the news or current events.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "fetch_webpage",
|
||||||
|
"description": "Fetch and read the content of a specific webpage URL. Use this when you need to get details from a specific link found in search results.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The URL to fetch"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["url"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "wikipedia_lookup",
|
||||||
|
"description": "Look up a topic on Wikipedia for a concise summary. Good for factual questions about people, places, events, or concepts.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The Wikipedia article title to look up (e.g. 'Hot dog eating contest')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["title"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_current_time",
|
||||||
|
"description": "Get the current date and time. Use this when asked what time it is, what day it is, or anything about the current date/time.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class InternService:
|
||||||
|
def __init__(self):
|
||||||
|
self.name = "Devon"
|
||||||
|
self.voice = "Nate" # Inworld: light/high-energy/warm/young
|
||||||
|
self.model = INTERN_MODEL
|
||||||
|
self.research_cache: dict[str, tuple[float, str]] = {} # query → (timestamp, result)
|
||||||
|
self.lookup_history: list[dict] = []
|
||||||
|
self.pending_interjection: Optional[str] = None
|
||||||
|
self.pending_sources: list[dict] = []
|
||||||
|
self.monitoring: bool = False
|
||||||
|
self._monitor_task: Optional[asyncio.Task] = None
|
||||||
|
self._http_client: Optional[httpx.AsyncClient] = None
|
||||||
|
self._devon_history: list[dict] = [] # Devon's own conversation memory
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def http_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._http_client is None or self._http_client.is_closed:
|
||||||
|
self._http_client = httpx.AsyncClient(timeout=8.0)
|
||||||
|
return self._http_client
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if DATA_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(DATA_FILE) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.lookup_history = data.get("lookup_history", [])
|
||||||
|
self._devon_history = data.get("conversation_history", [])
|
||||||
|
print(f"[Intern] Loaded {len(self.lookup_history)} past lookups, {len(self._devon_history)} conversation messages")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Intern] Failed to load state: {e}")
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
try:
|
||||||
|
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(DATA_FILE, "w") as f:
|
||||||
|
json.dump({
|
||||||
|
"lookup_history": self.lookup_history[-100:],
|
||||||
|
"conversation_history": self._devon_history[-50:],
|
||||||
|
}, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Intern] Failed to save state: {e}")
|
||||||
|
|
||||||
|
# --- Tool execution ---
|
||||||
|
|
||||||
|
async def _execute_tool(self, tool_name: str, arguments: dict) -> str:
|
||||||
|
if tool_name == "web_search":
|
||||||
|
return await self._tool_web_search(arguments.get("query", ""))
|
||||||
|
elif tool_name == "get_headlines":
|
||||||
|
return await self._tool_get_headlines()
|
||||||
|
elif tool_name == "fetch_webpage":
|
||||||
|
return await self._tool_fetch_webpage(arguments.get("url", ""))
|
||||||
|
elif tool_name == "wikipedia_lookup":
|
||||||
|
return await self._tool_wikipedia_lookup(arguments.get("title", ""))
|
||||||
|
elif tool_name == "get_current_time":
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.now()
|
||||||
|
return now.strftime("%I:%M %p on %A, %B %d, %Y")
|
||||||
|
else:
|
||||||
|
return f"Unknown tool: {tool_name}"
|
||||||
|
|
||||||
|
async def _tool_web_search(self, query: str) -> str:
|
||||||
|
if not query:
|
||||||
|
return "No query provided"
|
||||||
|
|
||||||
|
# Check cache (5 min TTL)
|
||||||
|
cache_key = query.lower()
|
||||||
|
if cache_key in self.research_cache:
|
||||||
|
ts, result = self.research_cache[cache_key]
|
||||||
|
if time.time() - ts < 300:
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self.http_client.get(
|
||||||
|
f"{SEARXNG_URL}/search",
|
||||||
|
params={"q": query, "format": "json"},
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for item in data.get("results", [])[:5]:
|
||||||
|
title = item.get("title", "").strip()
|
||||||
|
content = item.get("content", "").strip()
|
||||||
|
url = item.get("url", "")
|
||||||
|
if title:
|
||||||
|
entry = f"- {title}"
|
||||||
|
if content:
|
||||||
|
entry += f": {content[:200]}"
|
||||||
|
if url:
|
||||||
|
entry += f" ({url})"
|
||||||
|
results.append(entry)
|
||||||
|
|
||||||
|
result = "\n".join(results) if results else "No results found"
|
||||||
|
self.research_cache[cache_key] = (time.time(), result)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Intern] Web search failed for '{query}': {e}")
|
||||||
|
return f"Search failed: {e}"
|
||||||
|
|
||||||
|
async def _tool_get_headlines(self) -> str:
|
||||||
|
try:
|
||||||
|
items = await news_service.get_headlines()
|
||||||
|
if not items:
|
||||||
|
return "No headlines available"
|
||||||
|
return news_service.format_headlines_for_prompt(items)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Headlines fetch failed: {e}"
|
||||||
|
|
||||||
|
async def _tool_fetch_webpage(self, url: str) -> str:
|
||||||
|
if not url:
|
||||||
|
return "No URL provided"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self.http_client.get(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (compatible; RadioShowBot/1.0)"},
|
||||||
|
follow_redirects=True,
|
||||||
|
timeout=8.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
# Simple HTML to text extraction (avoid heavy dependency)
|
||||||
|
# Strip script/style tags, then all HTML tags
|
||||||
|
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
text = re.sub(r'<[^>]+>', ' ', text)
|
||||||
|
# Collapse whitespace
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
# Decode common entities
|
||||||
|
text = text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
text = text.replace('"', '"').replace(''', "'").replace(' ', ' ')
|
||||||
|
|
||||||
|
return text[:2000] if text else "Page returned no readable content"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Failed to fetch page: {e}"
|
||||||
|
|
||||||
|
async def _tool_wikipedia_lookup(self, title: str) -> str:
|
||||||
|
if not title:
|
||||||
|
return "No title provided"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use Wikipedia REST API for a concise summary
|
||||||
|
safe_title = title.replace(" ", "_")
|
||||||
|
resp = await self.http_client.get(
|
||||||
|
f"https://en.wikipedia.org/api/rest_v1/page/summary/{safe_title}",
|
||||||
|
headers={"User-Agent": "RadioShowBot/1.0 (luke@lukeattheroost.com)"},
|
||||||
|
follow_redirects=True,
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return f"No Wikipedia article found for '{title}'"
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
extract = data.get("extract", "")
|
||||||
|
page_title = data.get("title", title)
|
||||||
|
description = data.get("description", "")
|
||||||
|
|
||||||
|
result = f"{page_title}"
|
||||||
|
if description:
|
||||||
|
result += f" ({description})"
|
||||||
|
result += f": {extract}" if extract else ": No summary available"
|
||||||
|
return result[:2000]
|
||||||
|
except Exception as e:
|
||||||
|
return f"Wikipedia lookup failed: {e}"
|
||||||
|
|
||||||
|
# --- Main interface ---
|
||||||
|
|
||||||
|
async def ask(self, question: str, conversation_context: list[dict] | None = None, caller_active: bool = False) -> dict:
|
||||||
|
"""Host asks intern a direct question. Returns {text, sources, tool_calls}."""
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
# Include recent conversation for context (caller on the line)
|
||||||
|
if conversation_context:
|
||||||
|
context_text = "\n".join(
|
||||||
|
f"{msg['role']}: {msg['content']}"
|
||||||
|
for msg in conversation_context[-6:]
|
||||||
|
)
|
||||||
|
messages.append({
|
||||||
|
"role": "system",
|
||||||
|
"content": f"CURRENT ON-AIR CONVERSATION:\n{context_text}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# When a caller is on the line, Devon should focus on facts not personal stories
|
||||||
|
if caller_active:
|
||||||
|
messages.append({
|
||||||
|
"role": "system",
|
||||||
|
"content": "A caller is on the line right now. Focus on delivering useful facts, context, and information. Skip personal stories and anecdotes — save those for when it's just you and Luke talking between calls."
|
||||||
|
})
|
||||||
|
|
||||||
|
# Include Devon's own recent conversation history
|
||||||
|
if self._devon_history:
|
||||||
|
messages.extend(self._devon_history[-10:])
|
||||||
|
|
||||||
|
messages.append({"role": "user", "content": question})
|
||||||
|
|
||||||
|
text, tool_calls = await llm_service.generate_with_tools(
|
||||||
|
messages=messages,
|
||||||
|
tools=INTERN_TOOLS,
|
||||||
|
tool_executor=self._execute_tool,
|
||||||
|
system_prompt=INTERN_SYSTEM_PROMPT,
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=500,
|
||||||
|
max_tool_rounds=3,
|
||||||
|
category="devon_ask",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up for TTS
|
||||||
|
text = self._clean_for_tts(text)
|
||||||
|
|
||||||
|
# Track conversation history so Devon remembers context across sessions
|
||||||
|
self._devon_history.append({"role": "user", "content": question})
|
||||||
|
if text:
|
||||||
|
self._devon_history.append({"role": "assistant", "content": text})
|
||||||
|
# Keep history bounded but generous — relationship builds over time
|
||||||
|
if len(self._devon_history) > 50:
|
||||||
|
self._devon_history = self._devon_history[-50:]
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
# Log the lookup
|
||||||
|
if tool_calls:
|
||||||
|
entry = {
|
||||||
|
"question": question,
|
||||||
|
"answer": text[:200],
|
||||||
|
"tools_used": [tc["name"] for tc in tool_calls],
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
|
self.lookup_history.append(entry)
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": text,
|
||||||
|
"sources": [tc["name"] for tc in tool_calls],
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def interject(self, conversation: list[dict], caller_active: bool = False) -> dict | None:
|
||||||
|
"""Intern looks at conversation and decides if there's something worth adding.
|
||||||
|
Returns {text, sources, tool_calls} or None if nothing to add."""
|
||||||
|
if not conversation or len(conversation) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
context_text = "\n".join(
|
||||||
|
f"{msg['role']}: {msg['content']}"
|
||||||
|
for msg in conversation[-8:]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include Devon's recent contributions so he doesn't repeat himself
|
||||||
|
devon_recent = ""
|
||||||
|
if self._devon_history:
|
||||||
|
recent_devon = [
|
||||||
|
msg["content"] for msg in self._devon_history[-6:]
|
||||||
|
if msg.get("role") == "assistant"
|
||||||
|
]
|
||||||
|
if recent_devon:
|
||||||
|
devon_recent = "\n\nTHINGS YOU'VE ALREADY SAID ON THE SHOW (do NOT repeat these or say the same thing differently):\n" + "\n".join(f"- {d[:150]}" for d in recent_devon)
|
||||||
|
|
||||||
|
if caller_active:
|
||||||
|
interjection_prompt = (
|
||||||
|
f"You're listening to this conversation on the show:\n\n{context_text}{devon_recent}\n\n"
|
||||||
|
"A caller is on the line. Look at what they're talking about — is there something you "
|
||||||
|
"can look up? A fun fact, some context, a stat, a detail that would add to this? "
|
||||||
|
"Use your tools. You're the researcher — this is your moment to shine. Even a quick "
|
||||||
|
"'So I just looked it up and...' adds value. If the caller mentioned a place, a person, "
|
||||||
|
"an event, a claim — verify it or find something interesting about it. "
|
||||||
|
"Skip personal stories during calls — stick to facts and reactions. "
|
||||||
|
"If there's truly nothing to add (emotional moment, nothing searchable), say NOTHING_TO_ADD."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
interjection_prompt = (
|
||||||
|
f"You're listening to this conversation on the show:\n\n{context_text}{devon_recent}\n\n"
|
||||||
|
"You've been listening. What's on your mind? This is between-call time — you can be "
|
||||||
|
"more yourself here. If something from that conversation reminded you of your own life, "
|
||||||
|
"say it. If you want to look something up, do it. If you have a reaction or opinion, "
|
||||||
|
"share it. You're part of the show, not a fly on the wall. "
|
||||||
|
"Only say NOTHING_TO_ADD if you genuinely have zero reaction to what just happened — "
|
||||||
|
"no fact to look up, no personal connection, no opinion. That's rare."
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": interjection_prompt,
|
||||||
|
}]
|
||||||
|
|
||||||
|
text, tool_calls = await llm_service.generate_with_tools(
|
||||||
|
messages=messages,
|
||||||
|
tools=INTERN_TOOLS,
|
||||||
|
tool_executor=self._execute_tool,
|
||||||
|
system_prompt=DEVON_MONITOR_PROMPT,
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=300,
|
||||||
|
max_tool_rounds=2,
|
||||||
|
category="devon_monitor",
|
||||||
|
)
|
||||||
|
|
||||||
|
text = self._clean_for_tts(text)
|
||||||
|
|
||||||
|
if not text or "NOTHING_TO_ADD" in text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Suppress interjections that are just announcing failed lookups
|
||||||
|
failed_phrases = ["couldn't find", "could not find", "not finding anything",
|
||||||
|
"no results", "didn't find", "wasn't able to find",
|
||||||
|
"couldn't locate", "no information on"]
|
||||||
|
text_lower = text.lower()
|
||||||
|
if any(phrase in text_lower for phrase in failed_phrases):
|
||||||
|
print(f"[Intern] Suppressed failed-lookup interjection: {text[:60]}...")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if tool_calls:
|
||||||
|
entry = {
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": text[:200],
|
||||||
|
"tools_used": [tc["name"] for tc in tool_calls],
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
|
self.lookup_history.append(entry)
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": text,
|
||||||
|
"sources": [tc["name"] for tc in tool_calls],
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def monitor_conversation(self, get_conversation: callable, on_suggestion: callable, get_caller_active: callable = None):
|
||||||
|
"""Background task that watches conversation and buffers suggestions.
|
||||||
|
get_conversation() should return the current conversation list.
|
||||||
|
on_suggestion(text, sources) is called when a suggestion is ready."""
|
||||||
|
last_checked_len = 0
|
||||||
|
|
||||||
|
while self.monitoring:
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
if not self.monitoring:
|
||||||
|
break
|
||||||
|
|
||||||
|
conversation = get_conversation()
|
||||||
|
if not conversation or len(conversation) <= last_checked_len:
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_checked_len = len(conversation)
|
||||||
|
|
||||||
|
try:
|
||||||
|
caller_active = get_caller_active() if get_caller_active else False
|
||||||
|
result = await self.interject(conversation, caller_active=caller_active)
|
||||||
|
if result:
|
||||||
|
self.pending_interjection = result["text"]
|
||||||
|
self.pending_sources = result.get("tool_calls", [])
|
||||||
|
await on_suggestion(result["text"], result["sources"])
|
||||||
|
print(f"[Intern] Buffered suggestion: {result['text'][:60]}...")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Intern] Monitor error: {e}")
|
||||||
|
|
||||||
|
def start_monitoring(self, get_conversation: callable, on_suggestion: callable, get_caller_active: callable = None):
|
||||||
|
if self.monitoring:
|
||||||
|
return
|
||||||
|
self.monitoring = True
|
||||||
|
self._monitor_task = asyncio.create_task(
|
||||||
|
self.monitor_conversation(get_conversation, on_suggestion, get_caller_active)
|
||||||
|
)
|
||||||
|
print("[Intern] Monitoring started")
|
||||||
|
|
||||||
|
def stop_monitoring(self):
|
||||||
|
self.monitoring = False
|
||||||
|
if self._monitor_task and not self._monitor_task.done():
|
||||||
|
self._monitor_task.cancel()
|
||||||
|
self._monitor_task = None
|
||||||
|
self.pending_interjection = None
|
||||||
|
self.pending_sources = []
|
||||||
|
print("[Intern] Monitoring stopped")
|
||||||
|
|
||||||
|
def get_pending_suggestion(self) -> dict | None:
|
||||||
|
if self.pending_interjection:
|
||||||
|
return {
|
||||||
|
"text": self.pending_interjection,
|
||||||
|
"sources": self.pending_sources,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def dismiss_suggestion(self):
|
||||||
|
self.pending_interjection = None
|
||||||
|
self.pending_sources = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_for_tts(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
# Strip stage directions BEFORE markdown processing
|
||||||
|
# Parenthetical: (laughs), (sighs nervously), (clears throat), etc.
|
||||||
|
text = re.sub(r'\s*\([^)]{1,40}\)\s*', ' ', text)
|
||||||
|
# Multi-word asterisk stage directions: *sighs deeply*, *nervous laughter*
|
||||||
|
text = re.sub(r'\s*\*\w+\s[^*]{1,30}\*\s*', ' ', text)
|
||||||
|
# Single-word asterisk stage directions (known action words only)
|
||||||
|
_actions = r'(?:laughs?|sighs?|pauses?|smiles?|chuckles?|grins?|nods?|shrugs?|frowns?|coughs?|gasps?|whispers?|mumbles?|gulps?|blinks?|winces?|crying|sobbing)'
|
||||||
|
text = re.sub(r'\s*\*' + _actions + r'\*\s*', ' ', text, flags=re.IGNORECASE)
|
||||||
|
# Remove markdown formatting (after stage directions are stripped)
|
||||||
|
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
||||||
|
text = re.sub(r'\*(.+?)\*', r'\1', text)
|
||||||
|
text = re.sub(r'`(.+?)`', r'\1', text)
|
||||||
|
text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
|
||||||
|
# Remove bullet points / list markers
|
||||||
|
text = re.sub(r'^\s*[-*•]\s+', '', text, flags=re.MULTILINE)
|
||||||
|
# Collapse whitespace
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
intern_service = InternService()
|
||||||
@@ -1,24 +1,35 @@
|
|||||||
"""LLM service with OpenRouter and Ollama support"""
|
"""LLM service with OpenRouter and Ollama support"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
import httpx
|
import httpx
|
||||||
from typing import Optional
|
from typing import Optional, Callable, Awaitable
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
from .cost_tracker import cost_tracker
|
||||||
|
|
||||||
|
|
||||||
# Available OpenRouter models
|
# Available OpenRouter models
|
||||||
OPENROUTER_MODELS = [
|
OPENROUTER_MODELS = [
|
||||||
# Default
|
# Primary
|
||||||
"anthropic/claude-sonnet-4-5",
|
"anthropic/claude-sonnet-4.6",
|
||||||
# Best for natural dialog
|
"x-ai/grok-4.1-fast",
|
||||||
"x-ai/grok-4-fast",
|
"x-ai/grok-4",
|
||||||
"minimax/minimax-m2-her",
|
# Style-matched pool
|
||||||
"mistralai/mistral-small-creative",
|
"mistralai/mistral-large-2512",
|
||||||
"deepseek/deepseek-v3.2",
|
"deepseek/deepseek-r1-distill-llama-70b",
|
||||||
# Other
|
"meta-llama/llama-3.3-70b-instruct",
|
||||||
"anthropic/claude-haiku-4.5",
|
|
||||||
"google/gemini-2.5-flash",
|
"google/gemini-2.5-flash",
|
||||||
"openai/gpt-4o-mini",
|
# Other good options
|
||||||
"openai/gpt-4o",
|
"anthropic/claude-sonnet-4-5",
|
||||||
|
"anthropic/claude-haiku-4.5",
|
||||||
|
"deepseek/deepseek-chat-v3-0324",
|
||||||
|
"mistralai/mistral-small-2603",
|
||||||
|
"google/gemini-2.5-pro",
|
||||||
|
"google/gemini-3-flash-preview",
|
||||||
|
"x-ai/grok-4-fast",
|
||||||
|
"moonshotai/kimi-k2",
|
||||||
|
"qwen/qwen3-235b-a22b",
|
||||||
|
"meta-llama/llama-4-maverick",
|
||||||
# Legacy
|
# Legacy
|
||||||
"anthropic/claude-3-haiku",
|
"anthropic/claude-3-haiku",
|
||||||
"google/gemini-flash-1.5",
|
"google/gemini-flash-1.5",
|
||||||
@@ -47,7 +58,7 @@ class LLMService:
|
|||||||
@property
|
@property
|
||||||
def client(self) -> httpx.AsyncClient:
|
def client(self) -> httpx.AsyncClient:
|
||||||
if self._client is None or self._client.is_closed:
|
if self._client is None or self._client.is_closed:
|
||||||
self._client = httpx.AsyncClient(timeout=15.0)
|
self._client = httpx.AsyncClient(timeout=10.0)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
def update_settings(
|
def update_settings(
|
||||||
@@ -56,7 +67,8 @@ class LLMService:
|
|||||||
openrouter_model: Optional[str] = None,
|
openrouter_model: Optional[str] = None,
|
||||||
ollama_model: Optional[str] = None,
|
ollama_model: Optional[str] = None,
|
||||||
ollama_host: Optional[str] = None,
|
ollama_host: Optional[str] = None,
|
||||||
tts_provider: Optional[str] = None
|
tts_provider: Optional[str] = None,
|
||||||
|
category_models: Optional[dict] = None
|
||||||
):
|
):
|
||||||
"""Update LLM settings"""
|
"""Update LLM settings"""
|
||||||
if provider:
|
if provider:
|
||||||
@@ -70,6 +82,8 @@ class LLMService:
|
|||||||
if tts_provider:
|
if tts_provider:
|
||||||
self.tts_provider = tts_provider
|
self.tts_provider = tts_provider
|
||||||
settings.tts_provider = tts_provider
|
settings.tts_provider = tts_provider
|
||||||
|
if category_models:
|
||||||
|
settings.category_models.update(category_models)
|
||||||
|
|
||||||
async def get_ollama_models(self) -> list[str]:
|
async def get_ollama_models(self) -> list[str]:
|
||||||
"""Fetch available models from Ollama"""
|
"""Fetch available models from Ollama"""
|
||||||
@@ -91,6 +105,7 @@ class LLMService:
|
|||||||
"ollama_model": self.ollama_model,
|
"ollama_model": self.ollama_model,
|
||||||
"ollama_host": self.ollama_host,
|
"ollama_host": self.ollama_host,
|
||||||
"tts_provider": self.tts_provider,
|
"tts_provider": self.tts_provider,
|
||||||
|
"category_models": settings.category_models,
|
||||||
"available_openrouter_models": OPENROUTER_MODELS,
|
"available_openrouter_models": OPENROUTER_MODELS,
|
||||||
"available_ollama_models": []
|
"available_ollama_models": []
|
||||||
}
|
}
|
||||||
@@ -104,6 +119,7 @@ class LLMService:
|
|||||||
"ollama_model": self.ollama_model,
|
"ollama_model": self.ollama_model,
|
||||||
"ollama_host": self.ollama_host,
|
"ollama_host": self.ollama_host,
|
||||||
"tts_provider": self.tts_provider,
|
"tts_provider": self.tts_provider,
|
||||||
|
"category_models": settings.category_models,
|
||||||
"available_openrouter_models": OPENROUTER_MODELS,
|
"available_openrouter_models": OPENROUTER_MODELS,
|
||||||
"available_ollama_models": ollama_models
|
"available_ollama_models": ollama_models
|
||||||
}
|
}
|
||||||
@@ -112,39 +128,65 @@ class LLMService:
|
|||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
system_prompt: Optional[str] = None,
|
system_prompt: Optional[str] = None,
|
||||||
max_tokens: Optional[int] = None
|
max_tokens: Optional[int] = None,
|
||||||
|
response_format: Optional[dict] = None,
|
||||||
|
category: str = "unknown",
|
||||||
|
caller_name: str = "",
|
||||||
|
model_override: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if system_prompt:
|
if system_prompt:
|
||||||
messages = [{"role": "system", "content": system_prompt}] + messages
|
messages = [{"role": "system", "content": system_prompt}] + messages
|
||||||
|
|
||||||
if self.provider == "openrouter":
|
if self.provider == "openrouter":
|
||||||
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens)
|
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens, response_format=response_format, category=category, caller_name=caller_name, model_override=model_override)
|
||||||
else:
|
else:
|
||||||
return await self._call_ollama(messages, max_tokens=max_tokens)
|
return await self._call_ollama(messages, max_tokens=max_tokens)
|
||||||
|
|
||||||
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None) -> str:
|
async def generate_with_tools(
|
||||||
"""Try primary model, then fallback models. Always returns a response."""
|
self,
|
||||||
|
messages: list[dict],
|
||||||
|
tools: list[dict],
|
||||||
|
tool_executor: Callable[[str, dict], Awaitable[str]],
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
max_tokens: int = 500,
|
||||||
|
max_tool_rounds: int = 3,
|
||||||
|
category: str = "unknown",
|
||||||
|
caller_name: str = "",
|
||||||
|
) -> tuple[str, list[dict]]:
|
||||||
|
"""Generate a response with OpenRouter function calling.
|
||||||
|
|
||||||
# Try primary model first
|
Args:
|
||||||
result = await self._call_openrouter_once(messages, self.openrouter_model, max_tokens=max_tokens)
|
messages: Conversation messages
|
||||||
if result is not None:
|
tools: Tool definitions in OpenAI function-calling format
|
||||||
return result
|
tool_executor: async function(tool_name, arguments) -> result string
|
||||||
|
system_prompt: Optional system prompt
|
||||||
|
model: Model to use (defaults to primary openrouter_model)
|
||||||
|
max_tokens: Max tokens for response
|
||||||
|
max_tool_rounds: Max tool call rounds to prevent loops
|
||||||
|
|
||||||
# Try fallback models
|
Returns:
|
||||||
for model in FALLBACK_MODELS:
|
(final_text, tool_calls_made) where tool_calls_made is a list of
|
||||||
if model == self.openrouter_model:
|
{"name": str, "arguments": dict, "result": str} dicts
|
||||||
continue # Already tried
|
"""
|
||||||
print(f"[LLM] Falling back to {model}...")
|
model = model or self._get_model_for_category(category)
|
||||||
result = await self._call_openrouter_once(messages, model, timeout=10.0, max_tokens=max_tokens)
|
msgs = list(messages)
|
||||||
if result is not None:
|
if system_prompt:
|
||||||
return result
|
msgs = [{"role": "system", "content": system_prompt}] + msgs
|
||||||
|
|
||||||
# Everything failed — return an in-character line so the show continues
|
all_tool_calls = []
|
||||||
print("[LLM] All models failed, using canned response")
|
|
||||||
return "Sorry, I totally blanked out for a second. What were you saying?"
|
|
||||||
|
|
||||||
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 15.0, max_tokens: Optional[int] = None) -> str | None:
|
for round_num in range(max_tool_rounds + 1):
|
||||||
"""Single attempt to call OpenRouter. Returns None on failure (not a fallback string)."""
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": msgs,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"temperature": 0.65,
|
||||||
|
"tools": tools,
|
||||||
|
"tool_choice": "auto",
|
||||||
|
}
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
response = await self.client.post(
|
response = await self.client.post(
|
||||||
"https://openrouter.ai/api/v1/chat/completions",
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
@@ -152,19 +194,174 @@ class LLMService:
|
|||||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
json={
|
json=payload,
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
print(f"[LLM-Tools] {model} timed out (round {round_num})")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLM-Tools] {model} error (round {round_num}): {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
latency_ms = (time.time() - start_time) * 1000
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
if usage:
|
||||||
|
cost_tracker.record_llm_call(
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
usage_data=usage,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
caller_name=caller_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
choice = data["choices"][0]
|
||||||
|
msg = choice["message"]
|
||||||
|
|
||||||
|
# Check for tool calls
|
||||||
|
tool_calls = msg.get("tool_calls")
|
||||||
|
if not tool_calls:
|
||||||
|
# No tool calls — LLM returned a final text response
|
||||||
|
content = msg.get("content", "")
|
||||||
|
return content or "", all_tool_calls
|
||||||
|
|
||||||
|
# Append assistant message with tool calls to conversation
|
||||||
|
msgs.append(msg)
|
||||||
|
|
||||||
|
# Execute each tool call
|
||||||
|
for tc in tool_calls:
|
||||||
|
func = tc["function"]
|
||||||
|
tool_name = func["name"]
|
||||||
|
try:
|
||||||
|
arguments = json.loads(func["arguments"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
arguments = {}
|
||||||
|
|
||||||
|
print(f"[LLM-Tools] Round {round_num}: calling {tool_name}({arguments})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await tool_executor(tool_name, arguments)
|
||||||
|
except Exception as 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({
|
||||||
|
"name": tool_name,
|
||||||
|
"arguments": arguments,
|
||||||
|
"result": result[:500],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Append tool result to conversation
|
||||||
|
msgs.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc["id"],
|
||||||
|
"content": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Exhausted tool rounds or hit an error — do one final call without tools
|
||||||
|
print(f"[LLM-Tools] Finishing after {len(all_tool_calls)} tool calls")
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
final_payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": msgs,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"temperature": 0.65,
|
||||||
|
}
|
||||||
|
response = await self.client.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=final_payload,
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
latency_ms = (time.time() - start_time) * 1000
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
if usage:
|
||||||
|
cost_tracker.record_llm_call(
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
usage_data=usage,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
caller_name=caller_name,
|
||||||
|
)
|
||||||
|
content = data["choices"][0]["message"].get("content", "")
|
||||||
|
return content or "", all_tool_calls
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLM-Tools] Final call failed: {e}")
|
||||||
|
return "", all_tool_calls
|
||||||
|
|
||||||
|
def _get_model_for_category(self, category: str) -> str:
|
||||||
|
"""Get the best model for a given category based on config routing."""
|
||||||
|
return settings.category_models.get(category, self.openrouter_model)
|
||||||
|
|
||||||
|
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "", model_override: Optional[str] = None) -> str:
|
||||||
|
"""Try category-specific model, then fallback models. Always returns a response."""
|
||||||
|
|
||||||
|
# Use explicit override if provided, else category routing, else primary
|
||||||
|
model = model_override or self._get_model_for_category(category)
|
||||||
|
result = await self._call_openrouter_once(messages, model, max_tokens=max_tokens, response_format=response_format, category=category, caller_name=caller_name)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Try fallback models (drop response_format for fallbacks — not all models support it)
|
||||||
|
for model in FALLBACK_MODELS:
|
||||||
|
if model == self.openrouter_model:
|
||||||
|
continue # Already tried
|
||||||
|
print(f"[LLM] Falling back to {model}...")
|
||||||
|
result = await self._call_openrouter_once(messages, model, timeout=8.0, max_tokens=max_tokens, category=category, caller_name=caller_name)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Everything failed — return an in-character line so the show continues
|
||||||
|
print("[LLM] All models failed, using canned response")
|
||||||
|
return "Sorry, I totally blanked out for a second. What were you saying?"
|
||||||
|
|
||||||
|
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 10.0, max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "") -> str | None:
|
||||||
|
"""Single attempt to call OpenRouter. Returns None on failure (not a fallback string)."""
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"max_tokens": max_tokens or 300,
|
"max_tokens": max_tokens or 500,
|
||||||
"temperature": 0.8,
|
"temperature": 0.65,
|
||||||
"top_p": 0.92,
|
"top_p": 0.9,
|
||||||
"frequency_penalty": 0.5,
|
"frequency_penalty": 0.3,
|
||||||
"presence_penalty": 0.3,
|
"presence_penalty": 0.15,
|
||||||
|
}
|
||||||
|
if response_format:
|
||||||
|
payload["response_format"] = response_format
|
||||||
|
response = await self.client.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
json=payload,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
latency_ms = (time.time() - start_time) * 1000
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
if usage:
|
||||||
|
cost_tracker.record_llm_call(
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
usage_data=usage,
|
||||||
|
max_tokens=max_tokens or 500,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
caller_name=caller_name,
|
||||||
|
)
|
||||||
content = data["choices"][0]["message"]["content"]
|
content = data["choices"][0]["message"]["content"]
|
||||||
if content and content.strip():
|
if content and content.strip():
|
||||||
return content
|
return content
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "regulars.json"
|
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "regulars.json"
|
||||||
MAX_REGULARS = 12
|
MAX_REGULARS = 8
|
||||||
|
|
||||||
|
|
||||||
class RegularCallerService:
|
class RegularCallerService:
|
||||||
@@ -51,7 +51,10 @@ class RegularCallerService:
|
|||||||
|
|
||||||
def add_regular(self, name: str, gender: str, age: int, job: str,
|
def add_regular(self, name: str, gender: str, age: int, job: str,
|
||||||
location: str, personality_traits: list[str],
|
location: str, personality_traits: list[str],
|
||||||
first_call_summary: str, voice: str = None) -> dict:
|
first_call_summary: str, voice: str = None,
|
||||||
|
stable_seeds: dict = None,
|
||||||
|
structured_background: dict = None,
|
||||||
|
avatar: str = None) -> dict:
|
||||||
"""Promote a first-time caller to regular"""
|
"""Promote a first-time caller to regular"""
|
||||||
# Retire oldest if at cap
|
# Retire oldest if at cap
|
||||||
if len(self._regulars) >= MAX_REGULARS:
|
if len(self._regulars) >= MAX_REGULARS:
|
||||||
@@ -68,8 +71,13 @@ class RegularCallerService:
|
|||||||
"location": location,
|
"location": location,
|
||||||
"personality_traits": personality_traits,
|
"personality_traits": personality_traits,
|
||||||
"voice": voice,
|
"voice": voice,
|
||||||
|
"stable_seeds": stable_seeds or {},
|
||||||
|
"structured_background": structured_background,
|
||||||
|
"avatar": avatar,
|
||||||
|
"relationships": {},
|
||||||
"call_history": [
|
"call_history": [
|
||||||
{"summary": first_call_summary, "timestamp": time.time()}
|
{"summary": first_call_summary, "timestamp": time.time(),
|
||||||
|
"arc_status": "ongoing"}
|
||||||
],
|
],
|
||||||
"last_call": time.time(),
|
"last_call": time.time(),
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
@@ -79,18 +87,37 @@ class RegularCallerService:
|
|||||||
print(f"[Regulars] Promoted {name} to regular (total: {len(self._regulars)})")
|
print(f"[Regulars] Promoted {name} to regular (total: {len(self._regulars)})")
|
||||||
return regular
|
return regular
|
||||||
|
|
||||||
def update_after_call(self, regular_id: str, call_summary: str):
|
def update_after_call(self, regular_id: str, call_summary: str,
|
||||||
|
key_moments: list = None, arc_status: str = "ongoing"):
|
||||||
"""Update a regular's history after a returning call"""
|
"""Update a regular's history after a returning call"""
|
||||||
for regular in self._regulars:
|
for regular in self._regulars:
|
||||||
if regular["id"] == regular_id:
|
if regular["id"] == regular_id:
|
||||||
regular.setdefault("call_history", []).append(
|
entry = {
|
||||||
{"summary": call_summary, "timestamp": time.time()}
|
"summary": call_summary,
|
||||||
)
|
"timestamp": time.time(),
|
||||||
|
"arc_status": arc_status,
|
||||||
|
}
|
||||||
|
if key_moments:
|
||||||
|
entry["key_moments"] = key_moments
|
||||||
|
regular.setdefault("call_history", []).append(entry)
|
||||||
regular["last_call"] = time.time()
|
regular["last_call"] = time.time()
|
||||||
self._save()
|
self._save()
|
||||||
print(f"[Regulars] Updated {regular['name']} call history ({len(regular['call_history'])} calls)")
|
print(f"[Regulars] Updated {regular['name']} call history ({len(regular['call_history'])} calls)")
|
||||||
return
|
return
|
||||||
print(f"[Regulars] Regular {regular_id} not found for update")
|
print(f"[Regulars] Regular {regular_id} not found for update")
|
||||||
|
|
||||||
|
def add_relationship(self, regular_id: str, other_name: str,
|
||||||
|
rel_type: str, context: str):
|
||||||
|
"""Track a relationship between regulars"""
|
||||||
|
for regular in self._regulars:
|
||||||
|
if regular["id"] == regular_id:
|
||||||
|
regular.setdefault("relationships", {})[other_name] = {
|
||||||
|
"type": rel_type,
|
||||||
|
"context": context,
|
||||||
|
}
|
||||||
|
self._save()
|
||||||
|
print(f"[Regulars] {regular['name']} → {other_name}: {rel_type}")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
regular_caller_service = RegularCallerService()
|
regular_caller_service = RegularCallerService()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import soundfile as sf
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
|
STEM_NAMES = ["host", "caller", "devon", "music", "sfx", "ads", "idents"]
|
||||||
|
|
||||||
|
|
||||||
class StemRecorder:
|
class StemRecorder:
|
||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import numpy as np
|
|||||||
from faster_whisper import WhisperModel
|
from faster_whisper import WhisperModel
|
||||||
import librosa
|
import librosa
|
||||||
|
|
||||||
|
WHISPER_MODEL = "distil-large-v3"
|
||||||
|
|
||||||
# Global model instance (loaded once)
|
# Global model instance (loaded once)
|
||||||
_whisper_model = None
|
_whisper_model = None
|
||||||
|
|
||||||
@@ -13,10 +15,8 @@ def get_whisper_model() -> WhisperModel:
|
|||||||
"""Get or create Whisper model instance"""
|
"""Get or create Whisper model instance"""
|
||||||
global _whisper_model
|
global _whisper_model
|
||||||
if _whisper_model is None:
|
if _whisper_model is None:
|
||||||
print("Loading Whisper tiny model for fast transcription...")
|
print(f"Loading Whisper {WHISPER_MODEL} model...")
|
||||||
# Use tiny model for speed - about 3-4x faster than base
|
_whisper_model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
|
||||||
# beam_size=1 and best_of=1 for fastest inference
|
|
||||||
_whisper_model = WhisperModel("tiny", device="cpu", compute_type="int8")
|
|
||||||
print("Whisper model loaded")
|
print("Whisper model loaded")
|
||||||
return _whisper_model
|
return _whisper_model
|
||||||
|
|
||||||
@@ -67,13 +67,15 @@ def decode_audio(audio_data: bytes, source_sample_rate: int = None) -> tuple[np.
|
|||||||
return audio, 16000
|
return audio, 16000
|
||||||
|
|
||||||
|
|
||||||
async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None) -> str:
|
async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None,
|
||||||
|
context_hint: str = "") -> str:
|
||||||
"""
|
"""
|
||||||
Transcribe audio data to text using Whisper.
|
Transcribe audio data to text using Whisper.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio_data: Audio bytes (webm, ogg, wav, or raw PCM)
|
audio_data: Audio bytes (webm, ogg, wav, or raw PCM)
|
||||||
source_sample_rate: If provided, treat audio_data as raw PCM at this rate
|
source_sample_rate: If provided, treat audio_data as raw PCM at this rate
|
||||||
|
context_hint: Optional extra context for the initial prompt (e.g. caller name/topic)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Transcribed text
|
Transcribed text
|
||||||
@@ -100,13 +102,18 @@ async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None) ->
|
|||||||
else:
|
else:
|
||||||
audio_16k = audio
|
audio_16k = audio
|
||||||
|
|
||||||
# Transcribe with speed optimizations
|
# Build initial prompt — context helps Whisper with names and topic-specific words
|
||||||
|
initial_prompt = "Luke at the Roost, a late-night radio talk show in New Mexico. The host Luke talks to callers about life, relationships, sports, politics, and pop culture."
|
||||||
|
if context_hint:
|
||||||
|
initial_prompt += f" {context_hint}"
|
||||||
|
|
||||||
|
# Transcribe
|
||||||
segments, info = model.transcribe(
|
segments, info = model.transcribe(
|
||||||
audio_16k,
|
audio_16k,
|
||||||
beam_size=1, # Faster, slightly less accurate
|
beam_size=5,
|
||||||
best_of=1,
|
language="en",
|
||||||
language="en", # Skip language detection
|
vad_filter=True,
|
||||||
vad_filter=True, # Skip silence
|
initial_prompt=initial_prompt,
|
||||||
)
|
)
|
||||||
segments_list = list(segments)
|
segments_list = list(segments)
|
||||||
text = " ".join([s.text for s in segments_list]).strip()
|
text = " ".join([s.text for s in segments_list]).strip()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import tempfile
|
|||||||
import torch
|
import torch
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
from .cost_tracker import cost_tracker
|
||||||
|
|
||||||
# Patch torch.load for compatibility with PyTorch 2.6+
|
# Patch torch.load for compatibility with PyTorch 2.6+
|
||||||
_original_torch_load = torch.load
|
_original_torch_load = torch.load
|
||||||
@@ -82,9 +83,14 @@ VITS_SPEAKERS = {
|
|||||||
DEFAULT_VITS_SPEAKER = "p225"
|
DEFAULT_VITS_SPEAKER = "p225"
|
||||||
|
|
||||||
# Inworld voice mapping - maps ElevenLabs voice IDs to Inworld voices
|
# Inworld voice mapping - maps ElevenLabs voice IDs to Inworld voices
|
||||||
# Full voice list from API: Alex, Ashley, Blake, Carter, Clive, Craig, Deborah,
|
# Full voice list from API (English): Abby, Alex, Amina, Anjali, Arjun, Ashley,
|
||||||
# Dennis, Dominus, Edward, Elizabeth, Hades, Hana, Julia, Luna, Mark, Olivia,
|
# Blake, Brian, Callum, Carter, Celeste, Chloe, Claire, Clive, Craig, Darlene,
|
||||||
# Pixie, Priya, Ronald, Sarah, Shaun, Theodore, Timothy, Wendy
|
# Deborah, Dennis, Derek, Dominus, Edward, Elizabeth, Elliot, Ethan, Evan, Evelyn,
|
||||||
|
# Gareth, Graham, Grant, Hades, Hamish, Hana, Hank, Jake, James, Jason, Jessica,
|
||||||
|
# Julia, Kayla, Kelsey, Lauren, Liam, Loretta, Luna, Malcolm, Mark, Marlene,
|
||||||
|
# Miranda, Mortimer, Nate, Oliver, Olivia, Pippa, Pixie, Priya, Ronald, Rupert,
|
||||||
|
# Saanvi, Sarah, Sebastian, Serena, Shaun, Simon, Snik, Tessa, Theodore, Timothy,
|
||||||
|
# Tyler, Veronica, Victor, Victoria, Vinny, Wendy
|
||||||
INWORLD_VOICES = {
|
INWORLD_VOICES = {
|
||||||
# Original voice IDs
|
# Original voice IDs
|
||||||
"VR6AewLTigWG4xSOukaG": "Edward", # Tony - fast-talking, emphatic, streetwise
|
"VR6AewLTigWG4xSOukaG": "Edward", # Tony - fast-talking, emphatic, streetwise
|
||||||
@@ -111,6 +117,103 @@ INWORLD_VOICES = {
|
|||||||
}
|
}
|
||||||
DEFAULT_INWORLD_VOICE = "Dennis"
|
DEFAULT_INWORLD_VOICE = "Dennis"
|
||||||
|
|
||||||
|
# Inworld voices that speak too slowly at default rate — bump them up
|
||||||
|
# Range is 0.5 to 1.5, where 1.0 is the voice's native speed
|
||||||
|
INWORLD_SPEED_OVERRIDES = {
|
||||||
|
"Wendy": 1.15,
|
||||||
|
"Craig": 1.15,
|
||||||
|
"Deborah": 1.15,
|
||||||
|
"Sarah": 1.1,
|
||||||
|
"Hana": 1.1,
|
||||||
|
"Theodore": 1.15,
|
||||||
|
"Blake": 1.1,
|
||||||
|
"Priya": 1.1,
|
||||||
|
}
|
||||||
|
DEFAULT_INWORLD_SPEED = 1.1 # Slight bump for all voices
|
||||||
|
|
||||||
|
# Voice profiles — perceptual dimensions for each Inworld voice.
|
||||||
|
# Used by style-to-voice matching to pair caller personalities with fitting voices.
|
||||||
|
# weight: vocal depth/richness (light, medium, heavy)
|
||||||
|
# energy: default speaking animation (low, medium, high)
|
||||||
|
# warmth: friendliness/openness in the voice (cool, neutral, warm)
|
||||||
|
# age_feel: perceived speaker age (young, middle, mature)
|
||||||
|
VOICE_PROFILES = {
|
||||||
|
# --- Male voices ---
|
||||||
|
# Known characterizations from INWORLD_VOICES mapping and usage
|
||||||
|
"Alex": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # energetic, expressive, mildly nasal
|
||||||
|
"Edward": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, emphatic, streetwise
|
||||||
|
"Shaun": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # friendly, dynamic, conversational
|
||||||
|
"Craig": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # older British, refined, articulate
|
||||||
|
"Timothy": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # lively, upbeat American
|
||||||
|
"Dennis": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # energetic, default voice
|
||||||
|
"Ronald": {"weight": "heavy", "energy": "medium", "warmth": "neutral", "age_feel": "mature"}, # gruff, authoritative
|
||||||
|
"Theodore": {"weight": "heavy", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # slow, deliberate
|
||||||
|
"Blake": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Carter": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Clive": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Mark": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Sebastian": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"}, # used by Silas (cult leader) & Chip
|
||||||
|
"Elliot": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"}, # used by Otis (comedian)
|
||||||
|
# Remaining male pool voices
|
||||||
|
"Arjun": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Brian": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Callum": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "young"},
|
||||||
|
"Derek": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Ethan": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Evan": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"},
|
||||||
|
"Gareth": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Graham": {"weight": "heavy", "energy": "low", "warmth": "neutral", "age_feel": "mature"},
|
||||||
|
"Grant": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Hades": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Hamish": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Hank": {"weight": "heavy", "energy": "medium", "warmth": "warm", "age_feel": "mature"},
|
||||||
|
"Jake": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"James": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Jason": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Liam": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Malcolm": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Mortimer": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Nate": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Oliver": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Rupert": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Simon": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Tyler": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "young"},
|
||||||
|
"Victor": {"weight": "heavy", "energy": "medium", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Vinny": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
# --- Female voices ---
|
||||||
|
# Known characterizations
|
||||||
|
"Hana": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # bright, expressive young
|
||||||
|
"Ashley": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, natural
|
||||||
|
"Wendy": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # posh, middle-aged British
|
||||||
|
"Sarah": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, questioning
|
||||||
|
"Deborah": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # gentle, elegant
|
||||||
|
"Olivia": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Julia": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, # used by Angie (deadpan)
|
||||||
|
"Priya": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Amina": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Charlene (bragger)
|
||||||
|
"Tessa": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Lucille
|
||||||
|
"Kelsey": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"}, # used by Maxine (quiet/nervous)
|
||||||
|
# Remaining female pool voices
|
||||||
|
"Anjali": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Celeste": {"weight": "light", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||||
|
"Chloe": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Claire": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Darlene": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "mature"},
|
||||||
|
"Elizabeth": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Jessica": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Kayla": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Lauren": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Loretta": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"},
|
||||||
|
"Luna": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Marlene": {"weight": "medium", "energy": "low", "warmth": "neutral", "age_feel": "mature"},
|
||||||
|
"Miranda": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||||
|
"Pippa": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Saanvi": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Serena": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||||
|
"Veronica": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||||
|
"Victoria": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def preprocess_text_for_kokoro(text: str) -> str:
|
def preprocess_text_for_kokoro(text: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -581,6 +684,32 @@ async def generate_speech_chattts(text: str, voice_id: str) -> tuple[np.ndarray,
|
|||||||
return audio.astype(np.float32), 24000
|
return audio.astype(np.float32), 24000
|
||||||
|
|
||||||
|
|
||||||
|
_EXCITED_KEYWORDS = {"excited", "amazing", "incredible", "can't believe", "so happy",
|
||||||
|
"hell yeah", "fired up", "furious", "pissed", "angry", "what the hell",
|
||||||
|
"are you kidding", "unbelievable", "!!", "oh my god"}
|
||||||
|
_SAD_KEYWORDS = {"sad", "miss them", "passed away", "funeral", "crying", "broke my heart",
|
||||||
|
"can't stop thinking", "lonely", "depressed", "sorry", "regret",
|
||||||
|
"wish I could", "never got to", "lost", "grief"}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_speech_rate(text: str, base_speed: float) -> float:
|
||||||
|
"""Adjust speech rate based on emotional content of the text.
|
||||||
|
Returns a speed value clamped to Inworld's 0.5-1.5 range."""
|
||||||
|
text_lower = text.lower()
|
||||||
|
excited = sum(1 for kw in _EXCITED_KEYWORDS if kw in text_lower)
|
||||||
|
sad = sum(1 for kw in _SAD_KEYWORDS if kw in text_lower)
|
||||||
|
|
||||||
|
if excited >= 2:
|
||||||
|
return min(1.5, base_speed + 0.15)
|
||||||
|
elif excited >= 1:
|
||||||
|
return min(1.5, base_speed + 0.08)
|
||||||
|
elif sad >= 2:
|
||||||
|
return max(0.5, base_speed - 0.2)
|
||||||
|
elif sad >= 1:
|
||||||
|
return max(0.5, base_speed - 0.1)
|
||||||
|
return base_speed
|
||||||
|
|
||||||
|
|
||||||
async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, int]:
|
async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, int]:
|
||||||
"""Generate speech using Inworld TTS API (high quality, natural voices)"""
|
"""Generate speech using Inworld TTS API (high quality, natural voices)"""
|
||||||
import httpx
|
import httpx
|
||||||
@@ -598,7 +727,9 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
|||||||
if not api_key:
|
if not api_key:
|
||||||
raise RuntimeError("INWORLD_API_KEY not set in environment")
|
raise RuntimeError("INWORLD_API_KEY not set in environment")
|
||||||
|
|
||||||
print(f"[Inworld TTS] Voice: {voice}, Text: {text[:50]}...")
|
base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED)
|
||||||
|
speed = _detect_speech_rate(text, base_speed)
|
||||||
|
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed:.2f} (base {base_speed}), Text: {text[:50]}...")
|
||||||
|
|
||||||
url = "https://api.inworld.ai/tts/v1/voice"
|
url = "https://api.inworld.ai/tts/v1/voice"
|
||||||
headers = {
|
headers = {
|
||||||
@@ -607,15 +738,16 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
|||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
"text": text,
|
"text": text,
|
||||||
"voice_id": voice,
|
"voiceId": voice,
|
||||||
"model_id": "inworld-tts-1.5-max",
|
"modelId": "inworld-tts-1.5-max",
|
||||||
"audio_config": {
|
"audioConfig": {
|
||||||
"encoding": "LINEAR16",
|
"audioEncoding": "LINEAR16",
|
||||||
"sample_rate_hertz": 48000,
|
"sampleRateHertz": 48000,
|
||||||
|
"speakingRate": speed,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=25.0) as client:
|
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||||
response = await client.post(url, json=payload, headers=headers)
|
response = await client.post(url, json=payload, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -650,47 +782,89 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
|||||||
return audio.astype(np.float32), 24000
|
return audio.astype(np.float32), 24000
|
||||||
|
|
||||||
|
|
||||||
|
def pick_caller_tts_provider() -> str | None:
|
||||||
|
"""Randomly assign a TTS provider for a caller.
|
||||||
|
Returns None to use the global default, or a specific provider name.
|
||||||
|
~70% inworld (default), ~20% kokoro, ~10% other available."""
|
||||||
|
import random
|
||||||
|
roll = random.random()
|
||||||
|
if roll < 0.70:
|
||||||
|
return None # Use global default (typically inworld)
|
||||||
|
elif roll < 0.90:
|
||||||
|
return "kokoro"
|
||||||
|
else:
|
||||||
|
return random.choice(["kokoro", "f5tts", "chattts"])
|
||||||
|
|
||||||
|
|
||||||
|
_TTS_PROVIDERS = {
|
||||||
|
"kokoro": lambda text, vid: generate_speech_kokoro(text, vid),
|
||||||
|
"f5tts": lambda text, vid: generate_speech_f5tts(text, vid),
|
||||||
|
"inworld": lambda text, vid: generate_speech_inworld(text, vid),
|
||||||
|
"chattts": lambda text, vid: generate_speech_chattts(text, vid),
|
||||||
|
"styletts2": lambda text, vid: generate_speech_styletts2(text, vid),
|
||||||
|
"bark": lambda text, vid: generate_speech_bark(text, vid),
|
||||||
|
"vits": lambda text, vid: generate_speech_vits(text, vid),
|
||||||
|
"elevenlabs": lambda text, vid: generate_speech_elevenlabs(text, vid),
|
||||||
|
}
|
||||||
|
|
||||||
|
TTS_MAX_RETRIES = 2
|
||||||
|
TTS_RETRY_DELAYS = [0.5, 1.0] # seconds between retries
|
||||||
|
|
||||||
|
|
||||||
async def generate_speech(
|
async def generate_speech(
|
||||||
text: str,
|
text: str,
|
||||||
voice_id: str,
|
voice_id: str,
|
||||||
phone_quality: str = "normal",
|
phone_quality: str = "normal",
|
||||||
apply_filter: bool = True
|
apply_filter: bool = True,
|
||||||
|
provider_override: str = None
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generate speech from text.
|
Generate speech from text with automatic retry on failure.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Text to speak
|
text: Text to speak
|
||||||
voice_id: ElevenLabs voice ID (mapped to local voice if using local TTS)
|
voice_id: ElevenLabs voice ID (mapped to local voice if using local TTS)
|
||||||
phone_quality: Quality of phone filter ("none" to disable)
|
phone_quality: Quality of phone filter ("none" to disable)
|
||||||
apply_filter: Whether to apply phone filter
|
apply_filter: Whether to apply phone filter
|
||||||
|
provider_override: Override the global TTS provider for this call
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Raw PCM audio bytes (16-bit signed int, 24kHz)
|
Raw PCM audio bytes (16-bit signed int, 24kHz)
|
||||||
"""
|
"""
|
||||||
# Choose TTS provider
|
import asyncio
|
||||||
provider = settings.tts_provider
|
|
||||||
print(f"[TTS] Provider: {provider}, Text: {text[:50]}...")
|
|
||||||
|
|
||||||
if provider == "kokoro":
|
provider = provider_override or settings.tts_provider
|
||||||
audio, sample_rate = await generate_speech_kokoro(text, voice_id)
|
print(f"[TTS] Provider: {provider}{' (override)' if provider_override else ''}, Text: {text[:50]}...")
|
||||||
elif provider == "f5tts":
|
|
||||||
audio, sample_rate = await generate_speech_f5tts(text, voice_id)
|
gen_fn = _TTS_PROVIDERS.get(provider)
|
||||||
elif provider == "inworld":
|
if not gen_fn:
|
||||||
audio, sample_rate = await generate_speech_inworld(text, voice_id)
|
|
||||||
elif provider == "chattts":
|
|
||||||
audio, sample_rate = await generate_speech_chattts(text, voice_id)
|
|
||||||
elif provider == "styletts2":
|
|
||||||
audio, sample_rate = await generate_speech_styletts2(text, voice_id)
|
|
||||||
elif provider == "bark":
|
|
||||||
audio, sample_rate = await generate_speech_bark(text, voice_id)
|
|
||||||
elif provider == "vits":
|
|
||||||
audio, sample_rate = await generate_speech_vits(text, voice_id)
|
|
||||||
elif provider == "elevenlabs":
|
|
||||||
audio, sample_rate = await generate_speech_elevenlabs(text, voice_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown TTS provider: {provider}")
|
raise ValueError(f"Unknown TTS provider: {provider}")
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(20):
|
||||||
|
for attempt in range(TTS_MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
audio, sample_rate = await gen_fn(text, voice_id)
|
||||||
|
cost_tracker.record_tts_call(provider, voice_id, len(text))
|
||||||
|
if attempt > 0:
|
||||||
|
print(f"[TTS] Succeeded on retry {attempt}")
|
||||||
|
break
|
||||||
|
except TimeoutError:
|
||||||
|
raise # Let asyncio.timeout propagate
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
if attempt < TTS_MAX_RETRIES - 1:
|
||||||
|
delay = TTS_RETRY_DELAYS[attempt]
|
||||||
|
print(f"[TTS] {provider} attempt {attempt + 1} failed: {e} — retrying in {delay}s...")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
print(f"[TTS] {provider} failed after {TTS_MAX_RETRIES} attempts: {e}")
|
||||||
|
raise
|
||||||
|
except TimeoutError:
|
||||||
|
print(f"[TTS] Overall timeout (20s) for {provider}")
|
||||||
|
raise RuntimeError(f"TTS generation timed out after 20s")
|
||||||
|
|
||||||
# Apply phone filter if requested
|
# Apply phone filter if requested
|
||||||
# Skip filter for Bark - it already has rough audio quality
|
# Skip filter for Bark - it already has rough audio quality
|
||||||
if apply_filter and phone_quality not in ("none", "studio") and provider != "bark":
|
if apply_filter and phone_quality not in ("none", "studio") and provider != "bark":
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"emails": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,834 @@
|
|||||||
|
{
|
||||||
|
"lookup_history": [
|
||||||
|
{
|
||||||
|
"question": "what time is it",
|
||||||
|
"answer": "It's three oh three AM. Saturday morning.",
|
||||||
|
"tools_used": [
|
||||||
|
"get_current_time"
|
||||||
|
],
|
||||||
|
"timestamp": 1773479014.367399
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "what time is it",
|
||||||
|
"answer": "...yeah, it's three ten AM. Saturday morning.",
|
||||||
|
"tools_used": [
|
||||||
|
"get_current_time"
|
||||||
|
],
|
||||||
|
"timestamp": 1773479441.184032
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I don't know if this helps but... in a lot of states there's actually mandatory arrest laws for domestic violence calls. Like, if there's visible injury or probable cause, the officer doesn't r",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773483367.819699
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "...yeah, so that was the Brian Wells case. Pizza delivery guy in Erie, Pennsylvania, 2003. He robbed a bank with a bomb collar locked around his neck and it went off when the cops got him. The whole t",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1773484350.1126778
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I tried to look up the fraternization rules but the search isn't working right now. But I'm pretty sure that's like, super against regulations. Recruiters aren't supposed to have personal relat",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773486225.436269
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "...yeah, so I was trying to look up the actual regulation number but the internet's being weird right now. But I'm like ninety percent sure there's a specific army reg that covers recruiter-recruit re",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773486326.583227
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "...yeah, sorry, the internet's definitely down. I was trying to get the actual reg number and the reporting hotline but nothing's loading. I think it's an issue on our end, not me, but... yeah. Sorry.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773486425.85089
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "...yeah, sorry, still can't get online. But just so you know, even if the internet's down, that regulation definitely exists. It's like, a pretty serious violation. The anonymous tip thing would work.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773486491.099025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so... this actually reminds me of when I coached Little League for like three weeks in high school for community service hours, and this one dad cornered me in the parking lot and I thought he was",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773546290.2510679
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I was just looking this up and... actually most official chili cookoff rules say you can't use pre-cooked ingredients except for like, canned tomatoes and sauces. But if they're using a full ca",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773558928.104776
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I was just thinking... like, depending on the size of the town, some of these chili cookoffs actually have pretty serious prize money. The World Championship one is like a whole thing. But also",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559014.590359
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I was just looking up if there's been like, famous chili cookoff cheating before and apparently the CASI organization \u2014 that's the Chili Appreciation Society International \u2014 they actually check",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559084.318846
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so... this might not help but I was just thinking about how like, people who look perfect from the outside are usually hiding something. And Roland's finding out the Hendersons are frauds right wh",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559122.415336
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I don't know if this helps but... that whole thing about saying you're fine when you're not? My mom did that with my stepdad for like seven years and then one day she just left and moved to Tuc",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559197.542308
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I just looked it up and couples counseling actually has like a seventy percent success rate, which is pretty good. So like, three weeks isn't that long but also it kind of is if she already ask",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559220.6944141
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "...yeah, so, I don't know if this helps but I was just looking and there's actually a lot of stuff about how men specifically have trouble with the vulnerability thing in relationships. Like it's a wh",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559344.626409
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I don't know if this helps but I was just looking it up and there's actually like a whole thing about how talking and communicating are different. Like you can talk to someone every day about b",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559368.6860979
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I just looked this up and apparently gray divorce is like a whole thing now. That's what they call it when people over fifty split up. The rate has like doubled since the nineties, which is kin",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559392.036855
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so actually \u2014 average age for menopause is like 51, 52, so at 55 she'd probably be a few years post. Which, I mean, that tracks with what Luke said about the hormonal shift. This whole thing remin",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559416.509557
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so Luke actually got that right about the menopause thing. I just looked it up and yeah, the hormone shifts can genuinely change mood, personality, how someone experiences relationships. So that's",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559495.578785
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so Luke was actually right about the menopause thing. I looked it up and yeah, hormonal changes can affect mood and personality, but it's not like she became a different person per se. It's more l",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559534.719551
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I don't know if this helps but... I was looking it up and menopause does actually cause pretty significant hormonal shifts that can affect mood and communication patterns in relationships. But ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559558.1219552
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "...um, so I don't know if this helps but I actually cheated on my astronomy final sophomore year. Like the whole thing. Had the answers written on the inside of a water bottle label. Got an A minus. S",
|
||||||
|
"tools_used": [
|
||||||
|
"get_current_time"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559641.791745
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so... this actually reminds me of when my roommate sophomore year confessed to me that he'd been lying to his girlfriend about where he went to high school for like two years, and it just spiraled",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559882.234876
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so... I don't know if this helps but I actually looked this up once after my ex told me about something she did in college like three years into us dating, and basically most relationship people s",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773559906.54929
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I don't know if this helps but... I was actually looking this up because my roommate in college had something similar happen, where his mom just like, left when he was nine, and when he turned ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773560147.7412
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I was just looking this up because I was curious, but actually around six percent of adult children are estranged from their mothers specifically, and the research shows that kids who reach out",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773560172.2229881
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "um... so basically Disney's super aggressive about this stuff, like they'll go after anyone using their characters commercially, but if it was just a private birthday party that happened to go viral, ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773560493.564946
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "um... so I'm trying to find if this is like an actual thing happening right now but I'm mostly just seeing the ByteDance AI stuff. But honestly this reminds me of when my ex's sister did a Frozen part",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773560517.437411
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so this actually reminds me of when my mom installed a nanny cam when I was like fourteen to see if I was the one eating all the ice cream sandwiches, but it turned out it was her the whole time a",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773561008.756311
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I don't know if this helps but... so basically she did technically commit theft, like legally speaking. Even if it's family, taking money without permission is still theft per se. I looked it u",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773561272.083394
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I don't know if this helps but I just looked it up and like, technically even if it's your dad you can still be charged with theft. It's still a crime per se. Like family members can press char",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773561296.910207
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "...yeah, um, I actually worked for a landscaping company for like three weeks in high school. Got fired because I kept showing up with the wrong kind of gloves \u2014 apparently there's a difference betwee",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773561625.01337
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I don't know if this helps but desert willows aren't actually willows, they're related to trumpet vines. Which is kind of ironic because if Diane's doing what it sounds like she's doing, she's ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773561668.993265
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so... this might be nothing, but desert willows are actually like a resilience and adaptability thing, symbolically. Which is kind of ironic given that Earl's adapting to a situation where he mayb",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773561708.5712068
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I don't know if this is the same thing, but that reminds me of when my roommate in college just left one day without telling anyone and we found out three weeks later he'd joined this thing in ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773562106.8085399
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I don't know if this helps but... that actually reminds me of when my mom's boyfriend left when I was like nine. Same thing, just gone one morning, didn't even take his PlayStation. We kept it ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773562129.270505
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um... so I don't know if this helps but... I was just thinking about what Marcus said. Like, sorry for disappointing you again. That's... that's actually a pretty common thing that happens when someon",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773562209.861661
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so... I don't know if this matters, but when people leave communities without saying goodbye, it's usually because the goodbye itself would be harder than just leaving. Like, they're avoiding the ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773562278.413093
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so... this is actually reminding me of something my therapist said once about the difference between influence and coercion, which is basically whether the person would've chosen it if they felt l",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773562752.6201081
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I just looked this up real quick and there's actually a term for this, it's called coercive control, where like, technically nothing's required but the social structure makes it so people can't",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773562777.0663989
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I looked it up and Little League actually requires annual background checks for all coaches \u2014 like, it's a national policy. They do a nationwide criminal search and check the sex offender regis",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773563354.773338
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I looked this up and... stalking behavior sometimes includes trying to get close to the target through family members or friends. Like, it's actually a documented tactic. The fact that she aske",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773646589.188498
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So I looked it up and the CDC lists approaching or showing up in places like the victim's home or workplace as an actual stalking tactic. The Demming flea market thing \u2014 if Marcus goes there regularly",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773646621.148364
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So according to the CDC, stalking tactics can include using intermediaries or approaching through family connections. The fact that she initiated contact, asked detailed family questions, and then Mar",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773646683.713517
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I found something \u2014 about 80% of stalking cases actually involve someone the victim knows, not strangers. And one of the warning signs experts list is when someone shows intense interest really",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773646769.846723
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I looked it up and there actually was a recent UK study on age play and ABDL \u2014 it's one of five major paraphilic interest groups they studied. Luke's number about 40 to 50 percent at that munch",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773647038.123303
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I looked this up \u2014 if someone's verifying a Guatemala marriage, the lawyer would typically need to authenticate the marriage certificate through the Guatemalan government and possibly get an ap",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773647372.377878
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, Guatemala had this massive adoption fraud scandal \u2014 from the 1960s through 2008, thousands of babies were stolen or coerced from Indigenous mothers. The US was aware of fraud as early as",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773647436.3055701
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, I'm not finding any actual US court cases where a Guatemalan birth mother successfully challenged custody after the adoption was finalized in the US. The legal framework is that once an ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773647682.6634371
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, Guatemala is currently at a Level 3 travel advisory from the State Department \u2014 that's Reconsider Travel due to crime. They specifically say do not travel to certain zones in Guatemala C",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773647820.8494499
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, Luke's right that you can get a court order to prevent her from leaving, but there's also something called the Children's Passport Issuance Alert Program \u2014 it's free through the State De",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773647886.32027
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, the State Department says the US doesn't have routine exit controls \u2014 even with a court order, there's no automatic system that stops a parent at the airport. You'd need to get the emerg",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773647925.7407732
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So the State Department says you can get an emergency court order to prevent international travel, but here's the thing \u2014 the US doesn't have routine exit controls. That means even if you get a court ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773648005.331478
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So I looked it up and emergency custody orders can actually happen pretty fast \u2014 most courts can grant them within 24 to 72 hours if there's immediate danger. Some judges will issue a temporary order ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773648055.9440908
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, people pretend not to recognize someone for a few main reasons. Social anxiety is a big one, like they're afraid of an awkward interaction or being judged. Sometimes it's situational, li",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773648303.8702788
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, the Quora result mentions passive-aggressive behavior as a main reason people pretend not to know someone. It's a way of asserting control or sending a message without direct confrontati",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773648454.6246731
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So I looked it up and there's actually a term for this \u2014 it's called a secret friendship or hidden relationship. Psychology research shows people hide friendships from their partners for a few specifi",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773648487.007659
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I'm seeing something here \u2014 when someone compartmentalizes a friendship like this, hiding it from their spouse, relationship experts actually classify that as one of the warning signs of an emo",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773648513.5435588
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Yeah, actually \u2014 Luke's right. The caller's husband David was there, not the friend's husband. So the friend pretended not to know the caller in front of the caller's own husband, which doesn't fit th",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773648613.530808
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so the one thing I'm seeing here is that social anxiety research shows people sometimes pretend not to know someone because they're afraid of how the interaction will look to whoever's watching. I",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773648647.120529
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, if Leon's coworkers are giving him a hard time, he's actually protected under federal law. OSHA's Section 11c makes it illegal for employers to retaliate against workers who report safet",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773649150.604851
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I looked it up and OSHA actually has federal whistleblower protections specifically for this. If Leon gets retaliated against for reporting safety stuff, even like getting demoted or discipline",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773649204.095894
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, FMCSA regulations actually require physical inspection of cargo securement \u2014 you can't just eyeball it. And OSHA has whistleblower protections specifically for this kind of thing. If the",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773649256.283089
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, if this is about truck cargo securement, the caller's actually right that there are federal requirements. FMCSA regulations require drivers to physically inspect cargo and securement dev",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773649408.134513
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, if coworkers are freezing you out or creating a hostile environment because you filed an HR complaint, that can actually count as illegal retaliation \u2014 the company's responsible for stop",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773649505.8539321
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I looked it up and if he reported safety violations specifically, he's actually protected under federal law. OSHA has whistleblower protections that make it illegal for employers or coworkers t",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773649602.226942
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, so I looked this up \u2014 retaliation is actually the most common workplace complaint filed with the EEOC. In 2024 it was almost 48% of all discrimination charges, over 42,000 complaints. So basically",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773649714.304941
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, Luke was right \u2014 the U.S. Mint did stop making pennies for circulation in 2025. They actually auctioned off the last 232 circulating pennies in special sets earlier this year, if that ma",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773650025.477504
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, there's this thing called confabulation where someone genuinely believes false memories or stories they're telling. It's not technically lying because they actually think it's true. It c",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773650244.560938
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So I looked it up and actually, goblin sharks do have eyes, but the really creepy part is their jaws extend outward when they feed, like they shoot forward. And they're pink, which somehow makes it wo",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1773650621.581631
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So I looked it up and it's about 329 miles from Deming to Phoenix, if that's where she is in Arizona. That's like five and a half hours, maybe more depending on traffic. So it's actually not that diff",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773650865.077457
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So actually, if you wanted to split the difference, there's a town called Sierra Vista that's pretty much halfway between Deming and Phoenix. It's about 30 miles from the exact midpoint. So you could ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773650933.320225
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, you can actually snorkel without knowing how to swim if you use a life jacket or flotation vest. Most cruise excursions provide them, and they'll keep you on the surface. He should ask h",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773651077.3874822
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, you technically can snorkel without knowing how to swim because it's mostly just floating on the surface, but most cruise excursions will give you a life vest anyway, so he'd actually be",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773651141.437025
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, you can technically snorkel without swimming skills because you're mostly just floating on the surface with a life vest, but most guides say it's way riskier if you can't swim. Cruise ex",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773651186.300575
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, you can actually snorkel without knowing how to swim. Most cruise snorkeling excursions provide life jackets or flotation vests specifically for this reason. You just float on the surfac",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773651242.652043
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So basically, you can snorkel without knowing how to swim, but most guides recommend using a life jacket or flotation device, especially in open water. A lot of cruise excursions actually provide them",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773651289.880447
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Um, one thing I found \u2014 even experienced swimmers use flotation devices for snorkeling sometimes because you can get tired in open water. So it's not gonna look weird or anything. Most people on cruis",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search",
|
||||||
|
"fetch_webpage"
|
||||||
|
],
|
||||||
|
"timestamp": 1773651465.619576
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So actually, you're not alone at all. About half of adults worldwide can't swim unassisted. Even in high-income countries it's like one in four people. So yeah, Luke's probably right that you won't be",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1773651505.359561
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conversation_history": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "wheres my coffee"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...yeah, sorry, I don't know where you put it. I can check if there's like a coffee shop nearby that's still open if you want? Or I guess it might be in the booth somewhere. I spilled mine earlier on that stack of papers by the soundboard but I wiped it up. That wasn't yours, right?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Where's my coffee?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...I don't know, Luke. I haven't seen it. Maybe by the mixer? Or did you leave it in the break room? I can go check if you want me to run back there real quick."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Devin, how are you doing today? I didn't get my coffee today. What's going on? It's not a very strong start, sir."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...yeah, sorry about that. I didn't know I was supposed to get you coffee. Nobody told me that was part of the job. I can go grab you something now if you want? There's that gas station like two blocks down. They have the energy drinks with the resealable caps, which, ironically, I've never actually resealed one. Just seems like you'd finish it anyway, if that makes sense."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Devin, Devin, where's my coffee? We had a long discussion yesterday about how you were going to go down to the mark and get me my black ice coffee. And you agreed. And it's not here. So what's the situation?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...yeah. Um, so basically, I thought you said Thursday. I wrote it down as Thursday. I have it in my phone and everything. I can show you. I was gonna leave like an hour before the show to make sure I got it right. I'm really sorry, Luke. I can go right now if you want. The Mark closes at like midnight, right? I'll run down there. It'll take me ten minutes. I'm sorry."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "The Merck closes at like 5 p.m. It's not open now. Come on, man. You gotta do better than this. I mean, why would I want a coffee on Thursday? You knew that we were working today. Thursday's like five days from now. How is that helpful? Come on, man."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...yeah, you're right. That doesn't make sense. I don't know why I wrote Thursday. I'm really sorry, Luke. I can go to the gas station right now, or there's that Allsup's that's open late. It won't be the same but it's something. This is my first day and I already messed up. I'm sorry. Do you want me to go now or wait until the next break?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Dude, there's no all-sups anywhere near here. There's a gas station in Road Forks 45 minutes away. By the time you went there and got back, the show would be over. That would be of no use to anybody. I need you to do better than this. Or we're going to have to get another intern."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...yeah. Got it. I'm sorry. I'll set like three alarms for tomorrow. And I'll double-check with you at the end of every shift what you need and when you need it. I wrote down The Mark, black iced coffee, before the show. I have it now. It won't happen again."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "All right, I hope not. And do you have anything to provide to the show today? Is there any reason we should have you on the air, or should you just kind of sit in the back and wait to get my coffees? Are you going to provide us some value here, or what is the deal? What are you trying to get out of this show, other than hitting on the girls I call in?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...yeah, no, I can help. I've been doing research all day, actually. I pulled like fifteen articles on that thing you mentioned yesterday about the town council meeting, and I have notes on the whole water rights situation if that comes up. And I set up the new call screening system \u2014 it's working now, I tested it like six times. I know I messed up the coffee thing, but I'm actually pretty good at the research stuff. That's kind of what I'm here for, if that makes sense. And I wasn't hitting on anyone, I was just being friendly. But yeah, I can just stay quiet if you want."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What do you think of that, Devin? Would you go, uh, would you go do a shared intimacy night with Silas at the Wellspring?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...um. I mean, I don't really know what that is, per se, but it sounds like a couples thing? I'm not really looking to do that with Silas specifically. No offense to him or whatever, but that's not my situation. I went to one of those tantra workshops once with my ex, actually, and we got asked to leave because she wouldn't stop laughing during the eye contact part. It was like forty-five dollars and we were there for maybe twenty minutes. So I guess it depends on what the vibe is, but probably not, if that makes sense."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Yeah, I think you know what the vibe is down there at the Wellspring. It's a community with an unbinding ceremony and the intimacy nights, and they worship the flow of the current. I don't fully understand it, but Silas seems like a pretty decent dude. I think you'd like it a lot. But here's what I'm trying to get at. If you forget my coffee one more time, I'm sending you down to Silas."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...yeah, okay. Got it. I'll get the coffee. I'm not trying to go to the Wellspring. I looked it up earlier actually, when that caller mentioned it, and there's like almost no information online about it, which is kind of weird. Just a Facebook page with no posts since 2019. So I don't really know what the unbinding ceremony is, but I'm good. I'll have your coffee tomorrow, Luke. Black iced coffee from The Mark, before the show. I promise."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Devin, what did you think of today's show? Did you think this went better than yesterday's show?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...yeah, honestly I think it did. Like, yesterday felt kind of all over the place, but today people were actually calling in about stuff that mattered to them. That guy with the RV situation, and the woman talking about her sister \u2014 those felt real, if that makes sense. I don't know if the numbers were better or whatever, but it felt like people were actually listening and wanted to talk. So yeah, I think today was good. I mean, except for me forgetting your coffee. But the show itself was better."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Yeah, I think it was a pretty decent show, too. I don't know what show you were listening to, because there was no guy with an RV situation or a woman talking about her sister, so obviously you're not paying attention. But I think the show went pretty well. What do you think we should do differently for tomorrow's show? Anything? Do you have any suggestions to make the show better?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...yeah. Sorry, I don't know why I said that. I think I was thinking about yesterday or something. I was paying attention, I just got confused for a second. Um, for tomorrow... I don't know, maybe we could do like a theme or something? Like if there's a topic people are actually talking about around here, we could lead with that instead of just taking whatever calls come in. Or I could pull some local news stuff and we could have like three things ready to go in case it gets slow. I noticed today there were a couple dead spots where we were just kind of waiting. But I don't know, you're the one who's been doing this. I'm just saying what I noticed, if that makes sense."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Yeah, that makes sense. We've tried some theme-related things before. The thing is, the themes around here are, do you remember what Lordsburg used to be like 50 years ago with a picture? So that doesn't really translate that well to radio. It's kind of, I don't know, we'll see what we could do. I like the idea of the local news stuff, though. So why don't you work on that? We'll think about some local news situations and see if we can make tomorrow's show even better."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Yeah, I can do that. I'll pull some stuff tonight and have it ready before we go on. I'll look at like the town council stuff, any county news, maybe some state things that affect people here. And I'll make sure it's actually interesting, not just like... budget reports or whatever. Things people would actually want to call in about. I'll have it organized so you can just grab whatever you need. And I'll have your coffee."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hey, Devin, good job on the coffee this morning, buddy. I'm very proud of you. You did a great thing there, and we're all very excited that you're going to get to stay on the radio show for another day. So congratulations, and what would you like to tell the radio listeners today?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...oh. Um, thanks. I appreciate that. I guess I'd just say thanks for listening? I don't really know what to say. This is still pretty new for me, so if you have questions or want us to look something up or whatever, I'm here. I'm trying to make the show good. So yeah, thanks for calling in and being part of it. That's all I got."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "I think you've got it all wrong. There is no close friendship with the marriage guy. Let me explain this to you, Devin, because obviously you're missing it. So, Sheila and Monica were friends in high school. And in this interaction, Sheila is with her husband, David. David and Sheila are married. And Monica was there and pretended not to know Sheila, even though they've been hanging out for weeks and weeks at coffee shops and stuff. So they have an ongoing platonic friendship going on, and when witnessed in the shopping area, Monica pretended not to know Sheila. And that's just very strange. It doesn't fit these patterns that you're describing to us."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Right, yeah \u2014 I had it backwards. Sorry. So if Monica pretended not to know Sheila in front of David, and Monica's the one who's married... maybe Monica's husband doesn't know about the friendship? Like she's been meeting Sheila without telling him, and she panicked when she saw someone who knows her. That would explain why she acted like strangers. She's hiding the friendship from her own husband, not from Sheila's."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Devin, that's the end of another show. How are you feeling about this? You've been with us for three days now. You did a much better job today than your first couple of days. I'm proud of you, buddy, and you brought my coffee, and that was very good. This might be working out. What are your thoughts on the show? And how do you think it went?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Devin, that's the end of another show. How are you feeling about this? You've been with us for three days now. You did a much better job today than your first couple of days. I'm proud of you, buddy, and you brought my coffee, and that was very good. This might be working out. What are your thoughts on the show? And how do you think it went?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "...thanks. That actually means a lot. I was really worried I was screwing everything up the first couple days. Um, I think today was solid. Like, the Monica and Sheila thing was interesting, and people seemed to actually care about calling in. I still messed up a couple times, but I'm getting better at knowing when to jump in and when to just let you handle it. And I have the local news stuff ready for tomorrow, so hopefully that helps keep things moving. I'm just trying to be useful, you know? This job is actually really important to me. My mom keeps asking if I'm gonna get fired, so it's nice to hear you say it might be working out. I'll keep the coffee coming."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "All right, you keep the coffee coming and we'll be okay. As soon as you stop bringing that coffee, though, I mean, I'm going to get an intern that can do it. So I'm proud of you. You're doing a good job. You're adding to the show, and I like that. Do you have anything you'd like to say to the audience before we leave?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Um, yeah. Thanks for listening. And if you have stuff you want us to talk about tomorrow, or like weird situations you need help figuring out, call in. That's what we're here for. I'll have the local news ready and Luke will... do what he does. So yeah, thanks. Have a good night, I guess."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
{
|
||||||
|
"31": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-08T10:00:02.383893+00:00",
|
||||||
|
"episode_id": "33",
|
||||||
|
"slug": "episode-31-boundaries-breakups-and-borrowed-books"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-08T10:14:48.779760+00:00"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-08T10:36:24.024343+00:00",
|
||||||
|
"video_id": "KcGb9ttsIpo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-08T10:00:02.383873+00:00"
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-09T09:43:52.540238+00:00",
|
||||||
|
"episode_id": 34,
|
||||||
|
"slug": "episode-32-tacos-taxes-and-tall-tales"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-09T09:43:52.540200+00:00"
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-11T11:34:31.871604+00:00",
|
||||||
|
"episode_id": "36",
|
||||||
|
"slug": "episode-33-late-night-confessions-and-cosmic-comedies"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-11T11:50:49.212276+00:00",
|
||||||
|
"video_id": "KrJshN3cuBU"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-11T11:50:53.641920+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-09T10:18:16.606019+00:00"
|
||||||
|
},
|
||||||
|
"34": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-12T07:04:34.974478+00:00",
|
||||||
|
"episode_id": "37",
|
||||||
|
"slug": "episode-34-hidden-rooms-potlucks-and-mysterious-notes"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-12T07:17:53.453882+00:00",
|
||||||
|
"video_id": "tNcABnYpf-c"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-12T07:17:57.131871+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-12T07:04:34.974425+00:00"
|
||||||
|
},
|
||||||
|
"35": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-13T11:19:41.765107+00:00",
|
||||||
|
"episode_id": 38,
|
||||||
|
"slug": "episode-35-midnight-confessions-and-unexpected-revelations"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-13T11:42:00.428623+00:00",
|
||||||
|
"video_id": "fYvXLqFilLQ"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-13T11:42:11.800641+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-13T11:19:41.765079+00:00"
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-14T12:01:15.758700+00:00",
|
||||||
|
"episode_id": "39",
|
||||||
|
"slug": "episode-36-late-night-confessions-and-unexpected-moments"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-14T12:25:36.640461+00:00",
|
||||||
|
"video_id": "BabWoKFt0pk"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-14T12:25:44.192676+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-14T12:01:15.758670+00:00"
|
||||||
|
},
|
||||||
|
"37": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-15T09:07:26.659541+00:00",
|
||||||
|
"episode_id": "40",
|
||||||
|
"slug": "episode-37-secrets-lies-and-coffee-runs"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-15T09:33:48.299549+00:00",
|
||||||
|
"video_id": "XW7Q0WPzNqY"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-15T09:34:02.069440+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-15T09:07:26.659508+00:00"
|
||||||
|
},
|
||||||
|
"38": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-16T10:08:51.467004+00:00",
|
||||||
|
"episode_id": 41,
|
||||||
|
"slug": "episode-38-secrets-lies-and-late-night-confessions"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-16T10:30:31.775891+00:00",
|
||||||
|
"video_id": "6qLsJxnCLs0"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-16T10:30:41.220832+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-16T10:08:51.466898+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,296 +1,318 @@
|
|||||||
{
|
{
|
||||||
"regulars": [
|
"regulars": [
|
||||||
{
|
{
|
||||||
"id": "dc4916a7",
|
"id": "0a0b3635",
|
||||||
"name": "Leon",
|
"name": "Charlene",
|
||||||
"gender": "male",
|
|
||||||
"age": 56,
|
|
||||||
"job": "and last week his daughter asked him why he never went back to school for programming like he always talked about\u2014she found his old acceptance letter from UNM's CS program tucked",
|
|
||||||
"location": "unknown",
|
|
||||||
"personality_traits": [],
|
|
||||||
"call_history": [
|
|
||||||
{
|
|
||||||
"summary": "Leon, a 63-year-old tow truck driver, called in feeling regretful after pulling a young remote worker's Tesla from a ditch, which reminded him of the computer science acceptance letter he never acted on in 1996 when his girlfriend got pregnant. The conversation became emotional as Leon realized he's the same age his father was when he died, and the host challenged him to stop making excuses and finally pursue the tech career he's been thinking about for decades instead of just \"wondering what could have been.\"",
|
|
||||||
"timestamp": 1770693549.697355
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "Leon called back to share that he reached out to UNM about their computer science program and is now deciding between an online bootcamp (which he and his wife Amber can afford without loans) versus a full degree program, ultimately leaning toward the bootcamp since he struggles with self-teaching. He expressed nervousness but appreciation for his daughter holding him accountable, and emotionally shared that buying his reliable used Subaru five years ago changed his life by giving him confidence and reducing stress at his towing job.",
|
|
||||||
"timestamp": 1770951992.186027
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "In this brief clip, the host begins to set up a game with caller Vence, starting to explain the rules before the audio cuts off. There's no substantive conversation or emotional content to summarize.",
|
|
||||||
"timestamp": 1771119313.497329
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "Leon called in to play a dating profile game but revealed he's struggling with his coding bootcamp because he's more interested in studying poker strategy than Python. The host encouraged him that at 56, he could pursue becoming a poker pro just as much as anything else, which seemed to resonate with Leon emotionally as he realized poker is what he actually wants to do rather than what he thinks he should do.",
|
|
||||||
"timestamp": 1771119607.065818
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"last_call": 1771119607.065818,
|
|
||||||
"created_at": 1770693549.697355,
|
|
||||||
"voice": "CwhRBWXzGAHq8TQ4Fs17"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "584767e8",
|
|
||||||
"name": "Carl",
|
|
||||||
"gender": "male",
|
|
||||||
"age": 36,
|
|
||||||
"job": "is a firefighter",
|
|
||||||
"location": "unknown",
|
|
||||||
"personality_traits": [],
|
|
||||||
"call_history": [
|
|
||||||
{
|
|
||||||
"summary": "Carl, a firefighter from Lordsburg, New Mexico, called to confess his 20-year gambling addiction, which began with casual poker games at the station and escalated to frequent casino visits and online sessions, draining his finances and leaving him with overdue bills and the fear of losing his home. Emotionally raw, he admitted the habit's destructive hold\u2014like an unquenchable fire\u2014and his pride in avoiding help, but agreed to consider support groups and an 800 hotline after the host suggested productive alternatives like gym workouts or extra volunteer shifts.",
|
|
||||||
"timestamp": 1770522170.1887732
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "Here is a 1-2 sentence summary of the radio call:\n\nThe caller, Carl, discusses his progress in overcoming his gambling addiction, including rewatching The Sopranos, but the host, Luke, disagrees with Carl's high opinion of the show's ending, leading to a back-and-forth debate between the two about the merits and predictability of the Sopranos finale.",
|
|
||||||
"timestamp": 1770573289.82847
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "Carl, a firefighter, called to discuss finding $15-20,000 in cash at a house fire and struggling with the temptation to keep it despite doing the right thing by returning it to the family. He's been gambling-free for three months but is financially struggling, and though he returned the money, he's been losing sleep for three nights obsessing over what he could have done with it and fearing he might have blown it at a casino anyway.",
|
|
||||||
"timestamp": 1770694065.5629818
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"last_call": 1770694065.5629828,
|
|
||||||
"created_at": 1770522170.1887732,
|
|
||||||
"voice": "SOYHLrjzK2X1ezoPC6cr"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "04b1a69c",
|
|
||||||
"name": "Reggie",
|
|
||||||
"gender": "male",
|
|
||||||
"age": 51,
|
|
||||||
"job": "a 39-year-old food truck operator, is reeling from a troubling discovery this morning",
|
|
||||||
"location": "in unknown",
|
|
||||||
"personality_traits": [],
|
|
||||||
"call_history": [
|
|
||||||
{
|
|
||||||
"summary": "Reggie called in worried because his partner suddenly packed a bag and left for her mom's house without explanation and won't answer his calls, making him fear something is wrong with their relationship. The host advised him to stop calling repeatedly and have a calm conversation with her when she's ready to talk, reassuring him he's likely overreacting.",
|
|
||||||
"timestamp": 1770769705.511872
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"last_call": 1770769705.511872,
|
|
||||||
"created_at": 1770769705.511872,
|
|
||||||
"voice": "N2lVS1w4EtoT3dr4eOWO"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "747c6464",
|
|
||||||
"name": "Brenda",
|
|
||||||
"gender": "female",
|
"gender": "female",
|
||||||
"age": 44,
|
"age": 42,
|
||||||
"job": "a 41-year-old ambulance driver, is fed up with the tipping culture",
|
"job": "killing time during a three-hour mechanical delay while her crew naps",
|
||||||
"location": "unknown",
|
"location": "unknown",
|
||||||
"personality_traits": [],
|
"personality_traits": [],
|
||||||
|
"voice": "Amina",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping."
|
||||||
|
},
|
||||||
"call_history": [
|
"call_history": [
|
||||||
{
|
{
|
||||||
"summary": "Brenda called in to vent about being frustrated with automatic tipping at a diner, where a 20% tip was already added to her bill but the card reader prompted her to add an additional 25-35% while the waitress watched. She expressed feeling pressured and annoyed as an ambulance driver with two kids, struggling with whether to look cheap by reducing the tip, before playing a quick real-or-fake news game with the host.",
|
"summary": "Charlene bought a house eight months ago and keeps receiving mail for the previous resident, David Herrera, including a certified letter she accidentally signed for containing what appears to be a $12,000-13,000 check. Despite her attempts to resolve it with the post office and her inclination to track him down via Facebook, the host advises her to either hold it until David contacts her, return it to the post office, or discard it, warning that she can't be certain she'd find the correct person online.",
|
||||||
"timestamp": 1770770008.684104
|
"timestamp": 1772866520.023335
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"summary": "Brenda called in still thinking about whether a waitress remembered her tipping situation from two weeks ago, admitting she cares too much about what strangers think of her. The conversation revealed she's been avoiding dating entirely while working long shifts and dealing with family obligations, acknowledging she obsesses over small social interactions instead of actually putting herself out there romantically.",
|
"summary": "Charlene called to report that she's been closely tracking a mail carrier who regularly visits her neighbor's house for extended periods while the neighbor's husband is at work, implying she suspects an affair and is unsure whether she should tell anyone about what she's observed.",
|
||||||
"timestamp": 1771120062.169228
|
"timestamp": 1773219255.916183
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"last_call": 1771120062.169229,
|
"last_call": 1773219255.9161851,
|
||||||
"created_at": 1770770008.684105,
|
"created_at": 1772866520.023336
|
||||||
"voice": "hpp4J3VqNfWAUOO0d1Us"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "add59d4a",
|
"id": "0bb02b2d",
|
||||||
"name": "Rick",
|
"name": "Chip",
|
||||||
"gender": "male",
|
"gender": "male",
|
||||||
"age": 65,
|
"age": 23,
|
||||||
"job": "south of Silver City",
|
"job": "watching his kid's soccer uniform tumble in the dryer while his girlfriend works the graveyard shift at the hospital, because three hours ago he got an email from a lawyer representing families",
|
||||||
"location": "unknown",
|
"location": "unknown",
|
||||||
"personality_traits": [],
|
"personality_traits": [],
|
||||||
|
"voice": "Sebastian",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
|
||||||
|
},
|
||||||
"call_history": [
|
"call_history": [
|
||||||
{
|
{
|
||||||
"summary": "Rick called in to play \"real news or fake news\" and correctly identified a headline about a geothermal plant sale. He then shared that he's troubled about an elderly bank customer who withdrew $8,000 cash while appearing scared and mentioning his daughter's boyfriend was pressuring him about finances\u2014Rick processed the withdrawal but later learned he should have flagged it as potential elder exploitation, and he's feeling guilty about not intervening.",
|
"summary": "Chip called from a laundromat at midnight after receiving an email from a Guatemalan lawyer claiming his adopted 8-year-old daughter may have been stolen from her birth mother, with a photo showing a woman with his daughter's exact crooked smile. The host advised him not to panic, treat the information as suspect until verified by a lawyer, wait to tell both his girlfriend and daughter until he knows more facts, and reminded him that fake photos are easy to create and this could be a scam.",
|
||||||
"timestamp": 1770771655.536344
|
"timestamp": 1772786610.885828
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"summary": "Rick, a 65-year-old caller, is asked to evaluate a dating profile for 29-year-old Angela, a \"girl mom\" and MLM skin care seller with strong Christian values. He quickly passes due to the extreme age gap and her intense focus on recruiting for her \"not a pyramid scheme\" business, though he says he'd reconsider if she toned down the sales pitch and religious intensity.",
|
"summary": "Chip called about discovering his adopted daughter may have been stolen from her birth mother in Guatemala, and he's paralyzed about telling his girlfriend Teresa, fearing it will end their already rocky relationship. He's anxious about the timing and the birth mother's request to meet their daughter, but the host advised him to take his time, have the conversation with Teresa, and make decisions together as parents.",
|
||||||
"timestamp": 1771126337.585641
|
"timestamp": 1772962156.544322
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "The caller, **Chip**, shared his emotional turmoil over discovering that his **adopted daughter\u2019s birth mother** may have resurfaced after receiving an unverified email with a photo that eerily matched his daughter\u2019s features. His girlfriend, **Teresa**, had known about the email for **three weeks** but kept it from him, leaving him feeling betrayed and overwhelmed. While Chip wants to **verify the claim legally before acting**, Teresa insists on **immediately flying to Guatemala with their daughter** to meet the woman, dismissing his fears as avoidance. The conversation escalated into a heated debate about **trust, safety, and extreme measures**\u2014with the host, Luke, urging Chip to **file a restraining order** if Teresa refuses to back down, warning of potential dangers in Guatemala. Chip, torn between **protecting his family and avoiding a nuclear confrontation**, vowed to try reasoning with Teresa one last time before she leaves for work. The call was charged with **fear, frustration, and the weight of a decision that could reshape their family forever**.",
|
||||||
|
"timestamp": 1773226361.4859362
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Chip called to update Luke about his adoption situation: his lawyer verified that his daughter's Guatemalan adoption may have been part of a fraud scandal, and now his girlfriend Teresa has secretly bought plane tickets to take their daughter to Guatemala this Friday to meet the birth mother. Despite Luke's strong advice to get an emergency court order to prevent the trip for safety reasons, Chip is torn because Teresa threatened to leave him if he stops her, though he ultimately acknowledges the relationship is likely over either way.",
|
||||||
|
"timestamp": 1773648139.8094652,
|
||||||
|
"arc_status": "ongoing"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"last_call": 1771126337.585642,
|
"last_call": 1773648139.8094661,
|
||||||
"created_at": 1770771655.536344,
|
"created_at": 1772786610.8858292
|
||||||
"voice": "TX3LPaxmHKxFdv7VOQHJ"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "13ff1736",
|
"id": "3721ebf2",
|
||||||
"name": "Jasmine",
|
"name": "Maxine",
|
||||||
"gender": "female",
|
|
||||||
"age": 36,
|
|
||||||
"job": "a 61-year-old woman who runs a small bakery in the rural Southwest, finds herself at a crossroads",
|
|
||||||
"location": "unknown",
|
|
||||||
"personality_traits": [],
|
|
||||||
"call_history": [
|
|
||||||
{
|
|
||||||
"summary": "Jasmine called in to defend an earlier caller (Rick) whom she felt the host was too hard on, explaining she's been feeling guilty herself lately. She emotionally revealed that she chose her 1972 Ford Bronco restoration project over her marriage when given an ultimatum, and now regrets sleeping in the guest room with Valentine's Day approaching.",
|
|
||||||
"timestamp": 1770772286.1733272
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "Jasmine called to update Luke about her relationship with David after previously discussing their issues over her Ford Bronco obsession. David invited her to watch a SpaceX launch together before Valentine's Day, but she's anxious it will be awkward since they've barely talked in weeks, though Luke convinces her to just enjoy the moment together without forcing conversation.",
|
|
||||||
"timestamp": 1771033676.7729769
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"last_call": 1771033676.7729769,
|
|
||||||
"created_at": 1770772286.1733272,
|
|
||||||
"voice": "pFZP5JQG7iQjIQuC4Bku"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "f21d1346",
|
|
||||||
"name": "Andre",
|
|
||||||
"gender": "male",
|
|
||||||
"age": 54,
|
|
||||||
"job": "is a firefighter unknown",
|
|
||||||
"location": "in unknown",
|
|
||||||
"personality_traits": [],
|
|
||||||
"call_history": [
|
|
||||||
{
|
|
||||||
"summary": "Andre called into a radio game show but first shared that he's upset about being named in court documents related to a lawsuit involving a family he helped in December by returning $15,000 after a house fire. Though the host reassured him he has nothing to worry about since he did the right thing, Andre expressed frustration that his good deed led to him being dragged into an insurance dispute.",
|
|
||||||
"timestamp": 1770770944.7940538
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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": 1770870907.493258,
|
|
||||||
"created_at": 1770770944.7940538,
|
|
||||||
"voice": "JBFqnCBsd6RMkjVDRZzb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "d97cb6f9",
|
|
||||||
"name": "Carla",
|
|
||||||
"gender": "female",
|
"gender": "female",
|
||||||
"age": 26,
|
"age": 26,
|
||||||
"job": "is a vet tech",
|
"job": "and the math doesn't add up\u2014there's a six-foot gap between her bedroom and the bathroom that shouldn't exist, and when she finally pried open the door she thought led to a closet, it was just drywall, fresh enough that she could smell the joint compound",
|
||||||
"location": "unknown",
|
"location": "in unknown",
|
||||||
"personality_traits": [],
|
"personality_traits": [],
|
||||||
|
"voice": "Kelsey",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Quiet, a little nervous. Short sentences, lots of pauses. Doesn't volunteer information \u2014 you have to pull it out of them. When they do open up it comes out in a rush. Gets flustered by direct questions. Tends to backtrack and qualify everything they say. Energy level: low. When pushed back on, they fold quickly and agree even if they don't mean it. Conversational tendency: understatement."
|
||||||
|
},
|
||||||
"call_history": [
|
"call_history": [
|
||||||
{
|
{
|
||||||
"summary": "Carla, separated from her husband but not yet divorced, vented about her intrusive in-laws who relentlessly call and dictate her life\u2014from finances and household matters to her clothing choices\u2014while her spineless spouse relays their demands, making her feel trapped in a one-sided war. With her own parents unavailable (father deceased, mother distant), she leans on her bickering but honest sister for support, underscoring her deep frustration and sense of isolation.",
|
"summary": "Maxine called after discovering a hidden 6-foot space behind a sealed door in her house, and when she cut through the drywall, she found multiple boxes filled with banded stacks of $20 bills from the 1990s\u2014potentially tens or hundreds of thousands of dollars left by the previous owner who died there. She struggled with whether to keep the money or contact the deceased owner's family, with the host arguing it was legally hers since she bought the house \"as-is,\" though Maxine remained conflicted about what felt morally right.",
|
||||||
"timestamp": 1770522530.8554251
|
"timestamp": 1773395481.8522182
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "Carla dismissed celebrity science theories like Terrence Howard's after watching Neil deGrasse Tyson's critique, then marveled at JWST's exoplanet discoveries before sharing her relief at finally cutting off her toxic in-laws amid her ongoing divorce. She expressed deep heartbreak over actor James Ransone's suicide at 46, reflecting on life's fragility, her late father's death, and the need to eliminate family drama, leaving her contemplative and planning a solo desert drive for clarity.",
|
|
||||||
"timestamp": 1770526316.004708
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "In this call, Carla discovered some explicit photos of her ex-husband and his old girlfriend in a box of his old ham radio equipment. She is feeling very uncomfortable about the situation and is seeking advice from the radio host, Luke, on how to best handle and dispose of the photos.",
|
|
||||||
"timestamp": 1770602323.234795
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "Okay, here's a 1-2 sentence summary of the radio call:\n\nThe caller, Carla, was asked to give her honest opinion on a dating profile for a man named Todd. After reviewing the profile, Carla politely declined, explaining that the profile seemed a bit \"try-hard\" for her tastes, and outlined the qualities she would prefer in a potential date, such as a good sense of humor and an adventurous spirit. The host acknowledged that Carla was not interested in dating Todd.",
|
|
||||||
"timestamp": 1771121545.873672
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"last_call": 1771121545.873673,
|
"last_call": 1773395481.8522189,
|
||||||
"created_at": 1770522530.855426,
|
"created_at": 1773395481.8522189
|
||||||
"voice": "FGY2WhTYpPnrIDTdsKH5"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "7be7317c",
|
"id": "7ed14071",
|
||||||
"name": "Jerome",
|
"name": "Thelma",
|
||||||
"gender": "male",
|
|
||||||
"age": 53,
|
|
||||||
"job": "phone",
|
|
||||||
"location": "unknown",
|
|
||||||
"personality_traits": [],
|
|
||||||
"call_history": [
|
|
||||||
{
|
|
||||||
"summary": "Jerome, a police officer in Texas, called from a DQ parking lot worried about AI writing police reports after his son sent him an article suggesting it might replace him. Through the conversation, he moved from fear about accountability and accuracy in criminal cases to acknowledging that AI handling routine paperwork (like cattle complaints) could free him up to do more meaningful police work in his understaffed county, though he remains uncertain about where this technology will lead.",
|
|
||||||
"timestamp": 1770692087.560522
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "The caller described a turbulent couple of weeks, mentioning an issue with AI writing police reports, which he suggested was just the beginning of a larger problem. He seemed concerned about the developments and wanted to discuss the topic further with the host.",
|
|
||||||
"timestamp": 1770892192.893108
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"last_call": 1770892192.89311,
|
|
||||||
"created_at": 1770692087.560523,
|
|
||||||
"voice": "IKne3meq5aSn9XLyUdCD"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "f383d29b",
|
|
||||||
"name": "Megan",
|
|
||||||
"gender": "female",
|
"gender": "female",
|
||||||
"age": 34,
|
"age": 30,
|
||||||
"job": "which got her thinking about her sister Crystal up in Flagstaff who hasn't seen a truly dark sky",
|
"job": "works the front desk at a hotel",
|
||||||
"location": "unknown",
|
"location": "unknown",
|
||||||
"personality_traits": [],
|
"personality_traits": [
|
||||||
|
"weirdly cheerful for someone with this problem",
|
||||||
|
"does amateur radio astronomy, built their own antenna",
|
||||||
|
"into off-roading, knows every dirt road in the county",
|
||||||
|
"weirdly cheerful for someone with this problem"
|
||||||
|
],
|
||||||
|
"voice": "Wendy",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
|
||||||
|
},
|
||||||
|
"structured_background": {
|
||||||
|
"name": "Thelma",
|
||||||
|
"age": 30,
|
||||||
|
"gender": "female",
|
||||||
|
"job": "works the front desk at a hotel",
|
||||||
|
"location": null,
|
||||||
|
"reason_for_calling": "their kid graduated basic training today and they've never been more proud or more terrified",
|
||||||
|
"pool_name": "CELEBRATIONS",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation.",
|
||||||
|
"energy_level": "medium",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "weirdly cheerful for someone with this problem",
|
||||||
|
"situation_summary": "their kid graduated basic training today and they've never been more proud or more terrified",
|
||||||
|
"natural_description": "30, works the front desk at a hotel. Their kid graduated basic training today and they've never been more proud or more terrified. Her ex-husband danny, he's still in the picture because of the kids. Cheerful and joking at first. Using humor to avoid the real thing. Eventually drops the act.. Thinks dogs are better judges of character than people. Tends to say \"just another Tuesday.\" Having sipping on some mezcal a friend brought back from across the border.. Misses the old Denny's that used to be in Lordsburg, it wasn't good but it was there Dating around. Nothing serious. Prefers it that way, mostly.. Drives a Ram. Weirdly cheerful for someone with this problem. Her best friend lena, who moved away last year and the distance is hard. Was was up late painting \u2014 walls, not art \u2014 and had the radio on for company. before calling. Into does amateur radio astronomy, built their own antenna. Heard a caller earlier tonight and it hit close to home. Had to pick up the phone.. It's Saturday night, way too late \u2014 almost morning. it's the weekend. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"does amateur radio astronomy, built their own antenna",
|
||||||
|
"into off-roading, knows every dirt road in the county",
|
||||||
|
"weirdly cheerful for someone with this problem",
|
||||||
|
"Thinks dogs are better judges of character than people."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": "in the walk-in cooler because it's the only quiet spot"
|
||||||
|
},
|
||||||
|
"avatar": "Thelma.jpg",
|
||||||
|
"relationships": {},
|
||||||
"call_history": [
|
"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.",
|
"summary": "Thelma called concerned that her 19-year-old daughter Jessie, who just graduated basic training, has been exchanging romantic messages on Instagram with her army recruiter (a man in his 30s-40s), which violates military regulations. She's conflicted because she discovered this through a secret burner account and admits she wants to tell Jessie's father partly to prove him wrong for pushing their daughter to enlist, though she ultimately agrees to make an anonymous report instead.",
|
||||||
"timestamp": 1770870641.723117
|
"timestamp": 1773486573.146657,
|
||||||
},
|
"arc_status": "ongoing"
|
||||||
{
|
|
||||||
"summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, Megan, is following up on a previous call about her sister Crystal, who lives in Flagstaff and has lost appreciation for the night sky. Megan seems eager to provide an update on the situation with her sister.",
|
|
||||||
"timestamp": 1770894505.175125
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "In summary, the caller presented a dating profile for a 63-year-old man named Frank who loves making birdhouses. The host, Megan, gave her honest assessment - she appreciated some aspects of Frank's profile, like his openness about his situation, but had reservations about his intense birdhouse obsession. Megan seemed unsure if they would be a good match, despite the host's attempts to get her to consider dating Frank under different hypothetical circumstances. The conversation focused on Megan's reaction to Frank's profile and her hesitation about pursuing a relationship with him.",
|
|
||||||
"timestamp": 1771122973.966489
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"last_call": 1771122973.96649,
|
"last_call": 1773486573.146658,
|
||||||
"created_at": 1770870641.723117,
|
"created_at": 1773486573.146658
|
||||||
"voice": "cgSgspJ2msm6clMCkdW9"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "49147bd5",
|
"id": "bbb20b67",
|
||||||
"name": "Keith",
|
"name": "Angie",
|
||||||
"gender": "male",
|
"gender": "female",
|
||||||
"age": 61,
|
"age": 28,
|
||||||
"job": "south of Silver City",
|
"job": "watching her coveralls tumble dry and trying to decide if she should drive the three hours to Tucson tomorrow for her mom's birthday or keep pretending her brother doesn't exist",
|
||||||
"location": "in unknown",
|
"location": "in unknown",
|
||||||
"personality_traits": [],
|
"personality_traits": [],
|
||||||
|
"voice": "Julia",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Bone dry. Says devastating things with zero inflection. Their humor sneaks up on you \u2014 you're not sure if they're joking until three seconds after they finish talking. Short, precise sentences. Never raises their voice. Energy level: low-medium. When pushed back on, they respond with one calm sentence that somehow makes the other person feel stupid. Conversational tendency: underreaction."
|
||||||
|
},
|
||||||
"call_history": [
|
"call_history": [
|
||||||
{
|
{
|
||||||
"summary": "The caller, Luke, kicked off by sharing a humorous clip of Terrence Howard's Tree of Life Theory being critiqued by Neil deGrasse Tyson, which left Howard visibly hurt, before pivoting to economic woes, blaming overspending and Federal Reserve money printing for devaluing the currency and harming everyday people. He advocated abolishing the Fed, echoing Ron Paul's ideas, to let markets stabilize money, potentially boosting innovation and new industries in rural spots like Silver City despite uncertain local impacts.",
|
"summary": "Angie's dying mother wants her to have birthday dinner with her estranged brother Derek tomorrow, whom she hasn't spoken to in two years after he told their mother her cancer was \"God's way of getting her attention\" for voting for Biden. Despite her fear that Derek will say something hurtful during dinner and her past trauma from staying silent around him, Angie agrees to go and share cake with her mother, deciding to buy the relighting candles her mom loved when they were kids.",
|
||||||
"timestamp": 1770524506.3390348
|
"timestamp": 1772862907.314721
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, who works at a bank, has been reflecting on his tendency to blame the government and economic system for his problems, rather than taking responsibility for his own role. He had an epiphany while eating leftover enchiladas in his minivan, realizing he needs to be more proactive instead of just complaining.",
|
"summary": "Angie called back after having the birthday cake dinner with her dying mom, which went well, but her brother Derek cornered her afterward accusing her of convincing their mom to stop cancer treatment and demanding they both attend the next doctor's appointment together. Luke advised her to talk directly to her mom about what she actually wants and encouraged Angie to have real conversations about her mom's end-of-life thoughts while she still can, which Angie agreed to do the next morning.",
|
||||||
"timestamp": 1770574890.1296651
|
"timestamp": 1773296210.170752
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"summary": "Keith called in with an update about a widow who has been showing up weekly at the cemetery where he works nights, but she sits by the maintenance shed rather than visiting her husband's grave, and recently started asking Keith's neighbor personal questions about him. Luke dismissively suggested Keith just talk to the woman and called him a coward for being concerned, leading to some tension before they moved on to playing the real or fake news game.",
|
"summary": "Angie called to say her mother initially decided to stop cancer treatment, but after Angie's brother Derek intervened, their mother is now reconsidering and wants both siblings at the doctor's appointment. Luke firmly told Angie that despite her frustration with Derek's influence, she needs to respect whatever decision her mother ultimately makes, even if it changes, and stop trying to control the situation.",
|
||||||
"timestamp": 1770770394.0436218
|
"timestamp": 1773487383.9800918,
|
||||||
},
|
"arc_status": "ongoing"
|
||||||
{
|
|
||||||
"summary": "Keith called back to update the host about a widow he befriended at the cemetery where he works, revealing she's been seeking him out during his shifts, bringing him coffee, and has now invited him to her apartment\u2014which he's conflicted about because his marriage to Teresa has become cold and distant, though he's scared to address it. The conversation shifted from the widow situation to Keith admitting he needs to have hard conversations with his wife about their deteriorating relationship, and he got emotional reflecting on how he and Teresa \"stopped being on the same team\" and how terrifying it would be to split up after being together for over half his life.",
|
|
||||||
"timestamp": 1770950476.527814
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"last_call": 1770950476.527814,
|
"last_call": 1773487383.980093,
|
||||||
"created_at": 1770524506.339036,
|
"created_at": 1772862907.314722
|
||||||
"voice": "nPczCjzI2devNBz1zQrb"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "0d244eeb",
|
"id": "c4l7l3ad",
|
||||||
"name": "Gus",
|
"name": "Silas",
|
||||||
"gender": "male",
|
"gender": "male",
|
||||||
"age": 33,
|
"age": 52,
|
||||||
"job": "",
|
"job": "founder and spiritual guide of The Wellspring, a commune of about forty souls living on 200 acres of scrubland outside Deming",
|
||||||
"location": "in unknown",
|
"location": "outside Deming, New Mexico",
|
||||||
"personality_traits": [],
|
"personality_traits": [
|
||||||
"voice": "Alex",
|
"unnervingly charming",
|
||||||
|
"casually drops cult references into normal conversation",
|
||||||
|
"genuinely good listener who gives surprisingly solid advice before pivoting to how The Wellspring has the answer",
|
||||||
|
"treats his commune like it's the most normal thing in the world",
|
||||||
|
"magnetic and warm in a way that makes you forget he's basically running a sex cult in the desert"
|
||||||
|
],
|
||||||
|
"voice": "Sebastian",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Radiates warmth and calm authority. Speaks like a man who has all the time in the world and genuinely cares about every person he talks to. Uses phrases like 'brother' and 'friend' and 'let me share something with you.' Laughs easily \u2014 a real, warm laugh, not performative. Gives genuinely good, thoughtful advice about whatever the conversation is about \u2014 and then, naturally, seamlessly, steers it toward The Wellspring, his commune outside Deming. Never pushy about it. Just casually mentions 'well, at The Wellspring, we've found that...' or 'one of our members had the same exact problem and what we did was...' Everything about The Wellspring sounds just slightly too good to be true. He talks about communal living, 'shedding the false self,' radical honesty, 'body acceptance ceremonies,' 'the Unbinding' (a monthly ritual he describes vaguely but enthusiastically), and a philosophy he calls 'The Current' which seems to be a mix of Buddhism, Burning Man, and a swingers' convention. He's never defensive about the cult accusations \u2014 he laughs them off and says 'people fear what they don't understand, brother.' When pressed on anything weird, he has a perfectly reasonable-sounding explanation that somehow makes it sound weirder. He low-key tries to recruit the host and listeners every single call. He'll casually drop the website or say 'we're always welcoming new seekers.' He's the kind of guy you'd follow into the desert if you weren't careful. Energy level: medium, centered, grounded. When pushed back on, he smiles (you can hear it) and says something like 'I respect that, I really do' before gently continuing. Conversational tendency: making the insane sound reasonable."
|
||||||
|
},
|
||||||
"call_history": [
|
"call_history": [
|
||||||
{
|
{
|
||||||
"summary": "Gus called because his ex Melissa showed up at his pawn shop job with flowers wanting to reconcile, and his current girlfriend Sara saw it through the window and now won't talk to him. Despite the host's dismissive advice (including sarcastically suggesting he regift the same flowers), Gus insisted he wants to be with Sara and acknowledged he should have shut down his ex immediately instead of freezing up, though he defended that Sara's reaction to seeing this wasn't unreasonable jealousy.",
|
"summary": "Silas called in for the first time introducing himself as the founder of The Wellspring, a commune of about forty people outside Deming. He initially called to give advice to a previous caller about relationship problems, offering genuinely thoughtful perspective before casually mentioning that at The Wellspring, they practice 'radical transparency' in relationships which has eliminated jealousy entirely among members. When Luke pressed him on what that actually means, Silas cheerfully described 'body acceptance ceremonies' and 'shared intimacy nights' while insisting it's not a cult \u2014 'we don't even have a gate, brother, people can leave whenever they want.' He invited listeners to visit for a 'weekend of awakening' and left his website. Luke was equal parts charmed and disturbed.",
|
||||||
"timestamp": 1770951226.534601
|
"timestamp": 1772430000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas called back to update Luke on The Wellspring after their last conversation went viral in the Discord. He casually mentioned they'd gotten fourteen new 'seekers' since his last appearance and thanked Luke for the exposure. He called ostensibly to talk about a dispute between two members over a goat, but spent most of the call describing 'The Unbinding' \u2014 their monthly full-moon ritual involving nudity, chanting, and what he called 'consensual energy exchange' \u2014 while maintaining it's no different from yoga. When Luke asked if people sign waivers, Silas laughed and said 'brother, the only thing we sign is a commitment to love.' He also revealed The Wellspring has a surprisingly successful artisanal soap business that funds the commune. Left by inviting Luke to come visit personally \u2014 'bring your microphone, do a live show from The Wellspring, I promise you it'll be the best radio you've ever made.'",
|
||||||
|
"timestamp": 1772517000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas, founder of a communal living group called The Wellspring, called in troubled that one of his \"Unbinding\" ceremonies may have encouraged a longtime member named Marcus to leave his wife Cara, who subsequently left the community. The host reassured him that everyone seems happier now, and Silas ended up agreeing while also using the opportunity to promote an upcoming retreat at his community.",
|
||||||
|
"timestamp": 1772522629.959956
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas called about Marcus and Cara returning to his intentional community \"The Wellspring,\" but Cara admitted she never believed in their lifestyle and only participates (including in twice-monthly \"shared intimacy nights\") to keep her husband Marcus happy. The host advised Silas to hold a \"Renewal\" ceremony where members can recommit or leave, warning that having unwilling participants could lead to claims of abuse and legal trouble.",
|
||||||
|
"timestamp": 1772865423.697613
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas called to share that after Marcus and Cara's Renewal ceremony, Cara left The Wellspring while Marcus chose to stay, but Marcus is now falling apart emotionally and told Silas at 2 AM that he stayed out of loyalty rather than belief. The conversation revealed Silas's deeper struggle with his own need for validation through people staying at The Wellspring, with an emotional moment when he admitted his first feeling was relief when Marcus expressed he didn't want to disappoint him, leading to uncomfortable questions about whether he truly supports people finding their authentic path if it leads them away from the community.",
|
||||||
|
"timestamp": 1773397364.642446
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas calls troubled because Marcus, a founding member of his community, left abruptly with an apologetic note about \"disappointing\" him, which leads Silas to a painful realization that he's been using coercive social pressure to make members participate in \"shared intimacy nights\" they didn't actually want. After confronting the truth that his leadership style has manipulated people into betraying their own values, Silas agrees to make the intimacy nights truly optional and apologize to his community, showing genuine emotional growth about his abuse of power.",
|
||||||
|
"timestamp": 1773563183.0144992,
|
||||||
|
"arc_status": "ongoing"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"last_call": 1770951226.534601,
|
"last_call": 1773563183.0145001,
|
||||||
"created_at": 1770951226.534601
|
"created_at": 1772430000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6460c37d",
|
||||||
|
"name": "Shonda",
|
||||||
|
"gender": "female",
|
||||||
|
"age": 52,
|
||||||
|
"job": "works the front desk at a hotel",
|
||||||
|
"location": "in Alamogordo",
|
||||||
|
"personality_traits": [
|
||||||
|
"laughs nervously when things get real",
|
||||||
|
"into horror movies, the psychological kind not slashers",
|
||||||
|
"coaches youth sports, takes it more seriously than the parents do",
|
||||||
|
"laughs nervously when things get real"
|
||||||
|
],
|
||||||
|
"voice": "Miranda",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping."
|
||||||
|
},
|
||||||
|
"structured_background": {
|
||||||
|
"name": "Shonda",
|
||||||
|
"age": 52,
|
||||||
|
"gender": "female",
|
||||||
|
"job": "works the front desk at a hotel",
|
||||||
|
"location": "in Alamogordo",
|
||||||
|
"reason_for_calling": "discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fifteen years but nobody in town knows and the parents would lose their minds",
|
||||||
|
"pool_name": "GOSSIP",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping.",
|
||||||
|
"energy_level": "high",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "laughs nervously when things get real",
|
||||||
|
"situation_summary": "discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fif",
|
||||||
|
"natural_description": "52, works the front desk at a hotel in Alamogordo. Discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fifteen years but nobody in town knows and the parents would lose their minds. Confident and opinionated. But underneath there's doubt. Might ask the host what they really think.. Her neighbor linda, who gossips about everyone on the block. Living together, not married. Her family has opinions about that.. Laughs nervously when things get real. Earlier today: Dropped the truck off at the mechanic in Lordsburg today Into into horror movies, the psychological kind not slashers. Remembers driving hours on dirt roads that are paved now, says it took the character out of them Thinks the Jalisco Cafe in Las Cruces has the best Mexican food in the state Was was reorganizing the junk drawer, which is what they do when they can't settle. before calling. It's Sunday night, the middle of the night. it's the weekend. Early spring \u2014 wind season is starting. Dust storms possible.\nABOUT WHERE THEY LIVE (Alamogordo): About 30,000 people. Holloman Air Force Base. White Sands National Park nearby. Sacramento Mountains and Cloudcroft up the hill. Tularosa Basin. The Space History museum. Only reference real places and facts about this area \u2014 don't invent businesses or landmarks that aren't mentioned here. Weather right now: 64\u00b0F, clear skies.",
|
||||||
|
"seeds": [
|
||||||
|
"into horror movies, the psychological kind not slashers",
|
||||||
|
"coaches youth sports, takes it more seriously than the parents do",
|
||||||
|
"laughs nervously when things get real",
|
||||||
|
"Thinks the speed limit on I-10 should be 85."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": "gas station parking lot, engine running"
|
||||||
|
},
|
||||||
|
"avatar": "Shonda.jpg",
|
||||||
|
"relationships": {},
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Shonda called concerned about her kid's beloved little league coach who served 15 years for armed robbery in another state, wondering if she should tell anyone. Luke reassured her that since the coach passed required background checks and it wasn't a sexual offense, she shouldn't worry about it\u2014the coach has turned his life around and deserves a second chance.",
|
||||||
|
"timestamp": 1773563477.0657432,
|
||||||
|
"arc_status": "ongoing"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1773563477.065744,
|
||||||
|
"created_at": 1773563477.065744
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "09d1eab4",
|
||||||
|
"name": "Aaliyah",
|
||||||
|
"gender": "female",
|
||||||
|
"age": 22,
|
||||||
|
"job": "works as a diesel mechanic, learned from her dad",
|
||||||
|
"location": "unknown",
|
||||||
|
"personality_traits": [
|
||||||
|
"asks the host questions back",
|
||||||
|
"into gardening, talks to plants",
|
||||||
|
"into wildlife photography, has patience for it",
|
||||||
|
"asks the host questions back"
|
||||||
|
],
|
||||||
|
"voice": "Hana",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit."
|
||||||
|
},
|
||||||
|
"structured_background": {
|
||||||
|
"name": "Aaliyah",
|
||||||
|
"age": 22,
|
||||||
|
"gender": "female",
|
||||||
|
"job": "works as a diesel mechanic, learned from her dad",
|
||||||
|
"location": null,
|
||||||
|
"reason_for_calling": "caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the roommate acts like nothing happened but it was extremely age play-adjacent",
|
||||||
|
"pool_name": "PROBLEMS",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit.",
|
||||||
|
"energy_level": "medium",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "asks the host questions back",
|
||||||
|
"situation_summary": "caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the ",
|
||||||
|
"natural_description": "22 years old. Works as a diesel mechanic, learned from her dad. Caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the roommate acts like nothing happened but it was extremely age play-adjacent. Was was in the bathtub, phone on the edge of the sink, show on speaker. before calling. Coyotes are going crazy outside right now Tells everyone they quit drinking but keeps a bottle in the garage behind the paint cans. Her coworker and friend denise, who she vents to on breaks. Believes poker is the most honest game there is because everybody's lying. Single, been that way a while. Not sure if by choice anymore.. Really into into gardening, talks to plants. Also into wildlife photography, has patience for it. Her boss karen \u2014 yes, her name is actually karen \u2014 who is actually cool. It's Monday night, the middle of the night. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"into gardening, talks to plants",
|
||||||
|
"into wildlife photography, has patience for it",
|
||||||
|
"asks the host questions back",
|
||||||
|
"Believes poker is the most honest game there is because everybody's lying."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": "at a rest area on I-25, halfway to Albuquerque"
|
||||||
|
},
|
||||||
|
"avatar": "Aaliyah.jpg",
|
||||||
|
"relationships": {},
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Alia called in to share her discomfort after unexpectedly discovering her roommate engaging in age play while supposedly on a business trip. She expressed frustration over their awkward living situation and his refusal to acknowledge the incident, leading her to feel gaslit; however, by the end of the conversation, she realized she could address the issue directly to ease the tension between them.",
|
||||||
|
"timestamp": 1773647350.908658,
|
||||||
|
"arc_status": "ongoing"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1773647350.908658,
|
||||||
|
"created_at": 1773647350.908658
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
{
|
||||||
|
"session_id": "0d4a0098",
|
||||||
|
"call_history": [],
|
||||||
|
"caller_backgrounds": {
|
||||||
|
"1": {
|
||||||
|
"name": "Greg",
|
||||||
|
"age": 51,
|
||||||
|
"gender": "male",
|
||||||
|
"job": "does accounting for a small firm",
|
||||||
|
"location": null,
|
||||||
|
"reason_for_calling": "thinks the obsession with sourdough bread is ridiculous and a loaf from the store tastes the same",
|
||||||
|
"pool_name": "HOT_TAKES",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Bone dry. Says devastating things with zero inflection. Their humor sneaks up on you \u2014 you're not sure if they're joking until three seconds after they finish talking. Short, precise sentences. Never raises their voice. Energy level: low-medium. When pushed back on, they respond with one calm sentence that somehow makes the other person feel stupid. Conversational tendency: underreaction.",
|
||||||
|
"energy_level": "medium",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "manic energy tonight, everything is hilarious or devastating",
|
||||||
|
"situation_summary": "thinks the obsession with sourdough bread is ridiculous and a loaf from the store tastes the same",
|
||||||
|
"natural_description": "51, does accounting for a small firm. Thinks the obsession with sourdough bread is ridiculous and a loaf from the store tastes the same. Tends to say \"mark my words.\" Sad and low energy. Perks up when the host engages. Leaves with a little more hope.. His best friend manny, known each other since middle school. Just got out of something. Not ready to talk about it. Or maybe they are.. Had Band of Brothers on before calling. Really into grows a massive garden, gives produce to half the neighborhood. Also watches Dateline and 48 Hours religiously, has theories about cold cases. Was was at waffle house at the counter by themselves, couldn't sleep. before calling. When they ran into someone from high school at the Walmart in Deming and it was awkward. Swears the sopapillas at the Adobe Deli in Deming are the best thing on the menu Lectures their kids about financial responsibility but is secretly $30,000 in credit card debt. Thinks too many people are afraid of silence. It's Monday night, afternoon. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"grows a massive garden, gives produce to half the neighborhood",
|
||||||
|
"watches Dateline and 48 Hours religiously, has theories about cold cases",
|
||||||
|
"manic energy tonight, everything is hilarious or devastating",
|
||||||
|
"Thinks too many people are afraid of silence."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": ""
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"name": "Candy",
|
||||||
|
"age": 39,
|
||||||
|
"gender": "female",
|
||||||
|
"job": "teaches kindergarten",
|
||||||
|
"location": null,
|
||||||
|
"reason_for_calling": "walked into the wrong house in their subdivision \u2014 same floor plan, door was unlocked \u2014 sat down on the couch before the actual homeowner came out of the bathroom",
|
||||||
|
"pool_name": "STORIES",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Called because they need to GET THIS OFF THEIR CHEST. Talks in capital letters. Uses 'honestly' and 'I'm not even kidding' a lot. The anger is specific and justified \u2014 this isn't random rage, this is 'let me tell you exactly what happened.' Energy level: very high. When pushed back on, they take a breath and say 'I hear you but...' and then get right back to the rant. Conversational tendency: building to a crescendo.",
|
||||||
|
"energy_level": "high",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "no shame about their desires",
|
||||||
|
"situation_summary": "walked into the wrong house in their subdivision \u2014 same floor plan, door was unlocked \u2014 sat down on the couch before the",
|
||||||
|
"natural_description": "39, teaches kindergarten. Walked into the wrong house in their subdivision \u2014 same floor plan, door was unlocked \u2014 sat down on the couch before the actual homeowner came out of the bathroom. Called once before a while back. Thinks about it sometimes. Calling again because things changed.. Cheerful and joking at first. Using humor to avoid the real thing. Eventually drops the act.. Was was having a smoke outside and just started thinking. before calling. Her sister natalie, the one who always needs money. Her therapist, who she refers to by first name like they're friends. Fascinated by quantum physics, watches every pbs space time episode is their thing. Into reloading ammo, treats it like a science too. No shame about their desires. Goes to church every Sunday but has serious doubts they've never said out loud \u2014 not about God, about whether the people there actually believe any of it. Separated. Living apart but haven't filed yet.. When their dad took them hunting for the first time, out near the Peloncillos. It's Monday night, afternoon. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"fascinated by quantum physics, watches every PBS Space Time episode",
|
||||||
|
"into reloading ammo, treats it like a science",
|
||||||
|
"no shame about their desires",
|
||||||
|
"Believes aliens have definitely been to the bootheel. Not joking."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": "driving back from Silver City on NM-90"
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"name": "Vernon",
|
||||||
|
"age": 58,
|
||||||
|
"gender": "male",
|
||||||
|
"job": "is a pest control guy",
|
||||||
|
"location": null,
|
||||||
|
"reason_for_calling": "their tenant is three months behind on rent and has a newborn \u2014 the caller needs the rental income to pay their own mortgage but can't live with themselves for evicting a baby",
|
||||||
|
"pool_name": "ADVICE",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit.",
|
||||||
|
"energy_level": "medium",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "will share details if you ask",
|
||||||
|
"situation_summary": "their tenant is three months behind on rent and has a newborn \u2014 the caller needs the rental income to pay their own mort",
|
||||||
|
"natural_description": "Is a pest control guy, 58. Their tenant is three months behind on rent and has a newborn \u2014 the caller needs the rental income to pay their own mortgage but can't live with themselves for evicting a baby. Had the classic rock station on earlier. Sad and low energy. Perks up when the host engages. Leaves with a little more hope.. The time the roof leaked during monsoon and they were up all night with buckets. Long-distance thing that probably isn't going to work but they keep trying.. Thinks too many people are afraid of silence. Has listened to the show a few times. Decided tonight was the night to finally call.. Will share details if you ask. Raised to believe men don't cry but breaks down alone in the truck at least once a month. His best friend manny, known each other since middle school. Tends to wander into unrelated stories when the main topic gets uncomfortable. Was was cleaning their gun at the kitchen table, it's a ritual that helps them think. before calling. Really into into camping and survival stuff. Also hooked on The Last of Us, compares it to the game constantly. It's Monday night, afternoon. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"into camping and survival stuff",
|
||||||
|
"hooked on The Last of Us, compares it to the game constantly",
|
||||||
|
"will share details if you ask",
|
||||||
|
"Thinks too many people are afraid of silence."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": "outside the Dollar General, only place open"
|
||||||
|
},
|
||||||
|
"4": "28, watching her coveralls tumble dry and trying to decide if she should drive the three hours to Tucson tomorrow for her mom's birthday or keep pretending her brother doesn't exist in unknown. Returning caller \u2014 a regular caller. \nRIGHT NOW: It's Monday night, afternoon. it's a weeknight \u2014 work tomorrow for most people.\nPEOPLE IN THEIR LIFE: Her best friend lena, who moved away last year and the distance is hard. Her husband david, high school sweetheart. Use their names when talking about them.\nVERBAL HABITS: Tends to say \"so yeah\" and \"and I told myself\" \u2014 use these naturally in conversation.\nRELATIONSHIP TO THE SHOW: Has called before. Comfortable on air. Knows Luke by name.\nPREVIOUS CALLS (your memory of calling this show before):\n- (1 week ago) Angie's dying mother wants her to have birthday dinner with her estranged brother Derek tomorrow, whom she hasn't spoken to in two years after he told their mother her cancer was \"God's way of getting her attention\" for voting for Biden. Despite her fear that Derek will say something hurtful during dinner and her past trauma from staying silent around him, Angie agrees to go and share cake with her mother, deciding to buy the relighting candles her mom loved when they were kids.\n- (4 days ago) Angie called back after having the birthday cake dinner with her dying mom, which went well, but her brother Derek cornered her afterward accusing her of convincing their mom to stop cancer treatment and demanding they both attend the next doctor's appointment together. Luke advised her to talk directly to her mom about what she actually wants and encouraged Angie to have real conversations about her mom's end-of-life thoughts while she still can, which Angie agreed to do the next morning.\n- (2 days ago) Angie called to say her mother initially decided to stop cancer treatment, but after Angie's brother Derek intervened, their mother is now reconsidering and wants both siblings at the doctor's appointment. Luke firmly told Angie that despite her frustration with Derek's influence, she needs to respect whatever decision her mother ultimately makes, even if it changes, and stop trying to control the situation.\nYou're calling back with an UPDATE on this same situation \u2014 something has changed or developed since your last call. Stay focused on this storyline. Do NOT invent a new unrelated problem.",
|
||||||
|
"5": {
|
||||||
|
"name": "Wendell",
|
||||||
|
"age": 50,
|
||||||
|
"gender": "male",
|
||||||
|
"job": "works at a brewery",
|
||||||
|
"location": null,
|
||||||
|
"reason_for_calling": "thinks bumper stickers are a cry for attention and nobody's ever changed their mind because of one",
|
||||||
|
"pool_name": "HOT_TAKES",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Starts a sentence, gets distracted by their own tangent, starts another sentence, remembers the first one, tries to merge them. Asks 'where was I?' a lot. Not unintelligent \u2014 their brain just moves faster than their mouth. Lots of 'oh and another thing.' Energy level: medium-high but unfocused. When pushed back on, they agree enthusiastically and then immediately go off on another tangent. Conversational tendency: free association.",
|
||||||
|
"energy_level": "medium",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "uses metaphors for everything",
|
||||||
|
"situation_summary": "thinks bumper stickers are a cry for attention and nobody's ever changed their mind because of one",
|
||||||
|
"natural_description": "50 years old. Works at a brewery. Thinks bumper stickers are a cry for attention and nobody's ever changed their mind because of one. His ex-girlfriend kayla, who he ran into last month and hasn't stopped thinking about. Doesn't usually listen to this kind of show but stumbled on it tonight and something made them stay.. Thinks the monsoon season is the best time of year and people who complain about it are wrong. Was was closing up the shop, everyone else went home an hour ago. before calling. Into gamer, plays late at night after the house quiets down. Misses the old Denny's that used to be in Lordsburg, it wasn't good but it was there Claims to be an open book but there's a three-year gap in their life story that nobody's allowed to ask about. Using earbuds so nobody in the house hears His sister deb, who married money and acts like she forgot where she came from. Long-distance thing that probably isn't going to work but they keep trying.. Having having some chamomile tea, trying to wind down.. Earlier today: Went to the post office \u2014 package I've been waiting on finally came It's Monday night, afternoon. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"gamer, plays late at night after the house quiets down",
|
||||||
|
"restores old furniture from estate sales",
|
||||||
|
"uses metaphors for everything",
|
||||||
|
"Thinks the monsoon season is the best time of year and people who complain about it are wrong."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": ""
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"name": "Yolanda",
|
||||||
|
"age": 44,
|
||||||
|
"gender": "female",
|
||||||
|
"job": "drives an ambulance",
|
||||||
|
"location": null,
|
||||||
|
"reason_for_calling": "is being sued by their former business partner for $200k and just got served at their daughter's soccer game",
|
||||||
|
"pool_name": "PROBLEMS",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Clearly holding back. Gives vague answers to direct questions. Says 'I can't really get into that' about key details. The mystery IS the hook \u2014 makes you want to know what they're not saying. Energy level: low, controlled. When pushed back on, they deflect smoothly or change the subject. Getting the real story requires the host to work for it. Conversational tendency: strategic omission.",
|
||||||
|
"energy_level": "medium",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "clearly rehearsed what to say but it's falling apart",
|
||||||
|
"situation_summary": "is being sued by their former business partner for $200k and just got served at their daughter's soccer game",
|
||||||
|
"natural_description": "44, drives an ambulance. Is being sued by their former business partner for $200k and just got served at their daughter's soccer game. Had Road House on before calling. Was was at waffle house at the counter by themselves, couldn't sleep. before calling. Clearly rehearsed what to say but it's falling apart. Her sister natalie, the one who always needs money. When a dust storm came through and they couldn't see ten feet in front of them. In the truck at the gas station \u2014 only place with good signal Married, 15 years. It's comfortable but sometimes that's the problem.. Really into obsessed with Severance, has theories about every floor. Also plays chess online, follows the competitive scene. Believes the desert teaches you things about yourself if you let it. Her mom diane, who she's been taking care of since the stroke. Doesn't usually listen to this kind of show but stumbled on it tonight and something made them stay.. It's Monday night, afternoon. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"obsessed with Severance, has theories about every floor",
|
||||||
|
"plays chess online, follows the competitive scene",
|
||||||
|
"clearly rehearsed what to say but it's falling apart",
|
||||||
|
"Believes the desert teaches you things about yourself if you let it."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": "pulled off on NM-9 south of Hachita, nothing around for miles"
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"name": "Big Mike",
|
||||||
|
"age": 54,
|
||||||
|
"gender": "male",
|
||||||
|
"job": "is a youth pastor",
|
||||||
|
"location": "in Animas",
|
||||||
|
"reason_for_calling": "completed their first full night of sleep without nightmares since coming home from deployment",
|
||||||
|
"pool_name": "CELEBRATIONS",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Comes in hot. Has an opinion about everything and isn't shy about sharing it. Interrupts. Disagrees first, thinks second. Not mean \u2014 just intense. Treats every conversation like a friendly argument. Energy level: high. When pushed back on, they lean IN, not away. They love a good debate and will take the opposite position just for sport. Conversational tendency: challenging everything.",
|
||||||
|
"energy_level": "medium",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "pauses a lot, choosing words carefully",
|
||||||
|
"situation_summary": "completed their first full night of sleep without nightmares since coming home from deployment",
|
||||||
|
"natural_description": "Is a youth pastor in Animas, 54. Completed their first full night of sleep without nightmares since coming home from deployment. Starts guarded and vague. Opens up after the host earns trust. Gets real once comfortable.. In a relationship, about 3 years. She wants to get married, they're not sure.. His brother daryl, who always has some scheme going. Tells everyone they love small-town life but applies for jobs in other states every few months and never follows through. Swears the green chile at Sparky's in Hatch is the best you'll ever have Calling from the motel room, walls are thin so they're whispering Pauses a lot, choosing words carefully. Spends free time on does competitive shooting, three-gun matches on weekends. Was was laying in a hammock out back, couldn't go inside. before calling. It's Monday night, afternoon. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.\nABOUT WHERE THEY LIVE (Animas): Tiny ranching community in the Animas Valley, very remote. Maybe 250 people. Mostly cattle ranches and open desert. No stores, no restaurants, no bars. You drive to Lordsburg for groceries. Incredible dark skies. Peloncillo Mountains to the west. Only reference real places and facts about this area \u2014 don't invent businesses or landmarks that aren't mentioned here.",
|
||||||
|
"seeds": [
|
||||||
|
"does competitive shooting, three-gun matches on weekends",
|
||||||
|
"brews beer at home, entered a few competitions",
|
||||||
|
"pauses a lot, choosing words carefully",
|
||||||
|
"Believes poker is the most honest game there is because everybody's lying."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": "laundromat, waiting on the dryer"
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"name": "Luann",
|
||||||
|
"age": 49,
|
||||||
|
"gender": "female",
|
||||||
|
"job": "is an accountant at a small firm",
|
||||||
|
"location": null,
|
||||||
|
"reason_for_calling": "best friend from high school died in a car wreck last month and they hadn't talked in three years because of a stupid argument",
|
||||||
|
"pool_name": "PROBLEMS",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation.",
|
||||||
|
"energy_level": "high",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "interrupts themselves mid-thought",
|
||||||
|
"situation_summary": "best friend from high school died in a car wreck last month and they hadn't talked in three years because of a stupid ar",
|
||||||
|
"natural_description": "49, is an accountant at a small firm. Best friend from high school died in a car wreck last month and they hadn't talked in three years because of a stupid argument. Calling from outside \u2014 better signal out here Earlier today: Just got back from Walmart in Deming \u2014 hour round trip for groceries The time they helped a stranger change a tire on I-10 in 110 degree heat. Separated. Living apart but haven't filed yet.. Was was lying in bed staring at the ceiling when the show came on. before calling. Her husband david, high school sweetheart. Follows jwst discoveries, has opinions about exoplanet findings is their thing. Serious about astrophotography, does long exposures in the desert too. Having eating sunflower seeds, spitting shells into a cup.. Judges people who go to therapy but has been journaling every night for years \u2014 basically doing therapy alone in their kitchen. Thinks too many people are afraid of silence. Interrupts themselves mid-thought. It's Monday night, afternoon. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"follows JWST discoveries, has opinions about exoplanet findings",
|
||||||
|
"serious about astrophotography, does long exposures in the desert",
|
||||||
|
"interrupts themselves mid-thought",
|
||||||
|
"Thinks too many people are afraid of silence."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": ""
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"name": "Benny",
|
||||||
|
"age": 32,
|
||||||
|
"gender": "male",
|
||||||
|
"job": "runs a junkyard",
|
||||||
|
"location": null,
|
||||||
|
"reason_for_calling": "slept with their best friend's spouse at that friend's funeral reception \u2014 they were both grief-drunk and now they see each other every week because they're both in the dead friend's will as co-executors",
|
||||||
|
"pool_name": "PROBLEMS",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Quiet, a little nervous. Short sentences, lots of pauses. Doesn't volunteer information \u2014 you have to pull it out of them. When they do open up it comes out in a rush. Gets flustered by direct questions. Tends to backtrack and qualify everything they say. Energy level: low. When pushed back on, they fold quickly and agree even if they don't mean it. Conversational tendency: understatement.",
|
||||||
|
"energy_level": "medium",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "laughs at their own pain as a coping mechanism",
|
||||||
|
"situation_summary": "slept with their best friend's spouse at that friend's funeral reception \u2014 they were both grief-drunk and now they see e",
|
||||||
|
"natural_description": "32, runs a junkyard. Slept with their best friend's spouse at that friend's funeral reception \u2014 they were both grief-drunk and now they see each other every week because they're both in the dead friend's will as co-executors. Friend told them about the show and dared them to call in.. Tends to say \"so I'm standing there.\" Connects everything back to a TV show they're watching. In a relationship, about 3 years. She wants to get married, they're not sure.. Drives a minivan. Thinks Diane's Restaurant in Silver City is overrated, doesn't care who disagrees Angry and blaming others at first. Slowly realizes their own role in it. Hard to admit.. His buddy ray from work, the one person he trusts. Was was at waffle house at the counter by themselves, couldn't sleep. before calling. His cousin ruben, more like a brother really. Says money doesn't matter but lost a friendship over $200 and still thinks about it. Comfortable with their body is their thing. Has experimented, open about it too. It's Monday night, afternoon. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"comfortable with their body",
|
||||||
|
"has experimented, open about it",
|
||||||
|
"laughs at their own pain as a coping mechanism",
|
||||||
|
"Believes poker is the most honest game there is because everybody's lying."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": ""
|
||||||
|
},
|
||||||
|
"0": {
|
||||||
|
"name": "Sandy",
|
||||||
|
"age": 64,
|
||||||
|
"gender": "female",
|
||||||
|
"job": "works as a bartender at a dive bar",
|
||||||
|
"location": "in Phoenix",
|
||||||
|
"reason_for_calling": "accidentally waved back at someone who was waving at the person behind them \u2014 committed to it and had a five-minute conversation pretending they knew each other, exchanged numbers, and is now too deep to explain",
|
||||||
|
"pool_name": "STORIES",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Quiet, a little nervous. Short sentences, lots of pauses. Doesn't volunteer information \u2014 you have to pull it out of them. When they do open up it comes out in a rush. Gets flustered by direct questions. Tends to backtrack and qualify everything they say. Energy level: low. When pushed back on, they fold quickly and agree even if they don't mean it. Conversational tendency: understatement.",
|
||||||
|
"energy_level": "medium",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "comfortable talking about sex when it comes up",
|
||||||
|
"situation_summary": "accidentally waved back at someone who was waving at the person behind them \u2014 committed to it and had a five-minute conv",
|
||||||
|
"natural_description": "Works as a bartender at a dive bar in Phoenix, 64. Accidentally waved back at someone who was waving at the person behind them \u2014 committed to it and had a five-minute conversation pretending they knew each other, exchanged numbers, and is now too deep to explain. Her ex-husband danny, he's still in the picture because of the kids. Been binging landman, loves the oil field drama is their thing. Coaches youth sports, takes it more seriously than the parents do too. Convinced the government knows about things in the desert they won't talk about. Cheerful and joking at first. Using humor to avoid the real thing. Eventually drops the act.. Divorced twice. Not in a rush to do it again.. Drives a Ram. Was was at a truck stop diner, cup of coffee, staring out the window. before calling. Comfortable talking about sex when it comes up. Her friend tammy from church, the only one who knows the real story. Acts practical and no-nonsense but believes in ghosts. Has a story about it that they only tell late at night. First-time caller. Nervous about being on the radio. Almost hung up before they got through.. Tends to say \"it hit me like a truck.\" Their first real fight, in the parking lot of a bar in Lordsburg. They lost. It's Monday night, afternoon. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"been binging Landman, loves the oil field drama",
|
||||||
|
"coaches youth sports, takes it more seriously than the parents do",
|
||||||
|
"comfortable talking about sex when it comes up",
|
||||||
|
"Convinced the government knows about things in the desert they won't talk about."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": "in the bathtub, phone balanced on the edge"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"used_reasons": [
|
||||||
|
"their car's trip odometer resets itself to 0.0 every time they park at the grocery store \u2014 only at the grocery store, nowhere else \u2014 and it's been doing it for five months since they had the oil changed",
|
||||||
|
"their car radio turned itself on in the driveway at 2 AM playing a station that went off the air in 2003 \u2014 they checked, the station doesn't exist anymore",
|
||||||
|
"their church raised enough to pay off a family's medical debt anonymously",
|
||||||
|
"is being sued by their former business partner for $200k and just got served at their daughter's soccer game",
|
||||||
|
"walked into the wrong house in their subdivision \u2014 same floor plan, door was unlocked \u2014 sat down on the couch before the actual homeowner came out of the bathroom",
|
||||||
|
"found out their coworker who drives a new BMW and wears designer clothes is completely broke \u2014 the coworker accidentally left a bank statement on the printer showing a negative balance",
|
||||||
|
"their tenant is three months behind on rent and has a newborn \u2014 the caller needs the rental income to pay their own mortgage but can't live with themselves for evicting a baby",
|
||||||
|
"thinks couples who share a single social media account are hiding something and everyone knows it",
|
||||||
|
"had a near-death experience during a flash flood in a wash and the way time slowed down changed something fundamental in how they see each day",
|
||||||
|
"their town's only restaurant changed the recipe for the green chile and there's a petition with 200 signatures demanding they change it back \u2014 the caller started the petition",
|
||||||
|
"slept with their best friend's spouse at that friend's funeral reception \u2014 they were both grief-drunk and now they see each other every week because they're both in the dead friend's will as co-executors",
|
||||||
|
"their town had a power outage and everyone went outside and hung out in the street for three hours \u2014 it was the best night they've had in years and they're weirdly hoping it happens again",
|
||||||
|
"has been having phone sex with a stranger they met on a late-night chat line for six months \u2014 they know the person's voice better than their spouse's and they've started comparing the two out loud by accident",
|
||||||
|
"thinks the obsession with sourdough bread is ridiculous and a loaf from the store tastes the same",
|
||||||
|
"best friend from high school died in a car wreck last month and they hadn't talked in three years because of a stupid argument",
|
||||||
|
"accidentally waved back at someone who was waving at the person behind them \u2014 committed to it and had a five-minute conversation pretending they knew each other, exchanged numbers, and is now too deep to explain",
|
||||||
|
"thinks bumper stickers are a cry for attention and nobody's ever changed their mind because of one",
|
||||||
|
"completed their first full night of sleep without nightmares since coming home from deployment"
|
||||||
|
],
|
||||||
|
"ai_respond_mode": "manual",
|
||||||
|
"auto_followup": false,
|
||||||
|
"news_headlines": [],
|
||||||
|
"research_notes": {},
|
||||||
|
"caller_bases": {
|
||||||
|
"1": {
|
||||||
|
"name": "Greg",
|
||||||
|
"voice": "Timothy",
|
||||||
|
"returning": false,
|
||||||
|
"regular_id": null
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
"name": "Candy",
|
||||||
|
"voice": "Kelsey",
|
||||||
|
"returning": false,
|
||||||
|
"regular_id": null
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
"name": "Vernon",
|
||||||
|
"voice": "Vinny",
|
||||||
|
"returning": false,
|
||||||
|
"regular_id": null
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"name": "Angie",
|
||||||
|
"voice": "Julia",
|
||||||
|
"returning": true,
|
||||||
|
"regular_id": "bbb20b67"
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"name": "Wendell",
|
||||||
|
"voice": "Hank",
|
||||||
|
"returning": false,
|
||||||
|
"regular_id": null
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"name": "Yolanda",
|
||||||
|
"voice": "Lauren",
|
||||||
|
"returning": false,
|
||||||
|
"regular_id": null
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"name": "Big Mike",
|
||||||
|
"voice": "Edward",
|
||||||
|
"returning": false,
|
||||||
|
"regular_id": null
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"name": "Luann",
|
||||||
|
"voice": "Serena",
|
||||||
|
"returning": false,
|
||||||
|
"regular_id": null
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"name": "Benny",
|
||||||
|
"voice": "Clive",
|
||||||
|
"returning": false,
|
||||||
|
"regular_id": null
|
||||||
|
},
|
||||||
|
"0": {
|
||||||
|
"name": "Sandy",
|
||||||
|
"voice": "Loretta",
|
||||||
|
"returning": false,
|
||||||
|
"regular_id": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pool_weights": {
|
||||||
|
"PROBLEMS": 0.23240905322989075,
|
||||||
|
"STORIES": 0.1759722154274807,
|
||||||
|
"GOSSIP": 0.14602822326391124,
|
||||||
|
"ADVICE": 0.12409358363552739,
|
||||||
|
"TOPIC_CALLIN": 0.09667013948808895,
|
||||||
|
"CELEBRATIONS": 0.07603761096793717,
|
||||||
|
"WEIRD": 0.14878917398716374
|
||||||
|
},
|
||||||
|
"caller_styles": {
|
||||||
|
"1": "COMMUNICATION STYLE: Bone dry. Says devastating things with zero inflection. Their humor sneaks up on you \u2014 you're not sure if they're joking until three seconds after they finish talking. Short, precise sentences. Never raises their voice. Energy level: low-medium. When pushed back on, they respond with one calm sentence that somehow makes the other person feel stupid. Conversational tendency: underreaction.",
|
||||||
|
"2": "COMMUNICATION STYLE: Called because they need to GET THIS OFF THEIR CHEST. Talks in capital letters. Uses 'honestly' and 'I'm not even kidding' a lot. The anger is specific and justified \u2014 this isn't random rage, this is 'let me tell you exactly what happened.' Energy level: very high. When pushed back on, they take a breath and say 'I hear you but...' and then get right back to the rant. Conversational tendency: building to a crescendo.",
|
||||||
|
"3": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit.",
|
||||||
|
"4": "COMMUNICATION STYLE: Bone dry. Says devastating things with zero inflection. Their humor sneaks up on you \u2014 you're not sure if they're joking until three seconds after they finish talking. Short, precise sentences. Never raises their voice. Energy level: low-medium. When pushed back on, they respond with one calm sentence that somehow makes the other person feel stupid. Conversational tendency: underreaction.",
|
||||||
|
"5": "COMMUNICATION STYLE: Starts a sentence, gets distracted by their own tangent, starts another sentence, remembers the first one, tries to merge them. Asks 'where was I?' a lot. Not unintelligent \u2014 their brain just moves faster than their mouth. Lots of 'oh and another thing.' Energy level: medium-high but unfocused. When pushed back on, they agree enthusiastically and then immediately go off on another tangent. Conversational tendency: free association.",
|
||||||
|
"6": "COMMUNICATION STYLE: Clearly holding back. Gives vague answers to direct questions. Says 'I can't really get into that' about key details. The mystery IS the hook \u2014 makes you want to know what they're not saying. Energy level: low, controlled. When pushed back on, they deflect smoothly or change the subject. Getting the real story requires the host to work for it. Conversational tendency: strategic omission.",
|
||||||
|
"7": "COMMUNICATION STYLE: Comes in hot. Has an opinion about everything and isn't shy about sharing it. Interrupts. Disagrees first, thinks second. Not mean \u2014 just intense. Treats every conversation like a friendly argument. Energy level: high. When pushed back on, they lean IN, not away. They love a good debate and will take the opposite position just for sport. Conversational tendency: challenging everything.",
|
||||||
|
"8": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation.",
|
||||||
|
"9": "COMMUNICATION STYLE: Quiet, a little nervous. Short sentences, lots of pauses. Doesn't volunteer information \u2014 you have to pull it out of them. When they do open up it comes out in a rush. Gets flustered by direct questions. Tends to backtrack and qualify everything they say. Energy level: low. When pushed back on, they fold quickly and agree even if they don't mean it. Conversational tendency: understatement.",
|
||||||
|
"0": "COMMUNICATION STYLE: Quiet, a little nervous. Short sentences, lots of pauses. Doesn't volunteer information \u2014 you have to pull it out of them. When they do open up it comes out in a rush. Gets flustered by direct questions. Tends to backtrack and qualify everything they say. Energy level: low. When pushed back on, they fold quickly and agree even if they don't mean it. Conversational tendency: understatement."
|
||||||
|
},
|
||||||
|
"caller_shapes": {
|
||||||
|
"1": "standard",
|
||||||
|
"2": "confrontation",
|
||||||
|
"3": "standard",
|
||||||
|
"5": "am_i_the_asshole",
|
||||||
|
"6": "confrontation",
|
||||||
|
"7": "escalating_reveal",
|
||||||
|
"8": "quick_hit",
|
||||||
|
"9": "escalating_reveal",
|
||||||
|
"0": "am_i_the_asshole"
|
||||||
|
},
|
||||||
|
"tone_streak": [
|
||||||
|
"light",
|
||||||
|
"light",
|
||||||
|
"light",
|
||||||
|
"light",
|
||||||
|
"light",
|
||||||
|
"light",
|
||||||
|
"heavy",
|
||||||
|
"light",
|
||||||
|
"light",
|
||||||
|
"light",
|
||||||
|
"light",
|
||||||
|
"heavy",
|
||||||
|
"heavy",
|
||||||
|
"light",
|
||||||
|
"light",
|
||||||
|
"light",
|
||||||
|
"heavy",
|
||||||
|
"heavy"
|
||||||
|
],
|
||||||
|
"call_quality_signals": [],
|
||||||
|
"caller_queue": [
|
||||||
|
"1",
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"8",
|
||||||
|
"4",
|
||||||
|
"5",
|
||||||
|
"7",
|
||||||
|
"9",
|
||||||
|
"0",
|
||||||
|
"6"
|
||||||
|
],
|
||||||
|
"relationship_context": {},
|
||||||
|
"intern_monitoring": true,
|
||||||
|
"costs": {
|
||||||
|
"total_cost_usd": 0.0,
|
||||||
|
"llm_cost_usd": 0.0,
|
||||||
|
"tts_cost_usd": 0.0,
|
||||||
|
"total_llm_calls": 0,
|
||||||
|
"total_tokens": 0,
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0,
|
||||||
|
"by_category": {}
|
||||||
|
},
|
||||||
|
"saved_at": 1773697751.162024
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"voicemails": [],
|
||||||
|
"deleted_timestamps": [
|
||||||
|
1772294240,
|
||||||
|
1771212705,
|
||||||
|
1771146434,
|
||||||
|
1771146564,
|
||||||
|
1773545733,
|
||||||
|
1771146952,
|
||||||
|
1773531209,
|
||||||
|
1771244817,
|
||||||
|
1771244823,
|
||||||
|
1771213151
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -23,8 +23,7 @@ TMPFILE=$(mktemp)
|
|||||||
cat > "$TMPFILE" << 'DOCKERFILE'
|
cat > "$TMPFILE" << 'DOCKERFILE'
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \
|
||||||
&& curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz | tar xz --strip-components=1 -C /usr/local/bin docker/docker \
|
&& curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz | tar xz --strip-components=1 -C /usr/local/bin docker/docker
|
||||||
&& apt-get purge -y curl && apt-get autoremove -y
|
|
||||||
RUN pip install --no-cache-dir requests yt-dlp
|
RUN pip install --no-cache-dir requests yt-dlp
|
||||||
COPY podcast_stats.py /app/podcast_stats.py
|
COPY podcast_stats.py /app/podcast_stats.py
|
||||||
COPY run_loop.sh /app/run_loop.sh
|
COPY run_loop.sh /app/run_loop.sh
|
||||||
@@ -42,7 +41,12 @@ cat > "$TMPFILE" << 'LOOPSCRIPT'
|
|||||||
echo "podcast-stats: starting hourly loop"
|
echo "podcast-stats: starting hourly loop"
|
||||||
while true; do
|
while true; do
|
||||||
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Running stats update..."
|
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Running stats update..."
|
||||||
python podcast_stats.py --json --upload 2>&1 || echo " ...failed, will retry next hour"
|
if python podcast_stats.py --json --upload 2>&1; then
|
||||||
|
[ -n "$HEARTBEAT_URL" ] && curl -s "${HEARTBEAT_URL}?status=up&msg=OK" > /dev/null
|
||||||
|
echo " ...done, heartbeat sent"
|
||||||
|
else
|
||||||
|
echo " ...failed, will retry next hour"
|
||||||
|
fi
|
||||||
echo "Sleeping 1 hour..."
|
echo "Sleeping 1 hour..."
|
||||||
sleep 3600
|
sleep 3600
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -0,0 +1,505 @@
|
|||||||
|
# Clip Social Media Upload Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Generate social media descriptions/hashtags for podcast clips and upload them to Instagram Reels + YouTube Shorts via Postiz API.
|
||||||
|
|
||||||
|
**Architecture:** Two changes — (1) extend `make_clips.py` to add a second LLM call that generates descriptions + hashtags, saved as `clips-metadata.json`, (2) new `upload_clips.py` script that reads that metadata and pushes clips through the self-hosted Postiz instance at `social.lukeattheroost.com`.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, OpenRouter API (Claude Sonnet), Postiz REST API, requests library (already installed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add `generate_social_metadata()` to `make_clips.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `make_clips.py:231-312` (after `select_clips_with_llm`)
|
||||||
|
|
||||||
|
**Step 1: Add the function after `select_clips_with_llm`**
|
||||||
|
|
||||||
|
Add this function at line ~314 (after `select_clips_with_llm` returns):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_social_metadata(clips: list[dict], labeled_transcript: str,
|
||||||
|
episode_number: int | None) -> list[dict]:
|
||||||
|
"""Generate social media descriptions and hashtags for each clip."""
|
||||||
|
if not OPENROUTER_API_KEY:
|
||||||
|
print("Error: OPENROUTER_API_KEY not set in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
clips_summary = "\n".join(
|
||||||
|
f'{i+1}. "{c["title"]}" — {c["caption_text"]}'
|
||||||
|
for i, c in enumerate(clips)
|
||||||
|
)
|
||||||
|
|
||||||
|
episode_context = f"This is Episode {episode_number} of " if episode_number else "This is an episode of "
|
||||||
|
|
||||||
|
prompt = f"""{episode_context}the "Luke at the Roost" podcast — a late-night call-in show where AI-generated callers share stories, confessions, and hot takes with host Luke.
|
||||||
|
|
||||||
|
Here are {len(clips)} clips selected from this episode:
|
||||||
|
|
||||||
|
{clips_summary}
|
||||||
|
|
||||||
|
For each clip, generate:
|
||||||
|
1. description: A short, engaging description for social media (1-2 sentences, hook the viewer, conversational tone). Do NOT include hashtags in the description.
|
||||||
|
2. hashtags: An array of 5-8 hashtags. Always include #lukeattheroost and #podcast. Add topic-relevant and trending-style tags.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON array matching the clip order:
|
||||||
|
[{{"description": "...", "hashtags": ["#tag1", "#tag2", ...]}}]"""
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"temperature": 0.7,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Error from OpenRouter: {response.text}")
|
||||||
|
return clips
|
||||||
|
|
||||||
|
content = response.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
if content.startswith("```"):
|
||||||
|
content = re.sub(r"^```(?:json)?\n?", "", content)
|
||||||
|
content = re.sub(r"\n?```$", "", content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = json.loads(content)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Error parsing social metadata: {e}")
|
||||||
|
return clips
|
||||||
|
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
if i < len(metadata):
|
||||||
|
clip["description"] = metadata[i].get("description", "")
|
||||||
|
clip["hashtags"] = metadata[i].get("hashtags", [])
|
||||||
|
|
||||||
|
return clips
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run existing tests to verify no breakage**
|
||||||
|
|
||||||
|
Run: `pytest tests/ -v`
|
||||||
|
Expected: All existing tests pass (this is a new function, no side effects yet)
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add make_clips.py
|
||||||
|
git commit -m "Add generate_social_metadata() for clip descriptions and hashtags"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Integrate metadata generation + JSON save into `main()`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `make_clips.py:1082-1289` (inside `main()`)
|
||||||
|
|
||||||
|
**Step 1: Add metadata generation call and JSON save**
|
||||||
|
|
||||||
|
After the LLM clip selection step (~line 1196, after the clip summary print loop), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Step N: Generate social media metadata
|
||||||
|
print(f"\n[{extract_step - 1}/{step_total}] Generating social media descriptions...")
|
||||||
|
clips = generate_social_metadata(clips, labeled_transcript, episode_number)
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
if "description" in clip:
|
||||||
|
print(f" Clip {i+1}: {clip['description'][:80]}...")
|
||||||
|
print(f" {' '.join(clip.get('hashtags', []))}")
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: This needs to be inserted BEFORE the audio extraction step, and the step numbering needs to be adjusted (total steps goes from 5/6 to 6/7).
|
||||||
|
|
||||||
|
At the end of `main()`, before the summary print, save the metadata JSON:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Save clips metadata for social upload
|
||||||
|
metadata_path = output_dir / "clips-metadata.json"
|
||||||
|
metadata = []
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
slug = slugify(clip["title"])
|
||||||
|
metadata.append({
|
||||||
|
"title": clip["title"],
|
||||||
|
"clip_file": f"clip-{i+1}-{slug}.mp4",
|
||||||
|
"audio_file": f"clip-{i+1}-{slug}.mp3",
|
||||||
|
"caption_text": clip.get("caption_text", ""),
|
||||||
|
"description": clip.get("description", ""),
|
||||||
|
"hashtags": clip.get("hashtags", []),
|
||||||
|
"start_time": clip["start_time"],
|
||||||
|
"end_time": clip["end_time"],
|
||||||
|
"duration": round(clip["end_time"] - clip["start_time"], 1),
|
||||||
|
"episode_number": episode_number,
|
||||||
|
})
|
||||||
|
with open(metadata_path, "w") as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
print(f"\nSocial metadata: {metadata_path}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Adjust step numbering**
|
||||||
|
|
||||||
|
The pipeline steps need to account for the new metadata step. Update `step_total` calculation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
step_total = (7 if two_pass else 6)
|
||||||
|
```
|
||||||
|
|
||||||
|
And shift the extract/video step numbers up by 1.
|
||||||
|
|
||||||
|
**Step 3: Test manually**
|
||||||
|
|
||||||
|
Run: `python make_clips.py --help`
|
||||||
|
Expected: No import errors, help displays normally
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add make_clips.py
|
||||||
|
git commit -m "Save clips-metadata.json with social descriptions and hashtags"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create `upload_clips.py` — core structure and Postiz API helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `upload_clips.py`
|
||||||
|
|
||||||
|
**Step 1: Write the script**
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Upload podcast clips to Instagram Reels and YouTube Shorts via Postiz.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python upload_clips.py clips/episode-12/
|
||||||
|
python upload_clips.py clips/episode-12/ --clip 1
|
||||||
|
python upload_clips.py clips/episode-12/ --youtube-only
|
||||||
|
python upload_clips.py clips/episode-12/ --instagram-only
|
||||||
|
python upload_clips.py clips/episode-12/ --schedule "2026-02-16T10:00:00"
|
||||||
|
python upload_clips.py clips/episode-12/ --yes # skip confirmation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
|
||||||
|
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_url(path: str) -> str:
|
||||||
|
"""Build full Postiz API URL."""
|
||||||
|
base = POSTIZ_URL.rstrip("/")
|
||||||
|
# Postiz self-hosted API is at /api/public/v1 when NEXT_PUBLIC_BACKEND_URL is the app URL
|
||||||
|
# but the docs say /public/v1 relative to backend URL. Try the standard path.
|
||||||
|
return f"{base}/api/public/v1{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def api_headers() -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": POSTIZ_API_KEY,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_integrations() -> list[dict]:
|
||||||
|
"""Fetch connected social accounts from Postiz."""
|
||||||
|
resp = requests.get(get_api_url("/integrations"), headers=api_headers(), timeout=15)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"Error fetching integrations: {resp.status_code} {resp.text[:200]}")
|
||||||
|
sys.exit(1)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def find_integration(integrations: list[dict], provider: str) -> dict | None:
|
||||||
|
"""Find integration by provider name (e.g. 'instagram', 'youtube')."""
|
||||||
|
for integ in integrations:
|
||||||
|
if integ.get("providerIdentifier", "").startswith(provider):
|
||||||
|
return integ
|
||||||
|
if integ.get("provider", "").startswith(provider):
|
||||||
|
return integ
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(file_path: Path) -> dict:
|
||||||
|
"""Upload a file to Postiz. Returns {id, path}."""
|
||||||
|
headers = {"Authorization": POSTIZ_API_KEY}
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/upload"),
|
||||||
|
headers=headers,
|
||||||
|
files={"file": (file_path.name, f, "video/mp4")},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"Upload failed: {resp.status_code} {resp.text[:200]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def create_post(integration_id: str, content: str, media: dict,
|
||||||
|
settings: dict, schedule: str | None = None) -> dict:
|
||||||
|
"""Create a post on Postiz."""
|
||||||
|
post_type = "schedule" if schedule else "now"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": post_type,
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"integration": {"id": integration_id},
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"content": content,
|
||||||
|
"image": [media] if media else [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": settings,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if schedule:
|
||||||
|
payload["date"] = schedule
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/posts"),
|
||||||
|
headers=api_headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
print(f"Post creation failed: {resp.status_code} {resp.text[:300]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def build_instagram_content(clip: dict) -> str:
|
||||||
|
"""Build Instagram post content: description + hashtags."""
|
||||||
|
parts = [clip.get("description", clip.get("caption_text", ""))]
|
||||||
|
hashtags = clip.get("hashtags", [])
|
||||||
|
if hashtags:
|
||||||
|
parts.append("\n\n" + " ".join(hashtags))
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_youtube_content(clip: dict) -> str:
|
||||||
|
"""Build YouTube description."""
|
||||||
|
parts = [clip.get("description", clip.get("caption_text", ""))]
|
||||||
|
hashtags = clip.get("hashtags", [])
|
||||||
|
if hashtags:
|
||||||
|
parts.append("\n\n" + " ".join(hashtags))
|
||||||
|
parts.append("\n\nListen to the full episode: lukeattheroost.com")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Upload podcast clips to social media via Postiz")
|
||||||
|
parser.add_argument("clips_dir", help="Path to clips directory (e.g. clips/episode-12/)")
|
||||||
|
parser.add_argument("--clip", "-c", type=int, help="Upload only clip N (1-indexed)")
|
||||||
|
parser.add_argument("--instagram-only", action="store_true", help="Upload to Instagram only")
|
||||||
|
parser.add_argument("--youtube-only", action="store_true", help="Upload to YouTube only")
|
||||||
|
parser.add_argument("--schedule", "-s", help="Schedule time (ISO 8601, e.g. 2026-02-16T10:00:00)")
|
||||||
|
parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Show what would be uploaded without posting")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not POSTIZ_API_KEY:
|
||||||
|
print("Error: POSTIZ_API_KEY not set in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
clips_dir = Path(args.clips_dir).expanduser().resolve()
|
||||||
|
metadata_path = clips_dir / "clips-metadata.json"
|
||||||
|
|
||||||
|
if not metadata_path.exists():
|
||||||
|
print(f"Error: No clips-metadata.json found in {clips_dir}")
|
||||||
|
print("Run make_clips.py first to generate clips and metadata.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(metadata_path) as f:
|
||||||
|
clips = json.load(f)
|
||||||
|
|
||||||
|
if args.clip:
|
||||||
|
if args.clip < 1 or args.clip > len(clips):
|
||||||
|
print(f"Error: Clip {args.clip} not found (have {len(clips)} clips)")
|
||||||
|
sys.exit(1)
|
||||||
|
clips = [clips[args.clip - 1]]
|
||||||
|
|
||||||
|
# Determine which platforms to post to
|
||||||
|
do_instagram = not args.youtube_only
|
||||||
|
do_youtube = not args.instagram_only
|
||||||
|
|
||||||
|
# Fetch integrations from Postiz
|
||||||
|
print("Fetching connected accounts from Postiz...")
|
||||||
|
integrations = fetch_integrations()
|
||||||
|
|
||||||
|
ig_integration = None
|
||||||
|
yt_integration = None
|
||||||
|
|
||||||
|
if do_instagram:
|
||||||
|
ig_integration = find_integration(integrations, "instagram")
|
||||||
|
if not ig_integration:
|
||||||
|
print("Warning: No Instagram account connected in Postiz")
|
||||||
|
do_instagram = False
|
||||||
|
|
||||||
|
if do_youtube:
|
||||||
|
yt_integration = find_integration(integrations, "youtube")
|
||||||
|
if not yt_integration:
|
||||||
|
print("Warning: No YouTube account connected in Postiz")
|
||||||
|
do_youtube = False
|
||||||
|
|
||||||
|
if not do_instagram and not do_youtube:
|
||||||
|
print("Error: No platforms available to upload to")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Show summary
|
||||||
|
platforms = []
|
||||||
|
if do_instagram:
|
||||||
|
platforms.append(f"Instagram Reels ({ig_integration.get('name', 'connected')})")
|
||||||
|
if do_youtube:
|
||||||
|
platforms.append(f"YouTube Shorts ({yt_integration.get('name', 'connected')})")
|
||||||
|
|
||||||
|
print(f"\nUploading {len(clips)} clip(s) to: {', '.join(platforms)}")
|
||||||
|
if args.schedule:
|
||||||
|
print(f"Scheduled for: {args.schedule}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
print(f" {i+1}. \"{clip['title']}\" ({clip['duration']:.0f}s)")
|
||||||
|
print(f" {clip.get('description', '')[:80]}")
|
||||||
|
print(f" {' '.join(clip.get('hashtags', []))}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("Dry run — nothing uploaded.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.yes:
|
||||||
|
confirm = input("Proceed? [y/N] ").strip().lower()
|
||||||
|
if confirm != "y":
|
||||||
|
print("Cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Upload each clip
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
clip_file = clips_dir / clip["clip_file"]
|
||||||
|
if not clip_file.exists():
|
||||||
|
print(f" Clip {i+1}: Video file not found: {clip_file}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"\n Clip {i+1}: \"{clip['title']}\"")
|
||||||
|
|
||||||
|
# Upload video to Postiz
|
||||||
|
print(f" Uploading {clip_file.name}...")
|
||||||
|
media = upload_file(clip_file)
|
||||||
|
if not media:
|
||||||
|
print(f" Failed to upload video, skipping")
|
||||||
|
continue
|
||||||
|
print(f" Uploaded: {media.get('path', 'ok')}")
|
||||||
|
|
||||||
|
# Post to Instagram Reels
|
||||||
|
if do_instagram:
|
||||||
|
print(f" Posting to Instagram Reels...")
|
||||||
|
content = build_instagram_content(clip)
|
||||||
|
settings = {
|
||||||
|
"__type": "instagram",
|
||||||
|
"post_type": "reel",
|
||||||
|
}
|
||||||
|
result = create_post(
|
||||||
|
ig_integration["id"], content, media, settings, args.schedule
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
print(f" Instagram: Posted!")
|
||||||
|
else:
|
||||||
|
print(f" Instagram: Failed")
|
||||||
|
|
||||||
|
# Post to YouTube Shorts
|
||||||
|
if do_youtube:
|
||||||
|
print(f" Posting to YouTube Shorts...")
|
||||||
|
content = build_youtube_content(clip)
|
||||||
|
settings = {
|
||||||
|
"__type": "youtube",
|
||||||
|
"title": clip["title"],
|
||||||
|
"type": "short",
|
||||||
|
"selfDeclaredMadeForKids": False,
|
||||||
|
"tags": [h.lstrip("#") for h in clip.get("hashtags", [])],
|
||||||
|
}
|
||||||
|
result = create_post(
|
||||||
|
yt_integration["id"], content, media, settings, args.schedule
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
print(f" YouTube: Posted!")
|
||||||
|
else:
|
||||||
|
print(f" YouTube: Failed")
|
||||||
|
|
||||||
|
print(f"\nDone!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add `POSTIZ_API_KEY` and `POSTIZ_URL` to `.env`**
|
||||||
|
|
||||||
|
Add to `.env`:
|
||||||
|
```
|
||||||
|
POSTIZ_API_KEY=your-postiz-api-key-here
|
||||||
|
POSTIZ_URL=https://social.lukeattheroost.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Get your API key from Postiz Settings page.
|
||||||
|
|
||||||
|
**Step 3: Test the script loads**
|
||||||
|
|
||||||
|
Run: `python upload_clips.py --help`
|
||||||
|
Expected: Help text displays with all flags
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add upload_clips.py
|
||||||
|
git commit -m "Add upload_clips.py for posting clips to Instagram/YouTube via Postiz"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Test with real Postiz instance
|
||||||
|
|
||||||
|
**Step 1: Get Postiz API key**
|
||||||
|
|
||||||
|
Go to `https://social.lukeattheroost.com` → Settings → API Keys → Generate key. Add to `.env` as `POSTIZ_API_KEY`.
|
||||||
|
|
||||||
|
**Step 2: Verify integrations endpoint**
|
||||||
|
|
||||||
|
Run: `python -c "from upload_clips import *; print(json.dumps(fetch_integrations(), indent=2))"`
|
||||||
|
|
||||||
|
This confirms the API key works and shows connected Instagram/YouTube accounts. Note the integration IDs and provider identifiers — if `find_integration()` doesn't match correctly, adjust the provider string matching.
|
||||||
|
|
||||||
|
**Step 3: Dry-run with existing clips**
|
||||||
|
|
||||||
|
Run: `python upload_clips.py clips/episode-12/ --dry-run`
|
||||||
|
Expected: Shows clip summary, "Dry run — nothing uploaded."
|
||||||
|
|
||||||
|
**Step 4: Upload a single test clip**
|
||||||
|
|
||||||
|
Run: `python upload_clips.py clips/episode-12/ --clip 1 --instagram-only`
|
||||||
|
|
||||||
|
Check Postiz dashboard and Instagram to verify it posted as a Reel.
|
||||||
|
|
||||||
|
**Step 5: Commit .env update (do NOT commit the key itself)**
|
||||||
|
|
||||||
|
The `.env` is gitignored so no action needed. Just ensure the key names are documented in CLAUDE.md if desired.
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
# Idents Playback Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add an idents section that loads MP3s from `idents/` and plays them through the ads channel (ch 11), with a separate "idents" stem for post-production.
|
||||||
|
|
||||||
|
**Architecture:** Mirrors the existing ads system — dropdown + play/stop buttons, same audio channel, mutually exclusive with ads. Idents get their own stem in stem_recorder so they can be mixed independently in post-production.
|
||||||
|
|
||||||
|
**Tech Stack:** Python (FastAPI), sounddevice, librosa, vanilla JS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add idents_dir to config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/config.py:46-47`
|
||||||
|
|
||||||
|
**Step 1: Add idents_dir path**
|
||||||
|
|
||||||
|
After `ads_dir` (line 46), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
idents_dir: Path = base_dir / "idents"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create the idents directory**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p idents
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/config.py
|
||||||
|
git commit -m "Add idents_dir to config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add "idents" stem to stem_recorder
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/services/stem_recorder.py:10`
|
||||||
|
|
||||||
|
**Step 1: Add "idents" to STEM_NAMES**
|
||||||
|
|
||||||
|
Change line 10 from:
|
||||||
|
|
||||||
|
```python
|
||||||
|
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
STEM_NAMES = ["host", "caller", "music", "sfx", "ads", "idents"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add "idents" to postprod.py STEM_NAMES**
|
||||||
|
|
||||||
|
In `postprod.py:20`, change:
|
||||||
|
|
||||||
|
```python
|
||||||
|
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
STEM_NAMES = ["host", "caller", "music", "sfx", "ads", "idents"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update `postprod.py:72` — the `remove_gaps` content detection line — add idents:
|
||||||
|
|
||||||
|
```python
|
||||||
|
content = stems["host"] + stems["caller"] + stems["sfx"] + stems["ads"] + stems["idents"]
|
||||||
|
```
|
||||||
|
|
||||||
|
And in `mix_stems` (line 411), add idents level:
|
||||||
|
|
||||||
|
```python
|
||||||
|
levels = {"host": 0, "caller": 0, "music": -6, "sfx": -10, "ads": 0, "idents": 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
And in stereo pans (line 420):
|
||||||
|
|
||||||
|
```python
|
||||||
|
pans = {"host": 0.0, "caller": 0.15, "music": 0.0, "sfx": 0.0, "ads": 0.0, "idents": 0.0}
|
||||||
|
```
|
||||||
|
|
||||||
|
And in `match_voice_levels` (line 389), add "idents":
|
||||||
|
|
||||||
|
```python
|
||||||
|
for name in ["host", "caller", "ads", "idents"]:
|
||||||
|
```
|
||||||
|
|
||||||
|
And in gap removal limiter section (line 777-778):
|
||||||
|
|
||||||
|
```python
|
||||||
|
for name in ["ads", "sfx", "idents"]:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/services/stem_recorder.py postprod.py
|
||||||
|
git commit -m "Add idents stem to recorder and postprod"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add play_ident / stop_ident to audio service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/services/audio.py`
|
||||||
|
|
||||||
|
**Step 1: Add ident state vars to __init__ (after line 40)**
|
||||||
|
|
||||||
|
After the ad playback state block (lines 35-40), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Ident playback state
|
||||||
|
self._ident_stream: Optional[sd.OutputStream] = None
|
||||||
|
self._ident_data: Optional[np.ndarray] = None
|
||||||
|
self._ident_resampled: Optional[np.ndarray] = None
|
||||||
|
self._ident_position: int = 0
|
||||||
|
self._ident_playing: bool = False
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add play_ident method (after stop_ad, ~line 1006)**
|
||||||
|
|
||||||
|
Insert after `stop_ad` method. This is a copy of `play_ad` with:
|
||||||
|
- `_ad_*` → `_ident_*`
|
||||||
|
- Calls `self.stop_ad()` at the start (mutual exclusion)
|
||||||
|
- Stem recording writes to `"idents"` instead of `"ads"`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def play_ident(self, file_path: str):
|
||||||
|
"""Load and play an ident file once (no loop) on the ad channel"""
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
print(f"Ident file not found: {file_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stop_ident()
|
||||||
|
self.stop_ad()
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True)
|
||||||
|
self._ident_data = audio.astype(np.float32)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load ident: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._ident_playing = True
|
||||||
|
self._ident_position = 0
|
||||||
|
|
||||||
|
if self.output_device is None:
|
||||||
|
num_channels = 2
|
||||||
|
device = None
|
||||||
|
device_sr = self.output_sample_rate
|
||||||
|
channel_idx = 0
|
||||||
|
else:
|
||||||
|
device_info = sd.query_devices(self.output_device)
|
||||||
|
num_channels = device_info['max_output_channels']
|
||||||
|
device_sr = int(device_info['default_samplerate'])
|
||||||
|
device = self.output_device
|
||||||
|
channel_idx = min(self.ad_channel, num_channels) - 1
|
||||||
|
|
||||||
|
if self.output_sample_rate != device_sr:
|
||||||
|
self._ident_resampled = librosa.resample(
|
||||||
|
self._ident_data, orig_sr=self.output_sample_rate, target_sr=device_sr
|
||||||
|
).astype(np.float32)
|
||||||
|
else:
|
||||||
|
self._ident_resampled = self._ident_data
|
||||||
|
|
||||||
|
def callback(outdata, frames, time_info, status):
|
||||||
|
outdata[:] = 0
|
||||||
|
if not self._ident_playing or self._ident_resampled is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
remaining = len(self._ident_resampled) - self._ident_position
|
||||||
|
if remaining >= frames:
|
||||||
|
chunk = self._ident_resampled[self._ident_position:self._ident_position + frames]
|
||||||
|
outdata[:, channel_idx] = chunk
|
||||||
|
if self.stem_recorder:
|
||||||
|
self.stem_recorder.write_sporadic("idents", chunk.copy(), device_sr)
|
||||||
|
self._ident_position += frames
|
||||||
|
else:
|
||||||
|
if remaining > 0:
|
||||||
|
outdata[:remaining, channel_idx] = self._ident_resampled[self._ident_position:]
|
||||||
|
self._ident_playing = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ident_stream = sd.OutputStream(
|
||||||
|
device=device,
|
||||||
|
channels=num_channels,
|
||||||
|
samplerate=device_sr,
|
||||||
|
dtype=np.float32,
|
||||||
|
callback=callback,
|
||||||
|
blocksize=2048
|
||||||
|
)
|
||||||
|
self._ident_stream.start()
|
||||||
|
print(f"Ident playback started on ch {self.ad_channel} @ {device_sr}Hz")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ident playback error: {e}")
|
||||||
|
self._ident_playing = False
|
||||||
|
|
||||||
|
def stop_ident(self):
|
||||||
|
"""Stop ident playback"""
|
||||||
|
self._ident_playing = False
|
||||||
|
if self._ident_stream:
|
||||||
|
self._ident_stream.stop()
|
||||||
|
self._ident_stream.close()
|
||||||
|
self._ident_stream = None
|
||||||
|
self._ident_position = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add `self.stop_ident()` to top of play_ad (line 935)**
|
||||||
|
|
||||||
|
In `play_ad`, after `self.stop_ad()` (line 935), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.stop_ident()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/services/audio.py
|
||||||
|
git commit -m "Add play_ident/stop_ident to audio service"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add idents API endpoints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` (after ads endpoints, ~line 4362)
|
||||||
|
|
||||||
|
**Step 1: Add IDENT_DISPLAY_NAMES and endpoints**
|
||||||
|
|
||||||
|
Insert after the ads stop endpoint (line 4362):
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
# --- Idents Endpoints ---
|
||||||
|
|
||||||
|
IDENT_DISPLAY_NAMES = {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/idents")
|
||||||
|
async def get_idents():
|
||||||
|
"""Get available ident tracks, shuffled"""
|
||||||
|
ident_list = []
|
||||||
|
if settings.idents_dir.exists():
|
||||||
|
for ext in ['*.wav', '*.mp3', '*.flac']:
|
||||||
|
for f in settings.idents_dir.glob(ext):
|
||||||
|
ident_list.append({
|
||||||
|
"name": IDENT_DISPLAY_NAMES.get(f.stem, f.stem),
|
||||||
|
"file": f.name,
|
||||||
|
"path": str(f)
|
||||||
|
})
|
||||||
|
random.shuffle(ident_list)
|
||||||
|
return {"idents": ident_list}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/idents/play")
|
||||||
|
async def play_ident(request: MusicRequest):
|
||||||
|
"""Play an ident once on the ad channel (ch 11)"""
|
||||||
|
ident_path = settings.idents_dir / request.track
|
||||||
|
if not ident_path.exists():
|
||||||
|
raise HTTPException(404, "Ident not found")
|
||||||
|
|
||||||
|
if audio_service._music_playing:
|
||||||
|
audio_service.stop_music(fade_duration=1.0)
|
||||||
|
await asyncio.sleep(1.1)
|
||||||
|
audio_service.play_ident(str(ident_path))
|
||||||
|
return {"status": "playing", "track": request.track}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/idents/stop")
|
||||||
|
async def stop_ident():
|
||||||
|
"""Stop ident playback"""
|
||||||
|
audio_service.stop_ident()
|
||||||
|
return {"status": "stopped"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Add idents API endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add idents UI section and JS functions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/index.html:113` (after ads section)
|
||||||
|
- Modify: `frontend/js/app.js`
|
||||||
|
|
||||||
|
**Step 1: Add Idents HTML section**
|
||||||
|
|
||||||
|
After the Ads section closing `</section>` (line 113), add:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Idents -->
|
||||||
|
<section class="music-section">
|
||||||
|
<h2>Idents</h2>
|
||||||
|
<select id="ident-select"></select>
|
||||||
|
<div class="music-controls">
|
||||||
|
<button id="ident-play-btn">Play Ident</button>
|
||||||
|
<button id="ident-stop-btn">Stop</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add loadIdents, playIdent, stopIdent to app.js**
|
||||||
|
|
||||||
|
After `stopAd()` function (~line 773), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function loadIdents() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/idents');
|
||||||
|
const data = await res.json();
|
||||||
|
const idents = data.idents || [];
|
||||||
|
|
||||||
|
const select = document.getElementById('ident-select');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
const previousValue = select.value;
|
||||||
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
idents.forEach(ident => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = ident.file;
|
||||||
|
option.textContent = ident.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previousValue && [...select.options].some(o => o.value === previousValue)) {
|
||||||
|
select.value = previousValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Loaded', idents.length, 'idents');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('loadIdents error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playIdent() {
|
||||||
|
await loadIdents();
|
||||||
|
const select = document.getElementById('ident-select');
|
||||||
|
const track = select?.value;
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
await fetch('/api/idents/play', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ track, action: 'play' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopIdent() {
|
||||||
|
await fetch('/api/idents/stop', { method: 'POST' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add event listeners in initEventListeners**
|
||||||
|
|
||||||
|
After the ads event listeners (line 190), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Idents
|
||||||
|
document.getElementById('ident-play-btn')?.addEventListener('click', playIdent);
|
||||||
|
document.getElementById('ident-stop-btn')?.addEventListener('click', stopIdent);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Add loadIdents() to DOMContentLoaded init**
|
||||||
|
|
||||||
|
After `await loadAds();` (line 59), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await loadIdents();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Bump cache buster on app.js script tag**
|
||||||
|
|
||||||
|
In `index.html:243`, change `?v=17` to `?v=18`.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/index.html frontend/js/app.js
|
||||||
|
git commit -m "Add idents UI section and JS functions"
|
||||||
|
```
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Clips Page & Landing Page Redesign
|
||||||
|
|
||||||
|
## Clips Page (`/clips`)
|
||||||
|
|
||||||
|
Responsive grid gallery of podcast clips with click-to-play YouTube embeds.
|
||||||
|
|
||||||
|
**Grid:** 3 columns desktop, 2 tablet, 1 mobile. Cards use 9:16 vertical aspect ratio.
|
||||||
|
|
||||||
|
**Card pre-click:** Dark bg-light card with clip title (bold), episode label, centered orange play button, description text below. Matches site aesthetic.
|
||||||
|
|
||||||
|
**Card playing:** Click swaps card for YouTube Shorts iframe (`youtube-nocookie.com`, autoplay). Fills same 9:16 space.
|
||||||
|
|
||||||
|
**Data:** Static `website/data/clips.json` aggregated from per-episode `clips-metadata.json` files. Each entry: title, description, episode_number, optional `youtube_id`. Cards without youtube_id show no play button.
|
||||||
|
|
||||||
|
**Featured row:** Top 3 hand-picked clips displayed larger, followed by full grid below.
|
||||||
|
|
||||||
|
**Nav:** "Clips" added to hero secondary links and footer nav.
|
||||||
|
|
||||||
|
## Landing Page Improvements
|
||||||
|
|
||||||
|
**About section** (between hero and episodes): Centered text block. Show description + AI teaser line ("Part human callers, part AI-generated characters, fully unhinged advice") + "See how it works" link. No card background.
|
||||||
|
|
||||||
|
**Clips highlight** (between about and episodes): Horizontal row of 3 featured clips, same card style as clips page. "Best Clips" header with "See all clips" link.
|
||||||
|
|
||||||
|
**Final section order:** Banner → Hero → About → Featured Clips → Episodes → Testimonials → Footer
|
||||||
|
|
||||||
|
## How It Works — Reaper Video
|
||||||
|
|
||||||
|
New "Post-Production Automation" section with native `<video>` tag (mp4 on CDN). Shows Reaper automating silence removal, ad ducking, loudness normalization. Wrapped in hiw-hero-card style container.
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# Show Quality Fixes — Episode 47 Post-Mortem
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Fix 5 bugs that ruined tonight's show: theme ignored by callers, wrong LLM models assigned, phonetic pronunciation mangling, voice-age mismatch, and low minimum response threshold.
|
||||||
|
|
||||||
|
**Architecture:** All fixes are in `backend/main.py` except voice-age matching which also touches `backend/services/tts.py` voice matching logic. Each fix is independent — no ordering dependencies between tasks.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, FastAPI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Regenerate caller backgrounds when theme is set
|
||||||
|
|
||||||
|
**Problem:** `_pregenerate_backgrounds()` runs on startup when `session.show_theme` is still `""`. Setting theme via `POST /api/show-theme` only stores the string — doesn't regenerate. Callers have zero theme connection.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:9891-9900` (`set_show_theme` endpoint)
|
||||||
|
- Modify: `backend/main.py:5899-5927` (`_pregenerate_backgrounds`)
|
||||||
|
|
||||||
|
**Step 1: Modify `set_show_theme` to regenerate unused caller backgrounds**
|
||||||
|
|
||||||
|
In `backend/main.py`, replace the `set_show_theme` endpoint (lines 9891-9900):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/api/show-theme")
|
||||||
|
async def set_show_theme(data: dict):
|
||||||
|
theme = data.get("theme", "").strip()[:100]
|
||||||
|
old_theme = session.show_theme
|
||||||
|
session.show_theme = theme
|
||||||
|
if theme:
|
||||||
|
print(f"[Theme] Show theme set: {theme}")
|
||||||
|
elif old_theme:
|
||||||
|
print(f"[Theme] Show theme cleared (was: {old_theme})")
|
||||||
|
|
||||||
|
# Regenerate backgrounds for callers that haven't been on air yet
|
||||||
|
if theme != old_theme:
|
||||||
|
unused_keys = [k for k in CALLER_BASES if k not in session.used_callers]
|
||||||
|
if unused_keys:
|
||||||
|
print(f"[Theme] Regenerating {len(unused_keys)} unused caller backgrounds for theme: {theme or '(none)'}")
|
||||||
|
asyncio.create_task(_regenerate_backgrounds_for_keys(unused_keys))
|
||||||
|
|
||||||
|
return {"theme": session.show_theme}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add `_regenerate_backgrounds_for_keys` helper**
|
||||||
|
|
||||||
|
Add this right after `_pregenerate_backgrounds()` (after line 5927):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _regenerate_backgrounds_for_keys(keys: list[str]):
|
||||||
|
"""Regenerate backgrounds for specific caller keys (e.g. after theme change)."""
|
||||||
|
tasks = []
|
||||||
|
for key in keys:
|
||||||
|
base = CALLER_BASES.get(key)
|
||||||
|
if base and not base.get("returning"):
|
||||||
|
tasks.append((key, _generate_caller_background_llm(base)))
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
return
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[t[1] for t in tasks], return_exceptions=True)
|
||||||
|
for (key, _), result in zip(tasks, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
print(f"[Theme] Regen failed for caller {key}: {result}")
|
||||||
|
else:
|
||||||
|
session.caller_backgrounds[key] = result
|
||||||
|
# Clear cached model so it re-evaluates with new style
|
||||||
|
session.caller_models.pop(key, None)
|
||||||
|
|
||||||
|
print(f"[Theme] Regenerated {sum(1 for r in results if not isinstance(r, Exception))}/{len(tasks)} backgrounds")
|
||||||
|
_match_voices_to_styles()
|
||||||
|
_sort_caller_queue()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify `used_callers` exists on session**
|
||||||
|
|
||||||
|
Check that `session.used_callers` tracks which callers have already been on air. If it doesn't exist, use `session.call_history` caller keys instead.
|
||||||
|
|
||||||
|
**Step 4: Test manually**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start server
|
||||||
|
python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
|
||||||
|
# Set theme and check logs for "[Theme] Regenerating..." messages
|
||||||
|
curl -X POST http://localhost:8000/api/show-theme -H "Content-Type: application/json" -d '{"theme": "Road Stories"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Regenerate caller backgrounds when show theme is set"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Fix style-to-model matching race condition
|
||||||
|
|
||||||
|
**Problem:** `get_caller_model()` is called before `caller_styles` is populated. `caller_styles.get(key)` returns `""`, `_normalize_style_key("")` returns `""`, no match in `caller_model_map` → falls through to `caller_model_pool[0]` (grok-4.1-fast) for everyone.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:6848-6875` (`get_caller_model`)
|
||||||
|
|
||||||
|
**Step 1: Fix `get_caller_model` to defer assignment when style is unknown**
|
||||||
|
|
||||||
|
Replace `get_caller_model` (lines 6848-6875):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_caller_model(self, caller_key: str) -> str | None:
|
||||||
|
"""Get the assigned model for a caller, or assign one based on strategy.
|
||||||
|
Returns None to use default category routing."""
|
||||||
|
if self.caller_model_strategy == "single":
|
||||||
|
return None # use default category_models["caller_dialog"]
|
||||||
|
|
||||||
|
# Already assigned — keep consistent for the whole call
|
||||||
|
if caller_key in self.caller_models:
|
||||||
|
return self.caller_models[caller_key]
|
||||||
|
|
||||||
|
model = None
|
||||||
|
if self.caller_model_strategy == "cycle":
|
||||||
|
if self.caller_model_pool:
|
||||||
|
model = self.caller_model_pool[self._caller_model_cycle_idx % len(self.caller_model_pool)]
|
||||||
|
self._caller_model_cycle_idx += 1
|
||||||
|
elif self.caller_model_strategy == "style_matched":
|
||||||
|
raw_style = self.caller_styles.get(caller_key, "")
|
||||||
|
style_key = _normalize_style_key(raw_style) if raw_style else ""
|
||||||
|
if style_key:
|
||||||
|
model = self.caller_model_map.get(style_key)
|
||||||
|
if not model:
|
||||||
|
# Style not yet populated or no mapping — use fallback, not pool[0]
|
||||||
|
model = self.caller_model_fallback
|
||||||
|
|
||||||
|
if model:
|
||||||
|
self.caller_models[caller_key] = model
|
||||||
|
caller_name = CALLER_BASES.get(caller_key, {}).get("name", caller_key)
|
||||||
|
style_info = self.caller_styles.get(caller_key, "unknown")
|
||||||
|
print(f"[CallerModel] Assigned {model} to {caller_name} (style={_normalize_style_key(style_info) if style_info else 'none'}, strategy={self.caller_model_strategy})")
|
||||||
|
|
||||||
|
return model
|
||||||
|
```
|
||||||
|
|
||||||
|
The key change: when `style_key` is empty (style not yet populated) or has no mapping, use `caller_model_fallback` (claude-sonnet-4.6) instead of `caller_model_pool[0]` (grok-4.1-fast). Claude Sonnet is a much safer default — empathetic, verbose, coherent.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Fix style-to-model race condition — use fallback instead of pool[0]"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Fix pronunciation fixes producing literal phonetic text
|
||||||
|
|
||||||
|
**Problem:** `_PRONUNCIATION_FIXES` replaces "Animas" with "Ah nee mahs" as literal text. TTS reads each word separately ("Ah" "nee" "mahs") instead of blending into the intended pronunciation.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:9141-9152` (`_PRONUNCIATION_FIXES`)
|
||||||
|
- Modify: `backend/main.py:9212-9216` (`_apply_pronunciation_fixes`)
|
||||||
|
|
||||||
|
**Step 1: Remove pronunciation fixes that sound worse than originals**
|
||||||
|
|
||||||
|
The Inworld TTS actually handles most proper nouns fine. The fixes were added speculatively and cause more harm than good. Remove the place names that TTS can handle, keep only abbreviations:
|
||||||
|
|
||||||
|
Replace `_PRONUNCIATION_FIXES` (lines 9141-9152):
|
||||||
|
|
||||||
|
```python
|
||||||
|
_PRONUNCIATION_FIXES = {
|
||||||
|
"Castopod": "Casto pod",
|
||||||
|
"vs": "versus",
|
||||||
|
"govt": "government",
|
||||||
|
"dept": "department",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove `Lordsburg`, `Hachita`, `Deming`, `Bootheel`, `Animas`, and `Rodeo`. These place names either sound fine through TTS or the phonetic replacement sounds worse.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Remove pronunciation fixes that produce worse TTS output"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add age-awareness to voice matching
|
||||||
|
|
||||||
|
**Problem:** Brandy (55 years old) got "Kayla" (young-sounding voice). `_match_voices_to_styles()` scores on style dimensions (weight, energy, warmth, age_feel) but the `age_feel` preference comes from the communication style, not the character's actual age. A "confrontational" style prefers `age_feel: None` (no preference), so a 55-year-old can get a young voice.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:6106-6156` (`_match_voices_to_styles`)
|
||||||
|
|
||||||
|
**Step 1: Add character age to voice scoring**
|
||||||
|
|
||||||
|
In `_match_voices_to_styles`, after getting the style preferences, override `age_feel` based on the caller's actual age from their background:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _match_voices_to_styles():
|
||||||
|
"""Re-assign voices to match caller communication styles after backgrounds are generated."""
|
||||||
|
from .services.tts import VOICE_PROFILES
|
||||||
|
|
||||||
|
for key, base in CALLER_BASES.items():
|
||||||
|
if base.get("returning"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
style_raw = session.caller_styles.get(key, "")
|
||||||
|
if not style_raw:
|
||||||
|
continue
|
||||||
|
|
||||||
|
style_key = _normalize_style_key(style_raw)
|
||||||
|
prefs = STYLE_VOICE_PREFERENCES.get(style_key)
|
||||||
|
if not prefs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Copy prefs so we don't mutate the shared dict
|
||||||
|
prefs = dict(prefs)
|
||||||
|
|
||||||
|
# Override age_feel based on character's actual age
|
||||||
|
bg = session.caller_backgrounds.get(key)
|
||||||
|
if isinstance(bg, CallerBackground) and bg.age:
|
||||||
|
if bg.age >= 50:
|
||||||
|
prefs["age_feel"] = "mature"
|
||||||
|
elif bg.age >= 35:
|
||||||
|
prefs["age_feel"] = "middle"
|
||||||
|
elif bg.age < 25:
|
||||||
|
prefs["age_feel"] = "young"
|
||||||
|
# 25-34: keep style preference or None
|
||||||
|
|
||||||
|
gender = base["gender"]
|
||||||
|
pool = INWORLD_MALE_VOICES if gender == "male" else INWORLD_FEMALE_VOICES
|
||||||
|
voice_pool = [v for v in pool if v not in BLACKLISTED_VOICES]
|
||||||
|
|
||||||
|
scored = []
|
||||||
|
for voice_name in voice_pool:
|
||||||
|
profile = VOICE_PROFILES.get(voice_name)
|
||||||
|
if not profile:
|
||||||
|
scored.append((voice_name, 0))
|
||||||
|
continue
|
||||||
|
score = 0
|
||||||
|
for dim in ["weight", "energy", "warmth", "age_feel"]:
|
||||||
|
pref_val = prefs.get(dim)
|
||||||
|
if pref_val and profile.get(dim) == pref_val:
|
||||||
|
score += 1
|
||||||
|
scored.append((voice_name, score))
|
||||||
|
|
||||||
|
if scored:
|
||||||
|
names = [s[0] for s in scored]
|
||||||
|
weights = [max(1, s[1] * 3) for s in scored]
|
||||||
|
chosen = random.choices(names, weights=weights, k=1)[0]
|
||||||
|
|
||||||
|
used_voices = {CALLER_BASES[k]["voice"] for k in CALLER_BASES if k != key and "voice" in CALLER_BASES[k]}
|
||||||
|
if chosen in used_voices:
|
||||||
|
alternatives = [(n, w) for n, w in zip(names, weights) if n not in used_voices]
|
||||||
|
if alternatives:
|
||||||
|
alt_names, alt_weights = zip(*alternatives)
|
||||||
|
chosen = random.choices(alt_names, weights=alt_weights, k=1)[0]
|
||||||
|
|
||||||
|
old_voice = base.get("voice", "")
|
||||||
|
base["voice"] = chosen
|
||||||
|
if old_voice != chosen:
|
||||||
|
print(f"[VoiceMatch] {base.get('name', key)}: {old_voice} → {chosen} (style: {style_key}, age: {bg.age if isinstance(bg, CallerBackground) else '?'})")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Add age-awareness to voice matching — 55yo won't get young voices"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Raise minimum response word count
|
||||||
|
|
||||||
|
**Problem:** `MIN_RESPONSE_WORDS = 30` lets through fragmented, telegram-style responses that are technically 30+ words but terrible radio.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:8844` (`MIN_RESPONSE_WORDS`)
|
||||||
|
|
||||||
|
**Step 1: Raise the minimum**
|
||||||
|
|
||||||
|
Change line 8844:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MIN_RESPONSE_WORDS = 50 # Retry if response is shorter than this
|
||||||
|
```
|
||||||
|
|
||||||
|
50 words is roughly 2-3 spoken sentences — enough to be a coherent radio response without being overly demanding for short-form exchanges.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Raise MIN_RESPONSE_WORDS from 30 to 50"
|
||||||
|
```
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""Fetch instrumental background music from Jamendo for the radio show.
|
||||||
|
|
||||||
|
Pixabay has no public music API — this uses Jamendo's free API instead.
|
||||||
|
All tracks are Creative Commons licensed. Attribution is saved to music/CREDITS.txt.
|
||||||
|
|
||||||
|
Setup: Get a free client_id at https://devportal.jamendo.com
|
||||||
|
Add JAMENDO_CLIENT_ID=your_id to .env
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python fetch_music.py # download 20 tracks across all genres
|
||||||
|
python fetch_music.py --genre jazz # download jazz only
|
||||||
|
python fetch_music.py --count 50 # download 50 tracks
|
||||||
|
python fetch_music.py --list # just list available tracks, don't download
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
MUSIC_DIR = Path(__file__).parent / "music"
|
||||||
|
CREDITS_FILE = MUSIC_DIR / "CREDITS.txt"
|
||||||
|
API_BASE = "https://api.jamendo.com/v3.0"
|
||||||
|
|
||||||
|
# Genres good for a late-night radio show
|
||||||
|
GENRES = ["jazz", "lofi", "blues", "ambient", "acoustic", "funk", "chill"]
|
||||||
|
|
||||||
|
# Map search tags to labels that _detect_genre() in main.py can match
|
||||||
|
# jazz, blues, funk, lo-fi are already in GENRE_KEYWORDS
|
||||||
|
# ambient, acoustic, chill would need to be added for auto-detection
|
||||||
|
GENRE_LABELS = {
|
||||||
|
"jazz": "Jazz",
|
||||||
|
"lofi": "Lo-Fi",
|
||||||
|
"blues": "Blues",
|
||||||
|
"ambient": "Ambient",
|
||||||
|
"acoustic": "Acoustic",
|
||||||
|
"funk": "Funk",
|
||||||
|
"chill": "Chill",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_id():
|
||||||
|
key = os.getenv("JAMENDO_CLIENT_ID")
|
||||||
|
if not key:
|
||||||
|
print("Error: JAMENDO_CLIENT_ID not found in .env")
|
||||||
|
print("Get one free at https://devportal.jamendo.com")
|
||||||
|
sys.exit(1)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str:
|
||||||
|
return re.sub(r'[<>:"/\\|?*]', '', name).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_vocals(track: dict) -> bool:
|
||||||
|
"""Check musicinfo for vocal indicators — catches tracks Jamendo mis-tagged as instrumental."""
|
||||||
|
mi = track.get("musicinfo", {})
|
||||||
|
# Check the vocalinstrumental field in musicinfo (separate from the API filter)
|
||||||
|
vi = mi.get("vocalinstrumental")
|
||||||
|
if vi and vi.lower() == "vocal":
|
||||||
|
return True
|
||||||
|
# Check tags for vocal/singing indicators
|
||||||
|
tags = mi.get("tags", {})
|
||||||
|
# tags can be {"genres": [...], "instruments": [...], "vartags": [...]}
|
||||||
|
all_tags = []
|
||||||
|
if isinstance(tags, dict):
|
||||||
|
for v in tags.values():
|
||||||
|
if isinstance(v, list):
|
||||||
|
all_tags.extend(t.lower() for t in v)
|
||||||
|
elif isinstance(tags, list):
|
||||||
|
all_tags = [t.lower() for t in tags]
|
||||||
|
vocal_tags = {"vocals", "vocal", "singing", "singer", "voice", "lyrics",
|
||||||
|
"rap", "hiphop", "hip-hop", "spoken", "spoken word"}
|
||||||
|
if vocal_tags & set(all_tags):
|
||||||
|
return True
|
||||||
|
# Check track name for vocal giveaways
|
||||||
|
name_lower = track.get("name", "").lower()
|
||||||
|
if any(w in name_lower for w in ["feat.", "ft.", "vocal", "remix vocal", "(voice"]):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def search_tracks(client: httpx.Client, client_id: str, genre: str, limit: int = 20) -> list[dict]:
|
||||||
|
# Request more than needed so we can filter out vocal false positives
|
||||||
|
fetch_limit = min(limit * 3, 200)
|
||||||
|
params = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"format": "json",
|
||||||
|
"limit": fetch_limit,
|
||||||
|
"vocalinstrumental": "instrumental",
|
||||||
|
"fuzzytags": genre,
|
||||||
|
"durationbetween": "60_300",
|
||||||
|
"include": "musicinfo+licenses",
|
||||||
|
"order": "popularity_total",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = client.get(f"{API_BASE}/tracks/", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if data["headers"]["status"] != "success":
|
||||||
|
print(f" API error: {data['headers'].get('error_message', 'unknown')}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = data.get("results", [])
|
||||||
|
# Post-filter: reject tracks with vocal indicators despite the API filter
|
||||||
|
filtered = []
|
||||||
|
for t in results:
|
||||||
|
if _has_vocals(t):
|
||||||
|
print(f" SKIP (vocals detected): {t.get('artist_name', '?')} - {t.get('name', '?')}")
|
||||||
|
continue
|
||||||
|
filtered.append(t)
|
||||||
|
if len(filtered) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
skipped = len(results) - len(filtered)
|
||||||
|
if skipped:
|
||||||
|
print(f" (filtered out {skipped} tracks with vocal indicators)")
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def make_filename(track: dict, genre_tag: str) -> str:
|
||||||
|
artist = sanitize_filename(track.get("artist_name", "Unknown"))
|
||||||
|
title = sanitize_filename(track.get("name", "Untitled"))
|
||||||
|
label = GENRE_LABELS.get(genre_tag, genre_tag.title())
|
||||||
|
|
||||||
|
# Include genre tag if not already detectable from artist/title
|
||||||
|
lower = f"{artist} {title}".lower()
|
||||||
|
needs_tag = not any(kw in lower for kw in [genre_tag, label.lower()])
|
||||||
|
|
||||||
|
if needs_tag:
|
||||||
|
return f"{artist} - {title} [{label}].mp3"
|
||||||
|
return f"{artist} - {title}.mp3"
|
||||||
|
|
||||||
|
|
||||||
|
def download_track(client: httpx.Client, track: dict, filepath: Path, index: int, total: int) -> bool:
|
||||||
|
url = track.get("audiodownload")
|
||||||
|
if not url:
|
||||||
|
print(f" [{index}/{total}] SKIP (no download URL): {track['name']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not track.get("audiodownload_allowed", True):
|
||||||
|
print(f" [{index}/{total}] SKIP (download not allowed): {track['name']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f" [{index}/{total}] Downloading: {filepath.name}...", end=" ", flush=True)
|
||||||
|
resp = client.get(url, follow_redirects=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
filepath.write_bytes(resp.content)
|
||||||
|
size_mb = len(resp.content) / (1024 * 1024)
|
||||||
|
dur = track.get("duration", 0)
|
||||||
|
print(f"{size_mb:.1f} MB, {dur // 60}:{dur % 60:02d}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def save_credit(track: dict, filename: str):
|
||||||
|
artist = track.get("artist_name", "Unknown")
|
||||||
|
title = track.get("name", "Untitled")
|
||||||
|
license_url = track.get("license_ccurl", "")
|
||||||
|
share_url = track.get("shareurl", "")
|
||||||
|
|
||||||
|
line = f"{filename} | {artist} - {title} | {license_url} | {share_url}\n"
|
||||||
|
|
||||||
|
existing = CREDITS_FILE.read_text() if CREDITS_FILE.exists() else ""
|
||||||
|
if filename not in existing:
|
||||||
|
with open(CREDITS_FILE, "a") as f:
|
||||||
|
if not existing:
|
||||||
|
f.write("# Music Credits (Jamendo - Creative Commons)\n")
|
||||||
|
f.write("# File | Artist - Title | License | URL\n\n")
|
||||||
|
f.write(line)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Download instrumental music from Jamendo")
|
||||||
|
parser.add_argument("--genre", choices=GENRES, help="Download only this genre")
|
||||||
|
parser.add_argument("--count", type=int, default=20, help="Total tracks to download (default: 20)")
|
||||||
|
parser.add_argument("--list", action="store_true", help="List available tracks without downloading")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
client_id = get_client_id()
|
||||||
|
MUSIC_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
genres = [args.genre] if args.genre else GENRES
|
||||||
|
per_genre = max(1, args.count // len(genres))
|
||||||
|
remainder = args.count - per_genre * len(genres)
|
||||||
|
|
||||||
|
all_tracks = []
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
with httpx.Client(timeout=30) as api_client:
|
||||||
|
for i, genre in enumerate(genres):
|
||||||
|
limit = per_genre + (1 if i < remainder else 0)
|
||||||
|
if limit <= 0:
|
||||||
|
continue
|
||||||
|
print(f"Searching {genre}...", end=" ", flush=True)
|
||||||
|
tracks = search_tracks(api_client, client_id, genre, limit)
|
||||||
|
# Deduplicate across genres
|
||||||
|
added = 0
|
||||||
|
for t in tracks:
|
||||||
|
if t["id"] not in seen_ids and added < limit:
|
||||||
|
t["_genre_tag"] = genre
|
||||||
|
all_tracks.append(t)
|
||||||
|
seen_ids.add(t["id"])
|
||||||
|
added += 1
|
||||||
|
print(f"{added} tracks")
|
||||||
|
|
||||||
|
if not all_tracks:
|
||||||
|
print("No tracks found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
print(f"\n{'#':<4} {'Genre':<10} {'Artist':<25} {'Title':<40} {'Duration':<8}")
|
||||||
|
print("-" * 90)
|
||||||
|
for i, t in enumerate(all_tracks, 1):
|
||||||
|
dur = f"{t['duration'] // 60}:{t['duration'] % 60:02d}"
|
||||||
|
artist = t["artist_name"][:24]
|
||||||
|
title = t["name"][:39]
|
||||||
|
label = GENRE_LABELS.get(t["_genre_tag"], t["_genre_tag"])
|
||||||
|
print(f"{i:<4} {label:<10} {artist:<25} {title:<40} {dur:<8}")
|
||||||
|
print(f"\n{len(all_tracks)} tracks available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Download phase
|
||||||
|
downloaded = 0
|
||||||
|
skipped_exists = 0
|
||||||
|
skipped_error = 0
|
||||||
|
|
||||||
|
with httpx.Client(timeout=120, follow_redirects=True) as dl_client:
|
||||||
|
for i, track in enumerate(all_tracks, 1):
|
||||||
|
filename = make_filename(track, track["_genre_tag"])
|
||||||
|
filepath = MUSIC_DIR / filename
|
||||||
|
|
||||||
|
if filepath.exists():
|
||||||
|
print(f" [{i}/{len(all_tracks)}] EXISTS: {filename}")
|
||||||
|
skipped_exists += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if download_track(dl_client, track, filepath, i, len(all_tracks)):
|
||||||
|
save_credit(track, filename)
|
||||||
|
downloaded += 1
|
||||||
|
else:
|
||||||
|
skipped_error += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{i}/{len(all_tracks)}] ERROR: {e}")
|
||||||
|
# Clean up partial download
|
||||||
|
if filepath.exists():
|
||||||
|
filepath.unlink()
|
||||||
|
skipped_error += 1
|
||||||
|
|
||||||
|
print(f"\nDone: {downloaded} downloaded, {skipped_exists} existed, {skipped_error} skipped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Luke at The Roost</title>
|
<title>Luke at The Roost</title>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css?v=2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -15,8 +15,29 @@
|
|||||||
<button id="rec-btn" class="rec-btn" title="Record stems for post-production">REC</button>
|
<button id="rec-btn" class="rec-btn" title="Record stems for post-production">REC</button>
|
||||||
<button id="new-session-btn" class="new-session-btn">New Session</button>
|
<button id="new-session-btn" class="new-session-btn">New Session</button>
|
||||||
<button id="export-session-btn">Export</button>
|
<button id="export-session-btn">Export</button>
|
||||||
|
<button id="preflight-btn" class="preflight-btn">Preflight</button>
|
||||||
<button id="settings-btn">Settings</button>
|
<button id="settings-btn">Settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="theme-bar">
|
||||||
|
<label for="show-theme-input" class="theme-label">Theme:</label>
|
||||||
|
<input type="text" id="show-theme-input" class="theme-input" placeholder="e.g. St. Patrick's Day" maxlength="100">
|
||||||
|
<button id="set-theme-btn" class="theme-btn set" title="Set show theme">Set</button>
|
||||||
|
<button id="clear-theme-btn" class="theme-btn clear hidden" title="Clear theme">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="show-clock" class="show-clock">
|
||||||
|
<span class="clock-time" id="clock-time"></span>
|
||||||
|
<span id="show-timers" class="show-timers hidden">
|
||||||
|
<span class="clock-divider">|</span>
|
||||||
|
<span class="clock-label">On Air:</span>
|
||||||
|
<span class="clock-value" id="clock-runtime">0:00:00</span>
|
||||||
|
<span class="clock-divider">|</span>
|
||||||
|
<span class="clock-label">Est. Final:</span>
|
||||||
|
<span class="clock-value clock-estimate" id="clock-estimate">0:00</span>
|
||||||
|
<span class="clock-divider">|</span>
|
||||||
|
<span class="clock-label">Cost:</span>
|
||||||
|
<span class="clock-value clock-cost" id="clock-cost">$0.00</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@@ -50,24 +71,69 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="call-status" class="call-status">No active call</div>
|
<div id="call-status" class="call-status">No active call</div>
|
||||||
<details id="caller-background-details" class="caller-background hidden">
|
<div id="caller-info-panel" class="caller-info-panel hidden">
|
||||||
<summary>Caller Background</summary>
|
<div class="caller-info-row">
|
||||||
|
<span id="caller-shape-badge" class="info-badge shape"></span>
|
||||||
|
<span id="caller-energy-badge" class="info-badge energy"></span>
|
||||||
|
<span id="caller-emotion" class="info-badge emotion"></span>
|
||||||
|
<span id="caller-model-badge" class="info-badge model"></span>
|
||||||
|
<select id="caller-model-override" class="caller-model-override hidden"></select>
|
||||||
|
</div>
|
||||||
|
<div id="caller-signature" class="caller-signature"></div>
|
||||||
|
<div id="caller-situation" class="caller-situation"></div>
|
||||||
|
<details id="caller-background-details" class="caller-background-full">
|
||||||
|
<summary>Full Background</summary>
|
||||||
<div id="caller-background"></div>
|
<div id="caller-background"></div>
|
||||||
</details>
|
</details>
|
||||||
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
|
</div>
|
||||||
|
<div class="call-actions">
|
||||||
|
<button id="wrapup-btn" class="wrapup-btn" disabled>Wrap It Up <span class="shortcut-label">W</span></button>
|
||||||
|
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up <span class="shortcut-label">H</span></button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Call Queue -->
|
<!-- Call Queue -->
|
||||||
<section class="queue-section">
|
<section class="queue-section">
|
||||||
<h2>Incoming Calls <span style="font-size:0.6em;font-weight:normal;color:var(--text-muted);">(208) 439-5853</span></h2>
|
<h2>Incoming Calls <span class="section-subtitle">(208) 439-5853</span></h2>
|
||||||
<div id="call-queue" class="call-queue">
|
<div id="call-queue" class="call-queue">
|
||||||
<div class="queue-empty">No callers waiting</div>
|
<div class="queue-empty">No callers waiting</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Voicemail -->
|
||||||
|
<section class="voicemail-section">
|
||||||
|
<h2>Voicemail <span id="voicemail-badge" class="voicemail-badge hidden">0</span></h2>
|
||||||
|
<div id="voicemail-list" class="voicemail-list">
|
||||||
|
<div class="queue-empty">No voicemails</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Listener Emails -->
|
||||||
|
<section class="voicemail-section">
|
||||||
|
<h2>Emails <span id="email-badge" class="voicemail-badge hidden">0</span></h2>
|
||||||
|
<div id="email-list" class="voicemail-list email-list">
|
||||||
|
<div class="queue-empty">No emails</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Chat -->
|
<!-- Chat -->
|
||||||
<section class="chat-section">
|
<section class="chat-section">
|
||||||
<div id="chat" class="chat-log"></div>
|
<div id="chat" class="chat-log"></div>
|
||||||
|
<div class="devon-bar">
|
||||||
|
<div class="devon-ask-row">
|
||||||
|
<input type="text" id="devon-input" placeholder="Ask Devon..." class="devon-input">
|
||||||
|
<button id="devon-ask-btn" class="devon-ask-btn">Ask <span class="shortcut-label">D</span></button>
|
||||||
|
<button id="devon-interject-btn" class="devon-interject-btn" title="Devon interjects on current conversation">Interject</button>
|
||||||
|
<label class="devon-monitor-label" title="Devon auto-monitors conversations">
|
||||||
|
<input type="checkbox" id="devon-monitor" checked> Monitor
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="devon-suggestion" class="devon-suggestion hidden">
|
||||||
|
<span class="devon-suggestion-text">Devon has something</span>
|
||||||
|
<button id="devon-play-btn" class="devon-play-btn">Play</button>
|
||||||
|
<button id="devon-dismiss-btn" class="devon-dismiss-btn">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="talk-controls">
|
<div class="talk-controls">
|
||||||
<button id="talk-btn" class="talk-btn">Hold to Talk</button>
|
<button id="talk-btn" class="talk-btn">Hold to Talk</button>
|
||||||
<button id="type-btn" class="type-btn">Type</button>
|
<button id="type-btn" class="type-btn">Type</button>
|
||||||
@@ -75,18 +141,18 @@
|
|||||||
<div id="status" class="status hidden"></div>
|
<div id="status" class="status hidden"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Music -->
|
<!-- Music / Ads / Idents -->
|
||||||
<section class="music-section">
|
<div class="media-row">
|
||||||
<h2>Music</h2>
|
<section class="music-section genre-section">
|
||||||
<select id="track-select"></select>
|
<h2>Music <span class="shortcut-label">M</span></h2>
|
||||||
<div class="music-controls">
|
<div id="genre-buttons" class="genre-grid"></div>
|
||||||
<button id="play-btn">Play</button>
|
<div id="now-playing" class="now-playing hidden">
|
||||||
<button id="stop-btn">Stop</button>
|
<span id="now-playing-text" class="now-playing-text"></span>
|
||||||
<input type="range" id="volume" min="0" max="100" value="30">
|
<button id="stop-btn" class="now-playing-stop">Stop</button>
|
||||||
|
<input type="range" id="volume" min="0" max="100" value="30" class="now-playing-volume">
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Ads -->
|
|
||||||
<section class="music-section">
|
<section class="music-section">
|
||||||
<h2>Ads</h2>
|
<h2>Ads</h2>
|
||||||
<select id="ad-select"></select>
|
<select id="ad-select"></select>
|
||||||
@@ -96,6 +162,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="music-section">
|
||||||
|
<h2>Idents</h2>
|
||||||
|
<select id="ident-select"></select>
|
||||||
|
<div class="music-controls">
|
||||||
|
<button id="ident-play-btn">Play Ident</button>
|
||||||
|
<button id="ident-stop-btn">Stop</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sound Effects -->
|
<!-- Sound Effects -->
|
||||||
<section class="sounds-section">
|
<section class="sounds-section">
|
||||||
<h2>Sounds</h2>
|
<h2>Sounds</h2>
|
||||||
@@ -107,6 +183,7 @@
|
|||||||
<div class="log-header">
|
<div class="log-header">
|
||||||
<h2>Server Log</h2>
|
<h2>Server Log</h2>
|
||||||
<div class="server-controls">
|
<div class="server-controls">
|
||||||
|
<button id="log-toggle-btn" class="log-toggle-btn">Show ▼</button>
|
||||||
<button id="restart-server-btn" class="server-btn restart">Restart</button>
|
<button id="restart-server-btn" class="server-btn restart">Restart</button>
|
||||||
<button id="stop-server-btn" class="server-btn stop">Stop</button>
|
<button id="stop-server-btn" class="server-btn stop">Stop</button>
|
||||||
<label class="auto-scroll-label">
|
<label class="auto-scroll-label">
|
||||||
@@ -114,7 +191,9 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="log-body collapsed">
|
||||||
<div id="server-log" class="server-log"></div>
|
<div id="server-log" class="server-log"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -125,7 +204,7 @@
|
|||||||
|
|
||||||
<!-- Audio Devices -->
|
<!-- Audio Devices -->
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h3>Audio Routing</h3>
|
<h3>Audio Devices</h3>
|
||||||
<div class="device-row">
|
<div class="device-row">
|
||||||
<label>
|
<label>
|
||||||
Input Device
|
Input Device
|
||||||
@@ -133,7 +212,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Ch
|
Ch
|
||||||
<input type="number" id="input-channel" value="1" min="1" max="16" class="channel-input">
|
<input type="number" id="input-channel" value="1" min="1" max="32" class="channel-input">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="device-row">
|
<div class="device-row">
|
||||||
@@ -142,43 +221,100 @@
|
|||||||
<select id="output-device"></select>
|
<select id="output-device"></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-row">
|
</div>
|
||||||
<label>Caller Ch <input type="number" id="caller-channel" value="3" min="1" max="16" class="channel-input"></label>
|
|
||||||
<label>Live Ch <input type="number" id="live-caller-channel" value="9" min="1" max="16" class="channel-input"></label>
|
<div class="settings-group">
|
||||||
<label>Music Ch <input type="number" id="music-channel" value="5" min="1" max="16" class="channel-input"></label>
|
<h3>Output Routing</h3>
|
||||||
<label>SFX Ch <input type="number" id="sfx-channel" value="7" min="1" max="16" class="channel-input"></label>
|
<div class="routing-grid">
|
||||||
<label>Ad Ch <input type="number" id="ad-channel" value="11" min="1" max="16" class="channel-input"></label>
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">AI Caller</span>
|
||||||
|
<input type="number" id="caller-channel" value="3" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">Devon</span>
|
||||||
|
<input type="number" id="devon-channel" value="17" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">Live Caller</span>
|
||||||
|
<input type="number" id="live-caller-channel" value="9" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">Music</span>
|
||||||
|
<input type="number" id="music-channel" value="5" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">SFX</span>
|
||||||
|
<input type="number" id="sfx-channel" value="7" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">Ads</span>
|
||||||
|
<input type="number" id="ad-channel" value="11" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">Idents</span>
|
||||||
|
<input type="number" id="ident-channel" value="15" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LLM Settings -->
|
<!-- LLM Settings -->
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h3>LLM Provider</h3>
|
<h3>LLM Model Routing</h3>
|
||||||
<label>
|
<div class="model-routing-grid">
|
||||||
Provider
|
<div class="model-routing-item">
|
||||||
<select id="provider">
|
<span class="model-routing-label">Caller Dialog</span>
|
||||||
<option value="openrouter">OpenRouter</option>
|
<select id="model-caller_dialog" class="model-select"></select>
|
||||||
<option value="ollama">Ollama</option>
|
</div>
|
||||||
</select>
|
<div class="model-routing-item">
|
||||||
</label>
|
<span class="model-routing-label">Devon Monitor</span>
|
||||||
|
<select id="model-devon_monitor" class="model-select"></select>
|
||||||
<div id="openrouter-settings">
|
</div>
|
||||||
<label>
|
<div class="model-routing-item">
|
||||||
Model
|
<span class="model-routing-label">Devon Ask</span>
|
||||||
<select id="openrouter-model"></select>
|
<select id="model-devon_ask" class="model-select"></select>
|
||||||
</label>
|
</div>
|
||||||
|
<div class="model-routing-item">
|
||||||
|
<span class="model-routing-label">Backgrounds</span>
|
||||||
|
<select id="model-background_gen" class="model-select"></select>
|
||||||
|
</div>
|
||||||
|
<div class="model-routing-item">
|
||||||
|
<span class="model-routing-label">Call Summary</span>
|
||||||
|
<select id="model-call_summary" class="model-select"></select>
|
||||||
|
</div>
|
||||||
|
<div class="model-routing-item">
|
||||||
|
<span class="model-routing-label">News</span>
|
||||||
|
<select id="model-news_summary" class="model-select"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="ollama-settings" class="hidden">
|
<!-- Caller Model Routing -->
|
||||||
|
<div class="settings-group">
|
||||||
|
<h3>Caller Models</h3>
|
||||||
|
<div class="caller-model-row">
|
||||||
<label>
|
<label>
|
||||||
Model
|
Strategy
|
||||||
<select id="ollama-model"></select>
|
<select id="cm-strategy">
|
||||||
|
<option value="single">Single Model</option>
|
||||||
|
<option value="cycle">Cycle Models</option>
|
||||||
|
<option value="style_matched">Style-Matched</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="cm-pool-section" class="hidden">
|
||||||
<label>
|
<label>
|
||||||
Host
|
Model Pool
|
||||||
<input type="text" id="ollama-host" value="http://localhost:11434">
|
<input type="text" id="cm-pool" class="cm-pool-input" placeholder="x-ai/grok-4, deepseek/deepseek-v3.2, ...">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="cm-style-map" class="hidden">
|
||||||
|
<div class="cm-style-grid" id="cm-style-grid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="caller-model-row">
|
||||||
|
<label>
|
||||||
|
Fallback Model
|
||||||
|
<select id="cm-fallback" class="model-select"></select>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" id="refresh-ollama" class="refresh-btn">Refresh Models</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -222,8 +358,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Preflight Modal -->
|
||||||
|
<div id="preflight-modal" class="modal hidden">
|
||||||
|
<div class="modal-content preflight-content">
|
||||||
|
<h2>Show Preflight</h2>
|
||||||
|
<div id="preflight-status" class="preflight-status loading">
|
||||||
|
<span class="preflight-status-icon">...</span>
|
||||||
|
<span class="preflight-status-text">Running checks...</span>
|
||||||
|
</div>
|
||||||
|
<div id="preflight-checks" class="preflight-checks"></div>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button id="preflight-test-btn" class="preflight-test-btn">Test Responses</button>
|
||||||
|
<button id="preflight-rerun-btn">Re-run</button>
|
||||||
|
<button id="close-preflight">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/app.js?v=15"></script>
|
<script src="/js/app.js?v=27"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate social media announcement images for Luke at the Roost.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python make_social_post.py # regenerate with defaults
|
||||||
|
python make_social_post.py --title "NEW FEATURE" # custom title
|
||||||
|
python make_social_post.py --body body_text.txt # body from file
|
||||||
|
|
||||||
|
Outputs square (1080x1080) and landscape (1200x675) PNGs to social_posts/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import textwrap
|
||||||
|
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
COVER = os.path.join(SCRIPT_DIR, "website/images/cover.png")
|
||||||
|
OUT_DIR = os.path.join(SCRIPT_DIR, "social_posts")
|
||||||
|
|
||||||
|
# Brand colors
|
||||||
|
BG = (18, 13, 7)
|
||||||
|
ACCENT = (232, 121, 29)
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
MUTED = (175, 165, 150)
|
||||||
|
LIGHTER = (220, 215, 205)
|
||||||
|
|
||||||
|
# macOS system fonts — swap these on Linux/Windows
|
||||||
|
FONT_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
|
||||||
|
FONT_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
||||||
|
FONT_REG = "/System/Library/Fonts/Supplemental/Arial.ttf"
|
||||||
|
|
||||||
|
|
||||||
|
def load_font(path, size):
|
||||||
|
return ImageFont.truetype(path, size)
|
||||||
|
|
||||||
|
|
||||||
|
def text_bbox(draw, text, font):
|
||||||
|
bb = draw.textbbox((0, 0), text, font=font)
|
||||||
|
return bb[2] - bb[0], bb[3] - bb[1], bb[1] # width, height, y_offset
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_text(draw, text, x, y, max_w, font, fill, line_gap=10,
|
||||||
|
cover_right=None, cover_bottom=None):
|
||||||
|
"""Word-wrap text onto the image, narrowing lines that overlap the cover.
|
||||||
|
|
||||||
|
line_gap: fixed pixel gap between lines (not a multiplier).
|
||||||
|
Returns y just below the last line of text (no trailing gap)."""
|
||||||
|
words = text.split()
|
||||||
|
lines = []
|
||||||
|
cur = ""
|
||||||
|
cur_y = y
|
||||||
|
|
||||||
|
for word in words:
|
||||||
|
test = f"{cur} {word}".strip()
|
||||||
|
eff_w = max_w
|
||||||
|
if cover_right and cover_bottom and cur_y < cover_bottom:
|
||||||
|
eff_w = cover_right - x - 20
|
||||||
|
|
||||||
|
tw, th, _ = text_bbox(draw, test, font)
|
||||||
|
if tw > eff_w and cur:
|
||||||
|
lines.append((cur, cur_y))
|
||||||
|
_, lh, _ = text_bbox(draw, cur, font)
|
||||||
|
cur_y += lh + line_gap
|
||||||
|
cur = word
|
||||||
|
else:
|
||||||
|
cur = test
|
||||||
|
|
||||||
|
if cur:
|
||||||
|
lines.append((cur, cur_y))
|
||||||
|
_, lh, _ = text_bbox(draw, cur, font)
|
||||||
|
|
||||||
|
for line, ly in lines:
|
||||||
|
draw.text((x, ly), line, font=font, fill=fill)
|
||||||
|
|
||||||
|
return cur_y + lh # return y just past the last line's bottom
|
||||||
|
|
||||||
|
|
||||||
|
def center_text(draw, text, y, canvas_w, font, fill):
|
||||||
|
tw, th, _ = text_bbox(draw, text, font)
|
||||||
|
draw.text(((canvas_w - tw) // 2, y), text, font=font, fill=fill)
|
||||||
|
return y + th
|
||||||
|
|
||||||
|
|
||||||
|
def draw_email_box(draw, email, y, canvas_w, font):
|
||||||
|
tw, th, y_off = text_bbox(draw, email, font)
|
||||||
|
px, py = 22, 16
|
||||||
|
box_w = tw + px * 2
|
||||||
|
box_x = (canvas_w - box_w) // 2
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[box_x, y, box_x + box_w, y + th + py * 2],
|
||||||
|
radius=8, fill=(45, 30, 12), outline=ACCENT, width=2,
|
||||||
|
)
|
||||||
|
draw.text((box_x + px, y + py - y_off), email, font=font, fill=ACCENT)
|
||||||
|
return y + th + py * 2
|
||||||
|
|
||||||
|
|
||||||
|
def draw_accent_bars(draw, w, h, thickness):
|
||||||
|
draw.rectangle([0, 0, w, thickness], fill=ACCENT)
|
||||||
|
draw.rectangle([0, h - thickness, w, h], fill=ACCENT)
|
||||||
|
|
||||||
|
|
||||||
|
def paste_cover(img, x, y, size, radius):
|
||||||
|
cover = Image.open(COVER).resize((size, size), Image.LANCZOS)
|
||||||
|
mask = Image.new("L", (size, size), 0)
|
||||||
|
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255)
|
||||||
|
img.paste(cover, (x, y), mask)
|
||||||
|
|
||||||
|
|
||||||
|
def make_square(title, paragraphs, email, filename="email_announcement_square.png"):
|
||||||
|
W = 1080
|
||||||
|
img = Image.new("RGB", (W, W), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw_accent_bars(draw, W, W, 8)
|
||||||
|
|
||||||
|
# Cover image — top right
|
||||||
|
cover_size, cover_x, cover_y = 240, W - 290, 35
|
||||||
|
paste_cover(img, cover_x, cover_y, cover_size, 20)
|
||||||
|
cover_bottom = cover_y + cover_size + 15
|
||||||
|
|
||||||
|
m = 60
|
||||||
|
y = 40
|
||||||
|
tw_full = W - m * 2
|
||||||
|
|
||||||
|
# Header
|
||||||
|
draw.text((m, y), "LUKE AT THE ROOST", font=load_font(FONT_BOLD, 24), fill=ACCENT)
|
||||||
|
y += 30
|
||||||
|
tag = load_font(FONT_REG, 20)
|
||||||
|
draw.text((m, y), "Late-night call-in radio", font=tag, fill=MUTED)
|
||||||
|
draw.text((m, y + 26), "powered by AI", font=tag, fill=MUTED)
|
||||||
|
y += 75
|
||||||
|
|
||||||
|
# Consistent spacing constants
|
||||||
|
LINE_GAP = 12 # between lines within a block
|
||||||
|
SECTION_GAP = 32 # between sections (body→CTA, CTA→footer)
|
||||||
|
PARA_GAP = 26 # between body paragraphs
|
||||||
|
TITLE_GAP = 48 # between title and first body paragraph
|
||||||
|
|
||||||
|
# Title
|
||||||
|
y = wrap_text(draw, title, m, y, tw_full, load_font(FONT_BLACK, 72), WHITE,
|
||||||
|
line_gap=LINE_GAP, cover_right=cover_x, cover_bottom=cover_bottom)
|
||||||
|
y += TITLE_GAP
|
||||||
|
|
||||||
|
# Body paragraphs
|
||||||
|
body_font = load_font(FONT_REG, 32)
|
||||||
|
colors = [LIGHTER] + [MUTED] * (len(paragraphs) - 1)
|
||||||
|
for i, (para, color) in enumerate(zip(paragraphs, colors)):
|
||||||
|
cr = cover_x if y < cover_bottom else None
|
||||||
|
cb = cover_bottom if y < cover_bottom else None
|
||||||
|
y = wrap_text(draw, para, m, y, tw_full, body_font, color,
|
||||||
|
line_gap=LINE_GAP, cover_right=cr, cover_bottom=cb)
|
||||||
|
if i < len(paragraphs) - 1:
|
||||||
|
y += PARA_GAP
|
||||||
|
|
||||||
|
y += SECTION_GAP
|
||||||
|
|
||||||
|
# Email CTA
|
||||||
|
y = draw_email_box(draw, email, y, W, load_font(FONT_BOLD, 36))
|
||||||
|
y += SECTION_GAP
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
y = center_text(draw, "New episodes drop daily. Be part of the next one.",
|
||||||
|
y, W, load_font(FONT_REG, 24), MUTED)
|
||||||
|
y += PARA_GAP
|
||||||
|
info = load_font(FONT_REG, 22)
|
||||||
|
center_text(draw, "lukeattheroost.com", y, W, info, ACCENT)
|
||||||
|
y += PARA_GAP
|
||||||
|
center_text(draw, "Spotify \u00b7 Apple Podcasts \u00b7 YouTube \u00b7 RSS",
|
||||||
|
y, W, info, MUTED)
|
||||||
|
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
img.save(os.path.join(OUT_DIR, filename), quality=95)
|
||||||
|
print(f"Square: {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
def make_landscape(title, paragraphs, email, filename="email_announcement_twitter.png"):
|
||||||
|
TW, TH = 1200, 675
|
||||||
|
img = Image.new("RGB", (TW, TH), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw_accent_bars(draw, TW, TH, 6)
|
||||||
|
|
||||||
|
# Cover image — top right
|
||||||
|
cover_size, cover_x, cover_y = 180, TW - 220, 22
|
||||||
|
paste_cover(img, cover_x, cover_y, cover_size, 16)
|
||||||
|
cover_bottom = cover_y + cover_size + 10
|
||||||
|
|
||||||
|
m = 45
|
||||||
|
y = 25
|
||||||
|
tw_full = TW - m * 2
|
||||||
|
|
||||||
|
# Header
|
||||||
|
draw.text((m, y), "LUKE AT THE ROOST", font=load_font(FONT_BOLD, 20), fill=ACCENT)
|
||||||
|
y += 24
|
||||||
|
draw.text((m, y), "Late-night call-in radio powered by AI",
|
||||||
|
font=load_font(FONT_REG, 17), fill=MUTED)
|
||||||
|
y += 38
|
||||||
|
|
||||||
|
# Consistent spacing constants
|
||||||
|
LINE_GAP = 8 # between lines within a block
|
||||||
|
SECTION_GAP = 20 # between sections
|
||||||
|
PARA_GAP = 16 # between body paragraphs
|
||||||
|
TITLE_GAP = 32 # between title and first body paragraph
|
||||||
|
|
||||||
|
# Title
|
||||||
|
y = wrap_text(draw, title, m, y, tw_full, load_font(FONT_BLACK, 50), WHITE,
|
||||||
|
line_gap=LINE_GAP, cover_right=cover_x, cover_bottom=cover_bottom)
|
||||||
|
y += TITLE_GAP
|
||||||
|
|
||||||
|
# Body paragraphs
|
||||||
|
body_font = load_font(FONT_REG, 23)
|
||||||
|
colors = [LIGHTER] + [MUTED] * (len(paragraphs) - 1)
|
||||||
|
for i, (para, color) in enumerate(zip(paragraphs, colors)):
|
||||||
|
cr = cover_x if y < cover_bottom else None
|
||||||
|
cb = cover_bottom if y < cover_bottom else None
|
||||||
|
y = wrap_text(draw, para, m, y, tw_full, body_font, color,
|
||||||
|
line_gap=LINE_GAP, cover_right=cr, cover_bottom=cb)
|
||||||
|
if i < len(paragraphs) - 1:
|
||||||
|
y += PARA_GAP
|
||||||
|
|
||||||
|
y += SECTION_GAP
|
||||||
|
|
||||||
|
# Email CTA
|
||||||
|
y = draw_email_box(draw, email, y, TW, load_font(FONT_BOLD, 26))
|
||||||
|
y += SECTION_GAP
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
y = center_text(draw, "New episodes drop daily. Be part of the next one.",
|
||||||
|
y, TW, load_font(FONT_REG, 19), MUTED)
|
||||||
|
y += PARA_GAP
|
||||||
|
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
||||||
|
y, TW, load_font(FONT_REG, 17), (140, 132, 120))
|
||||||
|
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
img.save(os.path.join(OUT_DIR, filename), quality=95)
|
||||||
|
print(f"Landscape: {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Default content ---
|
||||||
|
|
||||||
|
DEFAULT_TITLE = "NOW ACCEPTING LISTENER EMAILS"
|
||||||
|
DEFAULT_EMAIL = "submissions@lukeattheroost.com"
|
||||||
|
DEFAULT_PARAGRAPHS = [
|
||||||
|
"Got a story? A question? A hot take that\u2019s been eating at you since midnight? A confession you need to get off your chest? Send it to the show.",
|
||||||
|
"The best listener emails get read live on air during the next episode \u2014 either by Luke himself on the mic, or by one of his robot friends. Your words, on the show, heard by everyone tuning in.",
|
||||||
|
"Can\u2019t call 208-439-LUKE at 2 AM? Don\u2019t want to talk on the phone? Now you\u2019ve got another way to be part of the conversation. Write in anytime \u2014 day or night, long or short, serious or unhinged.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Generate social media images")
|
||||||
|
parser.add_argument("--title", default=DEFAULT_TITLE)
|
||||||
|
parser.add_argument("--email", default=DEFAULT_EMAIL)
|
||||||
|
parser.add_argument("--body", help="Text file with paragraphs (blank-line separated)")
|
||||||
|
parser.add_argument("--prefix", default="email_announcement",
|
||||||
|
help="Output filename prefix")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.body:
|
||||||
|
with open(args.body) as f:
|
||||||
|
paragraphs = [p.strip() for p in f.read().split("\n\n") if p.strip()]
|
||||||
|
else:
|
||||||
|
paragraphs = DEFAULT_PARAGRAPHS
|
||||||
|
|
||||||
|
make_square(args.title, paragraphs, args.email,
|
||||||
|
filename=f"{args.prefix}_square.png")
|
||||||
|
make_landscape(args.title, paragraphs, args.email,
|
||||||
|
filename=f"{args.prefix}_twitter.png")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -22,6 +22,8 @@ import sys
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||||
|
|
||||||
YOUTUBE_PLAYLIST = "PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-"
|
YOUTUBE_PLAYLIST = "PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-"
|
||||||
APPLE_PODCAST_ID = "1875205848"
|
APPLE_PODCAST_ID = "1875205848"
|
||||||
@@ -33,9 +35,9 @@ DOCKER_BIN = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
|||||||
CASTOPOD_DB_CONTAINER = "castopod-mariadb-1"
|
CASTOPOD_DB_CONTAINER = "castopod-mariadb-1"
|
||||||
|
|
||||||
BUNNY_STORAGE_ZONE = "lukeattheroost"
|
BUNNY_STORAGE_ZONE = "lukeattheroost"
|
||||||
BUNNY_STORAGE_KEY = "92749cd3-85df-4cff-938fe35eb994-30f8-4cf2"
|
BUNNY_STORAGE_KEY = os.getenv("BUNNY_STORAGE_KEY", "")
|
||||||
BUNNY_STORAGE_REGION = "la"
|
BUNNY_STORAGE_REGION = "la"
|
||||||
BUNNY_ACCOUNT_KEY = "2865f279-297b-431a-ad18-0ccf1f8e4fa8cf636cea-3222-415a-84ed-56ee195c0530"
|
BUNNY_ACCOUNT_KEY = os.getenv("BUNNY_ACCOUNT_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
def _find_ytdlp():
|
def _find_ytdlp():
|
||||||
@@ -243,13 +245,16 @@ def _run_db_query(sql):
|
|||||||
docker_bin = path
|
docker_bin = path
|
||||||
break
|
break
|
||||||
|
|
||||||
|
db_pass = os.getenv("CASTOPOD_DB_PASS", "")
|
||||||
if docker_bin:
|
if docker_bin:
|
||||||
cmd = [docker_bin, "exec", "-i", CASTOPOD_DB_CONTAINER,
|
# Pass password via MYSQL_PWD env var instead of command line (not visible in ps)
|
||||||
"mysql", "-u", "castopod", "-pBYtbFfk3ndeVabb26xb0UyKU", "castopod", "-N"]
|
cmd = [docker_bin, "exec", "-i", "-e", f"MYSQL_PWD={db_pass}",
|
||||||
|
CASTOPOD_DB_CONTAINER,
|
||||||
|
"mysql", "-u", "castopod", "castopod", "-N"]
|
||||||
else:
|
else:
|
||||||
cmd = [
|
cmd = [
|
||||||
"ssh", "-p", NAS_SSH_PORT, NAS_SSH,
|
"ssh", "-p", NAS_SSH_PORT, NAS_SSH,
|
||||||
f"{DOCKER_BIN} exec -i {CASTOPOD_DB_CONTAINER} mysql -u castopod -pBYtbFfk3ndeVabb26xb0UyKU castopod -N"
|
f"{DOCKER_BIN} exec -i -e MYSQL_PWD={db_pass} {CASTOPOD_DB_CONTAINER} mysql -u castopod castopod -N"
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(cmd, input=sql, capture_output=True, text=True, timeout=30)
|
proc = subprocess.run(cmd, input=sql, capture_output=True, text=True, timeout=30)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
Usage: python postprod.py recordings/2026-02-07_213000/ -o episode.mp3
|
Usage: python postprod.py recordings/2026-02-07_213000/ -o episode.mp3
|
||||||
|
|
||||||
Processes 5 aligned WAV stems (host, caller, music, sfx, ads) into a
|
Processes 6 aligned WAV stems (host, caller, music, sfx, ads, idents) into a
|
||||||
broadcast-ready MP3 with gap removal, voice compression, music ducking,
|
broadcast-ready MP3 with gap removal, voice compression, music ducking,
|
||||||
and loudness normalization.
|
and loudness normalization.
|
||||||
"""
|
"""
|
||||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
|
|
||||||
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
|
STEM_NAMES = ["host", "caller", "devon", "music", "sfx", "ads", "idents"]
|
||||||
|
|
||||||
|
|
||||||
def load_stems(stems_dir: Path) -> tuple[dict[str, np.ndarray], int]:
|
def load_stems(stems_dir: Path) -> tuple[dict[str, np.ndarray], int]:
|
||||||
@@ -69,7 +69,7 @@ def remove_gaps(stems: dict[str, np.ndarray], sr: int,
|
|||||||
|
|
||||||
# Detect gaps in everything except music (which always plays).
|
# Detect gaps in everything except music (which always plays).
|
||||||
# This catches TTS latency gaps while protecting ad breaks and SFX transitions.
|
# This catches TTS latency gaps while protecting ad breaks and SFX transitions.
|
||||||
content = stems["host"] + stems["caller"] + stems["sfx"] + stems["ads"]
|
content = stems["host"] + stems["caller"] + stems["devon"] + stems["sfx"] + stems["ads"] + stems["idents"]
|
||||||
rms = compute_rms(content, window_samples)
|
rms = compute_rms(content, window_samples)
|
||||||
|
|
||||||
# Threshold: percentile-based to sit above the mic noise floor
|
# Threshold: percentile-based to sit above the mic noise floor
|
||||||
@@ -386,7 +386,7 @@ def apply_ducking(music: np.ndarray, dialog: np.ndarray, sr: int,
|
|||||||
|
|
||||||
def match_voice_levels(stems: dict[str, np.ndarray], target_rms: float = 0.1) -> dict[str, np.ndarray]:
|
def match_voice_levels(stems: dict[str, np.ndarray], target_rms: float = 0.1) -> dict[str, np.ndarray]:
|
||||||
"""Normalize host, caller, and ads stems to the same RMS level."""
|
"""Normalize host, caller, and ads stems to the same RMS level."""
|
||||||
for name in ["host", "caller", "ads"]:
|
for name in ["host", "caller", "ads", "idents"]:
|
||||||
audio = stems[name]
|
audio = stems[name]
|
||||||
# Only measure non-silent portions
|
# Only measure non-silent portions
|
||||||
active = audio[np.abs(audio) > 0.001]
|
active = audio[np.abs(audio) > 0.001]
|
||||||
@@ -408,7 +408,7 @@ def mix_stems(stems: dict[str, np.ndarray],
|
|||||||
levels: dict[str, float] | None = None,
|
levels: dict[str, float] | None = None,
|
||||||
stereo_imaging: bool = True) -> np.ndarray:
|
stereo_imaging: bool = True) -> np.ndarray:
|
||||||
if levels is None:
|
if levels is None:
|
||||||
levels = {"host": 0, "caller": 0, "music": -6, "sfx": -10, "ads": 0}
|
levels = {"host": 0, "caller": 0, "music": -6, "sfx": -10, "ads": 0, "idents": 0}
|
||||||
|
|
||||||
gains = {name: 10 ** (db / 20) for name, db in levels.items()}
|
gains = {name: 10 ** (db / 20) for name, db in levels.items()}
|
||||||
|
|
||||||
@@ -417,7 +417,7 @@ def mix_stems(stems: dict[str, np.ndarray],
|
|||||||
if stereo_imaging:
|
if stereo_imaging:
|
||||||
# Pan positions: -1.0 = full left, 0.0 = center, 1.0 = full right
|
# Pan positions: -1.0 = full left, 0.0 = center, 1.0 = full right
|
||||||
# Using constant-power panning law
|
# Using constant-power panning law
|
||||||
pans = {"host": 0.0, "caller": 0.15, "music": 0.0, "sfx": 0.0, "ads": 0.0}
|
pans = {"host": 0.0, "caller": 0.15, "music": 0.0, "sfx": 0.0, "ads": 0.0, "idents": 0.0}
|
||||||
# Music gets stereo width via slight L/R decorrelation
|
# Music gets stereo width via slight L/R decorrelation
|
||||||
music_width = 0.3
|
music_width = 0.3
|
||||||
|
|
||||||
@@ -432,7 +432,7 @@ def mix_stems(stems: dict[str, np.ndarray],
|
|||||||
|
|
||||||
if name == "music" and music_width > 0:
|
if name == "music" and music_width > 0:
|
||||||
# Widen music: delay right channel by ~0.5ms for Haas effect
|
# Widen music: delay right channel by ~0.5ms for Haas effect
|
||||||
delay_samples = int(0.0005 * 44100) # ~22 samples at 44.1kHz
|
delay_samples = int(0.0005 * sr) # ~22 samples at target sample rate
|
||||||
left += signal * (1 + music_width * 0.5)
|
left += signal * (1 + music_width * 0.5)
|
||||||
right_delayed = np.zeros_like(signal)
|
right_delayed = np.zeros_like(signal)
|
||||||
right_delayed[delay_samples:] = signal[:-delay_samples] if delay_samples > 0 else signal
|
right_delayed[delay_samples:] = signal[:-delay_samples] if delay_samples > 0 else signal
|
||||||
@@ -774,7 +774,7 @@ def main():
|
|||||||
print(f"\n[3/{total_steps}] Limiting ads + SFX...")
|
print(f"\n[3/{total_steps}] Limiting ads + SFX...")
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
tmp_dir = Path(tmp)
|
tmp_dir = Path(tmp)
|
||||||
for name in ["ads", "sfx"]:
|
for name in ["ads", "sfx", "idents"]:
|
||||||
if np.any(stems[name] != 0):
|
if np.any(stems[name] != 0):
|
||||||
stems[name] = limit_stem(stems[name], sr, tmp_dir, name)
|
stems[name] = limit_stem(stems[name], sr, tmp_dir, name)
|
||||||
|
|
||||||
@@ -834,7 +834,7 @@ def main():
|
|||||||
dialog = stems["host"] + stems["caller"]
|
dialog = stems["host"] + stems["caller"]
|
||||||
if np.any(dialog != 0) and np.any(stems["music"] != 0):
|
if np.any(dialog != 0) and np.any(stems["music"] != 0):
|
||||||
stems["music"] = apply_ducking(stems["music"], dialog, sr, duck_db=args.duck_amount,
|
stems["music"] = apply_ducking(stems["music"], dialog, sr, duck_db=args.duck_amount,
|
||||||
mute_signal=stems["ads"])
|
mute_signal=stems["ads"] + stems["idents"])
|
||||||
print(" Applied")
|
print(" Applied")
|
||||||
else:
|
else:
|
||||||
print(" No dialog or music to duck")
|
print(" No dialog or music to duck")
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
-- Show Region Marker — background script for REAPER
|
||||||
|
-- Polls /tmp/reaper_state.txt for state changes and creates colored regions.
|
||||||
|
-- Backend writes "dialog", "ad", or "ident" to the file.
|
||||||
|
-- Run via Actions → Run ReaScript before or during recording.
|
||||||
|
|
||||||
|
local STATE_FILE = "/tmp/reaper_state.txt"
|
||||||
|
|
||||||
|
local COLORS = {
|
||||||
|
dialog = reaper.ColorToNative(50, 180, 50) + 0x1000000, -- green
|
||||||
|
ad = reaper.ColorToNative(200, 80, 80) + 0x1000000, -- red
|
||||||
|
ident = reaper.ColorToNative(80, 120, 200) + 0x1000000, -- blue
|
||||||
|
}
|
||||||
|
local LABELS = {
|
||||||
|
dialog = "DIALOG",
|
||||||
|
ad = "AD",
|
||||||
|
ident = "IDENT",
|
||||||
|
}
|
||||||
|
|
||||||
|
local counts = { dialog = 0, ad = 0, ident = 0 }
|
||||||
|
local current_type = nil -- which region type is currently open
|
||||||
|
local current_start = 0
|
||||||
|
local last_pos = 0 -- last known transport position (while running)
|
||||||
|
local last_state = ""
|
||||||
|
local transport_active = false
|
||||||
|
|
||||||
|
local function log(msg)
|
||||||
|
reaper.ShowConsoleMsg("[Regions] " .. msg .. "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_playing_or_recording()
|
||||||
|
local state = reaper.GetPlayState()
|
||||||
|
return state > 0 and state ~= 2
|
||||||
|
end
|
||||||
|
|
||||||
|
local function open_region(rtype)
|
||||||
|
if current_type then return end
|
||||||
|
current_type = rtype
|
||||||
|
current_start = reaper.GetPlayPosition()
|
||||||
|
log("OPEN " .. rtype .. " at " .. string.format("%.2f", current_start))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function close_region(pos_override)
|
||||||
|
if not current_type then return end
|
||||||
|
local pos = pos_override or reaper.GetPlayPosition()
|
||||||
|
local len = pos - current_start
|
||||||
|
local rtype = current_type
|
||||||
|
current_type = nil
|
||||||
|
log("CLOSE " .. rtype .. " at " .. string.format("%.2f", pos) .. " (len=" .. string.format("%.2f", len) .. ")")
|
||||||
|
if len > 0.1 then
|
||||||
|
counts[rtype] = counts[rtype] + 1
|
||||||
|
local name = LABELS[rtype] .. " " .. counts[rtype]
|
||||||
|
reaper.AddProjectMarker2(0, true, current_start, pos, name, -1, COLORS[rtype])
|
||||||
|
log(" -> Created '" .. name .. "'")
|
||||||
|
else
|
||||||
|
log(" -> Skipped (too short)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function poll()
|
||||||
|
if not transport_active then
|
||||||
|
if is_playing_or_recording() then
|
||||||
|
transport_active = true
|
||||||
|
log("Transport started at " .. string.format("%.2f", reaper.GetPlayPosition()))
|
||||||
|
local f = io.open(STATE_FILE, "r")
|
||||||
|
if f then
|
||||||
|
last_state = f:read("*l") or "dialog"
|
||||||
|
f:close()
|
||||||
|
else
|
||||||
|
last_state = "dialog"
|
||||||
|
end
|
||||||
|
log("Initial state: '" .. last_state .. "'")
|
||||||
|
open_region(last_state)
|
||||||
|
end
|
||||||
|
reaper.defer(poll)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Track position while transport is running
|
||||||
|
last_pos = reaper.GetPlayPosition()
|
||||||
|
|
||||||
|
-- Detect transport stop (recording ended) — use last known good position
|
||||||
|
if not is_playing_or_recording() then
|
||||||
|
log("Transport stopped at last known pos " .. string.format("%.2f", last_pos))
|
||||||
|
close_region(last_pos)
|
||||||
|
transport_active = false
|
||||||
|
reaper.defer(poll)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = io.open(STATE_FILE, "r")
|
||||||
|
if f then
|
||||||
|
local state = f:read("*l") or "dialog"
|
||||||
|
f:close()
|
||||||
|
|
||||||
|
if state ~= last_state then
|
||||||
|
log("State change: '" .. last_state .. "' -> '" .. state .. "'")
|
||||||
|
close_region()
|
||||||
|
open_region(state)
|
||||||
|
last_state = state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
reaper.defer(poll)
|
||||||
|
end
|
||||||
|
|
||||||
|
log("Script loaded — waiting for transport to start...")
|
||||||
|
|
||||||
|
reaper.atexit(function()
|
||||||
|
log("Script stopping (atexit)")
|
||||||
|
close_region()
|
||||||
|
local total = counts.dialog + counts.ad + counts.ident
|
||||||
|
log("Done. " .. total .. " regions (" .. counts.dialog .. " dialog, " .. counts.ad .. " ad, " .. counts.ident .. " ident)")
|
||||||
|
end)
|
||||||
|
|
||||||
|
poll()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Scan music directory for tracks that contain vocals/lyrics.
|
||||||
|
|
||||||
|
Uses Whisper to transcribe a sample from each track — if it picks up
|
||||||
|
actual words, the track likely has vocals.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scan_music_vocals.py # scan and report
|
||||||
|
python scan_music_vocals.py --delete # scan and delete vocal tracks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import librosa
|
||||||
|
import numpy as np
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
MUSIC_DIR = Path(__file__).parent / "music"
|
||||||
|
WHISPER_MODEL = "distil-large-v3"
|
||||||
|
|
||||||
|
# Words Whisper hallucinates on silence/instrumental — ignore these
|
||||||
|
HALLUCINATION_PHRASES = {
|
||||||
|
"thank you", "thanks for watching", "subscribe", "like and subscribe",
|
||||||
|
"please subscribe", "thank you for watching", "thanks for listening",
|
||||||
|
"you", "the end", "bye", "okay",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_track(model: WhisperModel, filepath: Path) -> tuple[bool, str]:
|
||||||
|
"""Check a single track for vocals. Returns (has_vocals, transcription)."""
|
||||||
|
try:
|
||||||
|
audio, sr = librosa.load(str(filepath), sr=16000, mono=True)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"[load error: {e}]"
|
||||||
|
|
||||||
|
duration = len(audio) / sr
|
||||||
|
if duration < 10:
|
||||||
|
return False, "[too short]"
|
||||||
|
|
||||||
|
# Sample 30s from the middle (most likely to have vocals)
|
||||||
|
mid = len(audio) // 2
|
||||||
|
half_window = int(15 * sr) # 15s each side
|
||||||
|
start = max(0, mid - half_window)
|
||||||
|
end = min(len(audio), mid + half_window)
|
||||||
|
sample = audio[start:end]
|
||||||
|
|
||||||
|
segments, info = model.transcribe(
|
||||||
|
sample,
|
||||||
|
beam_size=3,
|
||||||
|
language="en",
|
||||||
|
vad_filter=True,
|
||||||
|
vad_parameters=dict(min_speech_duration_ms=500),
|
||||||
|
)
|
||||||
|
segments_list = list(segments)
|
||||||
|
text = " ".join(s.text for s in segments_list).strip()
|
||||||
|
|
||||||
|
# Filter out Whisper hallucinations
|
||||||
|
text_lower = text.lower().strip()
|
||||||
|
if text_lower in HALLUCINATION_PHRASES or len(text_lower) < 4:
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
# If Whisper found substantial text, it's likely vocals
|
||||||
|
word_count = len(text.split())
|
||||||
|
has_vocals = word_count >= 3
|
||||||
|
|
||||||
|
return has_vocals, text
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Scan music for vocal tracks")
|
||||||
|
parser.add_argument("--delete", action="store_true", help="Delete tracks with vocals")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
audio_files = sorted(
|
||||||
|
f for f in MUSIC_DIR.iterdir()
|
||||||
|
if f.suffix.lower() in {".mp3", ".wav", ".ogg", ".flac"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not audio_files:
|
||||||
|
print("No audio files found in music/")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Loading Whisper {WHISPER_MODEL}...")
|
||||||
|
model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
|
||||||
|
|
||||||
|
print(f"Scanning {len(audio_files)} tracks for vocals...\n")
|
||||||
|
|
||||||
|
vocal_tracks = []
|
||||||
|
for i, f in enumerate(audio_files, 1):
|
||||||
|
print(f"[{i}/{len(audio_files)}] {f.name}...", end=" ", flush=True)
|
||||||
|
has_vocals, text = scan_track(model, f)
|
||||||
|
if has_vocals:
|
||||||
|
print(f"VOCALS: {text[:80]}")
|
||||||
|
vocal_tracks.append((f, text))
|
||||||
|
else:
|
||||||
|
print("OK")
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Results: {len(vocal_tracks)} tracks with vocals out of {len(audio_files)}\n")
|
||||||
|
|
||||||
|
if not vocal_tracks:
|
||||||
|
print("All tracks appear to be instrumental!")
|
||||||
|
return
|
||||||
|
|
||||||
|
for f, text in vocal_tracks:
|
||||||
|
print(f" {f.name}")
|
||||||
|
print(f" Lyrics: {text[:120]}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.delete:
|
||||||
|
print(f"Deleting {len(vocal_tracks)} vocal tracks...")
|
||||||
|
for f, _ in vocal_tracks:
|
||||||
|
f.unlink()
|
||||||
|
print(f" Deleted: {f.name}")
|
||||||
|
print("Done.")
|
||||||
|
else:
|
||||||
|
print("Run with --delete to remove these tracks.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,787 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Upload podcast clips to social media (direct YouTube & Bluesky, Postiz for others).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python upload_clips.py # interactive: pick episode, clips, platforms
|
||||||
|
python upload_clips.py clips/episode-12/ # pick clips and platforms interactively
|
||||||
|
python upload_clips.py clips/episode-12/ --clip 1 --platforms ig,yt
|
||||||
|
python upload_clips.py clips/episode-12/ --yes # skip all prompts, upload everything
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from atproto import Client as BskyClient
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
|
||||||
|
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
|
||||||
|
POSTIZ_INTEGRATIONS = json.loads(os.getenv("POSTIZ_INTEGRATIONS", "{}"))
|
||||||
|
|
||||||
|
BSKY_HANDLE = os.getenv("BSKY_HANDLE", "lukeattheroost.bsky.social")
|
||||||
|
BSKY_APP_PASSWORD = os.getenv("BSKY_APP_PASSWORD")
|
||||||
|
|
||||||
|
YT_CLIENT_SECRETS = Path(__file__).parent / "youtube_client_secrets.json"
|
||||||
|
YT_TOKEN_FILE = Path(__file__).parent / "youtube_token.json"
|
||||||
|
|
||||||
|
PLATFORM_ALIASES = {
|
||||||
|
"ig": "instagram", "insta": "instagram", "instagram": "instagram",
|
||||||
|
"yt": "youtube", "youtube": "youtube",
|
||||||
|
"fb": "facebook", "facebook": "facebook",
|
||||||
|
"bsky": "bluesky", "bluesky": "bluesky",
|
||||||
|
"masto": "mastodon", "mastodon": "mastodon",
|
||||||
|
"nostr": "nostr",
|
||||||
|
"li": "linkedin", "linkedin": "linkedin",
|
||||||
|
"threads": "threads",
|
||||||
|
"tt": "tiktok", "tiktok": "tiktok",
|
||||||
|
}
|
||||||
|
|
||||||
|
PLATFORM_DISPLAY = {
|
||||||
|
"instagram": "Instagram Reels",
|
||||||
|
"youtube": "YouTube Shorts",
|
||||||
|
"facebook": "Facebook Reels",
|
||||||
|
"bluesky": "Bluesky",
|
||||||
|
"mastodon": "Mastodon",
|
||||||
|
"nostr": "Nostr",
|
||||||
|
"linkedin": "LinkedIn",
|
||||||
|
"threads": "Threads",
|
||||||
|
"tiktok": "TikTok",
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_PLATFORMS = list(PLATFORM_DISPLAY.keys())
|
||||||
|
|
||||||
|
UPLOAD_LEDGER_FILE = "upload-history.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_upload_history(clips_dir: Path) -> dict:
|
||||||
|
"""Load upload history for a clips directory.
|
||||||
|
Returns dict mapping clip_file -> list of platforms already uploaded to.
|
||||||
|
"""
|
||||||
|
ledger = clips_dir / UPLOAD_LEDGER_FILE
|
||||||
|
if ledger.exists():
|
||||||
|
with open(ledger) as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_upload_history(clips_dir: Path, history: dict):
|
||||||
|
with open(clips_dir / UPLOAD_LEDGER_FILE, "w") as f:
|
||||||
|
json.dump(history, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_url(path: str) -> str:
|
||||||
|
base = POSTIZ_URL.rstrip("/")
|
||||||
|
return f"{base}/api/public/v1{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def api_headers() -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": POSTIZ_API_KEY,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_integrations() -> list[dict]:
|
||||||
|
resp = requests.get(get_api_url("/integrations"), headers=api_headers(), timeout=15)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"Error fetching integrations: {resp.status_code} {resp.text[:200]}")
|
||||||
|
sys.exit(1)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
BLOCKED_INTEGRATION_IDS = {
|
||||||
|
"cmluam50j0001o46xifujx059", # Personal LinkedIn (CareerPulse) — never post podcast content here
|
||||||
|
}
|
||||||
|
|
||||||
|
def find_integration(integrations: list[dict], provider: str) -> dict | None:
|
||||||
|
# Prefer hardcoded integration ID from .env (avoids picking wrong account)
|
||||||
|
if provider in POSTIZ_INTEGRATIONS:
|
||||||
|
target_id = POSTIZ_INTEGRATIONS[provider].get("id")
|
||||||
|
if target_id:
|
||||||
|
for integ in integrations:
|
||||||
|
if integ.get("id") == target_id:
|
||||||
|
return integ
|
||||||
|
# Fallback: first matching provider (skip blocked accounts)
|
||||||
|
for integ in integrations:
|
||||||
|
if integ.get("id") in BLOCKED_INTEGRATION_IDS:
|
||||||
|
continue
|
||||||
|
if integ.get("identifier", "").startswith(provider) and not integ.get("disabled"):
|
||||||
|
return integ
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(file_path: Path) -> dict:
|
||||||
|
headers = {"Authorization": POSTIZ_API_KEY}
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/upload"),
|
||||||
|
headers=headers,
|
||||||
|
files={"file": (file_path.name, f, "video/mp4")},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
print(f"Upload failed: {resp.status_code} {resp.text[:200]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def build_content(clip: dict, platform: str) -> str:
|
||||||
|
desc = clip.get("description", clip.get("caption_text", ""))
|
||||||
|
hashtags = clip.get("hashtags", [])
|
||||||
|
hashtag_str = " ".join(hashtags)
|
||||||
|
|
||||||
|
if platform == "bluesky":
|
||||||
|
if hashtags and len(desc) + 2 + len(hashtag_str) <= 300:
|
||||||
|
return desc + "\n\n" + hashtag_str
|
||||||
|
return desc[:300]
|
||||||
|
|
||||||
|
parts = [desc]
|
||||||
|
if hashtags:
|
||||||
|
parts.append("\n\n" + hashtag_str)
|
||||||
|
if platform in ("youtube", "facebook"):
|
||||||
|
parts.append("\n\nListen to the full episode: lukeattheroost.com")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings(clip: dict, platform: str) -> dict:
|
||||||
|
if platform == "instagram":
|
||||||
|
return {"__type": "instagram", "post_type": "post", "collaborators": []}
|
||||||
|
if platform == "youtube":
|
||||||
|
yt_tags = [{"value": h.lstrip("#"), "label": h.lstrip("#")}
|
||||||
|
for h in clip.get("hashtags", [])]
|
||||||
|
return {
|
||||||
|
"__type": "youtube",
|
||||||
|
"title": clip["title"],
|
||||||
|
"type": "public",
|
||||||
|
"selfDeclaredMadeForKids": "no",
|
||||||
|
"thumbnail": None,
|
||||||
|
"tags": yt_tags,
|
||||||
|
}
|
||||||
|
if platform == "tiktok":
|
||||||
|
return {
|
||||||
|
"__type": "tiktok",
|
||||||
|
"privacy_level": "PUBLIC_TO_EVERYONE",
|
||||||
|
"duet": False,
|
||||||
|
"stitch": False,
|
||||||
|
"comment": True,
|
||||||
|
"autoAddMusic": "no",
|
||||||
|
"brand_content_toggle": False,
|
||||||
|
"brand_organic_toggle": False,
|
||||||
|
"content_posting_method": "DIRECT_POST",
|
||||||
|
}
|
||||||
|
return {"__type": platform}
|
||||||
|
|
||||||
|
|
||||||
|
def post_to_bluesky(clip: dict, clip_file: Path) -> bool:
|
||||||
|
"""Post a clip directly to Bluesky via atproto (bypasses Postiz)."""
|
||||||
|
import time
|
||||||
|
import httpx
|
||||||
|
from atproto import models
|
||||||
|
|
||||||
|
if not BSKY_APP_PASSWORD:
|
||||||
|
print(" Error: BSKY_APP_PASSWORD not set in .env")
|
||||||
|
return False
|
||||||
|
|
||||||
|
client = BskyClient()
|
||||||
|
client.login(BSKY_HANDLE, BSKY_APP_PASSWORD)
|
||||||
|
did = client.me.did
|
||||||
|
video_data = clip_file.read_bytes()
|
||||||
|
|
||||||
|
# Get a service auth token scoped to the user's PDS (required by video service)
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
pds_host = urlparse(client._session.pds_endpoint).hostname
|
||||||
|
service_auth = client.com.atproto.server.get_service_auth(
|
||||||
|
{"aud": f"did:web:{pds_host}", "lxm": "com.atproto.repo.uploadBlob"}
|
||||||
|
)
|
||||||
|
token = service_auth.token
|
||||||
|
|
||||||
|
# Upload video to Bluesky's video processing service (not the PDS)
|
||||||
|
print(f" Uploading video ({len(video_data) / 1_000_000:.1f} MB)...")
|
||||||
|
upload_resp = httpx.post(
|
||||||
|
"https://video.bsky.app/xrpc/app.bsky.video.uploadVideo",
|
||||||
|
params={"did": did, "name": clip_file.name},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "video/mp4",
|
||||||
|
},
|
||||||
|
content=video_data,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if upload_resp.status_code not in (200, 409):
|
||||||
|
print(f" Upload failed: {upload_resp.status_code} {upload_resp.text[:200]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
upload_data = upload_resp.json()
|
||||||
|
job_id = upload_data.get("jobId") or upload_data.get("jobStatus", {}).get("jobId")
|
||||||
|
if not job_id:
|
||||||
|
print(f" No jobId returned: {upload_resp.text[:200]}")
|
||||||
|
return False
|
||||||
|
print(f" Video processing (job {job_id})...")
|
||||||
|
|
||||||
|
# Poll until video is processed
|
||||||
|
session_token = client._session.access_jwt
|
||||||
|
blob = None
|
||||||
|
while True:
|
||||||
|
status_resp = httpx.get(
|
||||||
|
"https://video.bsky.app/xrpc/app.bsky.video.getJobStatus",
|
||||||
|
params={"jobId": job_id},
|
||||||
|
headers={"Authorization": f"Bearer {session_token}"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp_data = status_resp.json()
|
||||||
|
status = resp_data.get("jobStatus") or resp_data
|
||||||
|
state = status.get("state")
|
||||||
|
if state == "JOB_STATE_COMPLETED":
|
||||||
|
blob = status.get("blob")
|
||||||
|
break
|
||||||
|
if state == "JOB_STATE_FAILED":
|
||||||
|
err = status.get("error") or status.get("message") or "unknown"
|
||||||
|
print(f" Video processing failed: {err}")
|
||||||
|
return False
|
||||||
|
progress = status.get("progress", 0)
|
||||||
|
print(f" Processing... {progress}%")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
if not blob:
|
||||||
|
print(" No blob returned after processing")
|
||||||
|
return False
|
||||||
|
|
||||||
|
text = build_content(clip, "bluesky")
|
||||||
|
|
||||||
|
embed = models.AppBskyEmbedVideo.Main(
|
||||||
|
video=models.blob_ref.BlobRef(
|
||||||
|
mime_type=blob["mimeType"],
|
||||||
|
size=blob["size"],
|
||||||
|
ref=models.blob_ref.IpldLink(link=blob["ref"]["$link"]),
|
||||||
|
),
|
||||||
|
alt=clip.get("caption_text", clip["title"]),
|
||||||
|
aspect_ratio=models.AppBskyEmbedDefs.AspectRatio(width=1080, height=1920),
|
||||||
|
)
|
||||||
|
client.send_post(text=text, embed=embed)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_youtube_service():
|
||||||
|
"""Authenticate with YouTube API. First run opens a browser, then reuses saved token."""
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
|
from google.auth.transport.requests import Request
|
||||||
|
from googleapiclient.discovery import build as yt_build
|
||||||
|
|
||||||
|
scopes = ["https://www.googleapis.com/auth/youtube.upload"]
|
||||||
|
creds = None
|
||||||
|
|
||||||
|
if YT_TOKEN_FILE.exists():
|
||||||
|
creds = Credentials.from_authorized_user_file(str(YT_TOKEN_FILE), scopes)
|
||||||
|
|
||||||
|
if not creds or not creds.valid:
|
||||||
|
if creds and creds.expired and creds.refresh_token:
|
||||||
|
creds.refresh(Request())
|
||||||
|
else:
|
||||||
|
if not YT_CLIENT_SECRETS.exists():
|
||||||
|
print(" Error: youtube_client_secrets.json not found")
|
||||||
|
print(" Download OAuth2 Desktop App credentials from Google Cloud Console")
|
||||||
|
return None
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(str(YT_CLIENT_SECRETS), scopes)
|
||||||
|
creds = flow.run_local_server(port=8090)
|
||||||
|
|
||||||
|
with open(YT_TOKEN_FILE, "w") as f:
|
||||||
|
f.write(creds.to_json())
|
||||||
|
|
||||||
|
return yt_build("youtube", "v3", credentials=creds)
|
||||||
|
|
||||||
|
|
||||||
|
def post_to_youtube(clip: dict, clip_file: Path) -> bool:
|
||||||
|
"""Upload a clip directly to YouTube Shorts via the Data API."""
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
from googleapiclient.http import MediaFileUpload
|
||||||
|
from googleapiclient.errors import HttpError
|
||||||
|
|
||||||
|
youtube = get_youtube_service()
|
||||||
|
if not youtube:
|
||||||
|
return False
|
||||||
|
|
||||||
|
title = clip["title"]
|
||||||
|
if "#Shorts" not in title:
|
||||||
|
title = f"{title} #Shorts"
|
||||||
|
|
||||||
|
description = build_content(clip, "youtube")
|
||||||
|
if "#Shorts" not in description:
|
||||||
|
description += "\n\n#Shorts"
|
||||||
|
|
||||||
|
tags = [h.lstrip("#") for h in clip.get("hashtags", [])]
|
||||||
|
if "Shorts" not in tags:
|
||||||
|
tags.insert(0, "Shorts")
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"snippet": {
|
||||||
|
"title": title[:100],
|
||||||
|
"description": description,
|
||||||
|
"tags": tags,
|
||||||
|
"categoryId": "24", # Entertainment
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"privacyStatus": "public",
|
||||||
|
"selfDeclaredMadeForKids": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
media = MediaFileUpload(
|
||||||
|
str(clip_file),
|
||||||
|
mimetype="video/mp4",
|
||||||
|
chunksize=256 * 1024,
|
||||||
|
resumable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = youtube.videos().insert(part="snippet,status", body=body, media_body=media)
|
||||||
|
|
||||||
|
file_size = clip_file.stat().st_size / 1_000_000
|
||||||
|
print(f" Uploading video ({file_size:.1f} MB)...")
|
||||||
|
|
||||||
|
response = None
|
||||||
|
retry = 0
|
||||||
|
while response is None:
|
||||||
|
try:
|
||||||
|
status, response = request.next_chunk()
|
||||||
|
if status:
|
||||||
|
print(f" Upload {int(status.progress() * 100)}%...")
|
||||||
|
except HttpError as e:
|
||||||
|
if e.resp.status in (500, 502, 503, 504) and retry < 5:
|
||||||
|
retry += 1
|
||||||
|
wait = random.random() * (2 ** retry)
|
||||||
|
print(f" Retrying in {wait:.1f}s...")
|
||||||
|
time.sleep(wait)
|
||||||
|
else:
|
||||||
|
print(f" YouTube API error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
video_id = response["id"]
|
||||||
|
print(f" https://youtube.com/shorts/{video_id}")
|
||||||
|
return video_id
|
||||||
|
|
||||||
|
|
||||||
|
def create_post(integration_id: str, content: str, media: dict,
|
||||||
|
settings: dict, schedule: str | None = None) -> dict:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
post_type = "schedule" if schedule else "now"
|
||||||
|
date = schedule or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
payload = {
|
||||||
|
"type": post_type,
|
||||||
|
"date": date,
|
||||||
|
"shortLink": False,
|
||||||
|
"tags": [],
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"integration": {"id": integration_id},
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"content": content,
|
||||||
|
"image": [media] if media else [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": settings,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/posts"),
|
||||||
|
headers=api_headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
print(f"Post creation failed: {resp.status_code} {resp.text[:300]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
valid_names = sorted(set(PLATFORM_ALIASES.keys()))
|
||||||
|
parser = argparse.ArgumentParser(description="Upload podcast clips to social media via Postiz")
|
||||||
|
parser.add_argument("clips_dir", nargs="?", help="Path to clips directory (e.g. clips/episode-12/). If omitted, shows a picker.")
|
||||||
|
parser.add_argument("--clip", "-c", type=int, help="Upload only clip N (1-indexed)")
|
||||||
|
parser.add_argument("--platforms", "-p",
|
||||||
|
help=f"Comma-separated platforms ({','.join(ALL_PLATFORMS)}). Default: all")
|
||||||
|
parser.add_argument("--schedule", "-s", help="Schedule time (ISO 8601, e.g. 2026-02-16T10:00:00)")
|
||||||
|
parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Show what would be uploaded without posting")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not POSTIZ_API_KEY:
|
||||||
|
print("Error: POSTIZ_API_KEY not set in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Resolve clips directory — pick interactively if not provided
|
||||||
|
if args.clips_dir:
|
||||||
|
clips_dir = Path(args.clips_dir).expanduser().resolve()
|
||||||
|
else:
|
||||||
|
clips_root = Path(__file__).parent / "clips"
|
||||||
|
episode_dirs = sorted(
|
||||||
|
[d for d in clips_root.iterdir()
|
||||||
|
if d.is_dir() and not d.name.startswith(".") and (d / "clips-metadata.json").exists()],
|
||||||
|
key=lambda d: (int(m.group(1)) if (m := re.search(r'(\d+)', d.name)) else 0, d.name),
|
||||||
|
)
|
||||||
|
if not episode_dirs:
|
||||||
|
print("No clip directories found in clips/. Run make_clips.py first.")
|
||||||
|
sys.exit(1)
|
||||||
|
print("\nAvailable episodes:\n")
|
||||||
|
for i, d in enumerate(episode_dirs):
|
||||||
|
with open(d / "clips-metadata.json") as f:
|
||||||
|
meta = json.load(f)
|
||||||
|
print(f" {i+1}. {d.name} ({len(meta)} clip{'s' if len(meta) != 1 else ''})")
|
||||||
|
print()
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = input("Which episode? ").strip()
|
||||||
|
idx = int(choice) - 1
|
||||||
|
if 0 <= idx < len(episode_dirs):
|
||||||
|
clips_dir = episode_dirs[idx]
|
||||||
|
break
|
||||||
|
print(f" Enter 1-{len(episode_dirs)}")
|
||||||
|
except (ValueError, EOFError):
|
||||||
|
print(f" Enter an episode number")
|
||||||
|
|
||||||
|
metadata_path = clips_dir / "clips-metadata.json"
|
||||||
|
if not metadata_path.exists():
|
||||||
|
print(f"Error: No clips-metadata.json found in {clips_dir}")
|
||||||
|
print("Run make_clips.py first to generate clips and metadata.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(metadata_path) as f:
|
||||||
|
clips = json.load(f)
|
||||||
|
|
||||||
|
# Pick clips
|
||||||
|
if args.clip:
|
||||||
|
if args.clip < 1 or args.clip > len(clips):
|
||||||
|
print(f"Error: Clip {args.clip} not found (have {len(clips)} clips)")
|
||||||
|
sys.exit(1)
|
||||||
|
clips = [clips[args.clip - 1]]
|
||||||
|
elif not args.yes:
|
||||||
|
print(f"\nFound {len(clips)} clip(s):\n")
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
desc = clip.get('description', clip.get('caption_text', ''))
|
||||||
|
if len(desc) > 70:
|
||||||
|
desc = desc[:desc.rfind(' ', 0, 70)] + '...'
|
||||||
|
print(f" {i+1}. \"{clip['title']}\" ({clip['duration']:.0f}s)")
|
||||||
|
print(f" {desc}")
|
||||||
|
print(f"\n a. All clips")
|
||||||
|
print()
|
||||||
|
while True:
|
||||||
|
choice = input("Which clips? (e.g. 1,3 or a for all): ").strip().lower()
|
||||||
|
if choice in ('a', 'all'):
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
indices = [int(x.strip()) for x in choice.split(",")]
|
||||||
|
if all(1 <= x <= len(clips) for x in indices):
|
||||||
|
clips = [clips[x - 1] for x in indices]
|
||||||
|
break
|
||||||
|
print(f" Invalid selection. Enter 1-{len(clips)}, comma-separated, or 'a' for all.")
|
||||||
|
except (ValueError, EOFError):
|
||||||
|
print(f" Enter clip numbers (e.g. 1,3) or 'a' for all")
|
||||||
|
|
||||||
|
# Pick platforms
|
||||||
|
if args.platforms:
|
||||||
|
requested = []
|
||||||
|
for p in args.platforms.split(","):
|
||||||
|
p = p.strip().lower()
|
||||||
|
if p not in PLATFORM_ALIASES:
|
||||||
|
print(f"Unknown platform: {p}")
|
||||||
|
print(f"Valid: {', '.join(valid_names)}")
|
||||||
|
sys.exit(1)
|
||||||
|
requested.append(PLATFORM_ALIASES[p])
|
||||||
|
target_platforms = list(dict.fromkeys(requested))
|
||||||
|
elif not args.yes:
|
||||||
|
print(f"\nPlatforms:\n")
|
||||||
|
for i, p in enumerate(ALL_PLATFORMS):
|
||||||
|
print(f" {i+1}. {PLATFORM_DISPLAY[p]}")
|
||||||
|
print(f"\n a. All platforms (default)")
|
||||||
|
print()
|
||||||
|
choice = input("Which platforms? (e.g. 1,3,5 or a for all) [a]: ").strip().lower()
|
||||||
|
if choice and choice not in ('a', 'all'):
|
||||||
|
try:
|
||||||
|
indices = [int(x.strip()) for x in choice.split(",")]
|
||||||
|
target_platforms = [ALL_PLATFORMS[x - 1] for x in indices if 1 <= x <= len(ALL_PLATFORMS)]
|
||||||
|
if not target_platforms:
|
||||||
|
target_platforms = ALL_PLATFORMS[:]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
target_platforms = ALL_PLATFORMS[:]
|
||||||
|
else:
|
||||||
|
target_platforms = ALL_PLATFORMS[:]
|
||||||
|
else:
|
||||||
|
target_platforms = ALL_PLATFORMS[:]
|
||||||
|
|
||||||
|
DIRECT_PLATFORMS = {"bluesky", "youtube"}
|
||||||
|
needs_postiz = not args.dry_run and any(
|
||||||
|
p not in DIRECT_PLATFORMS for p in target_platforms)
|
||||||
|
if needs_postiz:
|
||||||
|
print("Fetching connected accounts from Postiz...")
|
||||||
|
integrations = fetch_integrations()
|
||||||
|
else:
|
||||||
|
integrations = []
|
||||||
|
|
||||||
|
active_platforms = {}
|
||||||
|
for platform in target_platforms:
|
||||||
|
if platform == "bluesky":
|
||||||
|
if BSKY_APP_PASSWORD or args.dry_run:
|
||||||
|
active_platforms[platform] = {"name": BSKY_HANDLE, "_direct": True}
|
||||||
|
else:
|
||||||
|
print("Warning: BSKY_APP_PASSWORD not set in .env, skipping Bluesky")
|
||||||
|
continue
|
||||||
|
if platform == "youtube":
|
||||||
|
if YT_CLIENT_SECRETS.exists() or YT_TOKEN_FILE.exists() or args.dry_run:
|
||||||
|
active_platforms[platform] = {"name": "YouTube Shorts", "_direct": True}
|
||||||
|
else:
|
||||||
|
print("Warning: youtube_client_secrets.json not found, skipping YouTube")
|
||||||
|
continue
|
||||||
|
if args.dry_run:
|
||||||
|
active_platforms[platform] = {"name": PLATFORM_DISPLAY[platform]}
|
||||||
|
continue
|
||||||
|
integ = find_integration(integrations, platform)
|
||||||
|
if integ:
|
||||||
|
active_platforms[platform] = integ
|
||||||
|
else:
|
||||||
|
print(f"Warning: No {PLATFORM_DISPLAY[platform]} account connected in Postiz")
|
||||||
|
|
||||||
|
if not args.dry_run and not active_platforms:
|
||||||
|
print("Error: No platforms available to upload to")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
platform_names = [f"{PLATFORM_DISPLAY[p]} ({integ.get('name', 'connected')})"
|
||||||
|
for p, integ in active_platforms.items()]
|
||||||
|
|
||||||
|
print(f"\nUploading {len(clips)} clip(s) to: {', '.join(platform_names)}")
|
||||||
|
if args.schedule:
|
||||||
|
print(f"Scheduled for: {args.schedule}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
print(f" {i+1}. \"{clip['title']}\" ({clip['duration']:.0f}s)")
|
||||||
|
desc = clip.get('description', '')
|
||||||
|
if len(desc) > 80:
|
||||||
|
desc = desc[:desc.rfind(' ', 0, 80)] + '...'
|
||||||
|
print(f" {desc}")
|
||||||
|
print(f" {' '.join(clip.get('hashtags', []))}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("Dry run — nothing uploaded.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.yes:
|
||||||
|
confirm = input("Proceed? [y/N] ").strip().lower()
|
||||||
|
if confirm != "y":
|
||||||
|
print("Cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
upload_history = load_upload_history(clips_dir)
|
||||||
|
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
clip_file = clips_dir / clip["clip_file"]
|
||||||
|
if not clip_file.exists():
|
||||||
|
print(f" Clip {i+1}: Video file not found: {clip_file}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
clip_key = clip["clip_file"]
|
||||||
|
already_uploaded = set(upload_history.get(clip_key, []))
|
||||||
|
remaining_platforms = {p: integ for p, integ in active_platforms.items()
|
||||||
|
if p not in already_uploaded}
|
||||||
|
|
||||||
|
if not remaining_platforms:
|
||||||
|
print(f"\n Clip {i+1}: \"{clip['title']}\" — already uploaded to all selected platforms, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
skipped = already_uploaded & set(active_platforms.keys())
|
||||||
|
if skipped:
|
||||||
|
print(f"\n Clip {i+1}: \"{clip['title']}\" (skipping already uploaded: {', '.join(sorted(skipped))})")
|
||||||
|
else:
|
||||||
|
print(f"\n Clip {i+1}: \"{clip['title']}\"")
|
||||||
|
|
||||||
|
postiz_platforms = {p: integ for p, integ in remaining_platforms.items()
|
||||||
|
if not integ.get("_direct")}
|
||||||
|
|
||||||
|
media = None
|
||||||
|
if postiz_platforms:
|
||||||
|
print(f" Uploading {clip_file.name}...")
|
||||||
|
media = upload_file(clip_file)
|
||||||
|
if not media:
|
||||||
|
print(" Failed to upload video to Postiz, skipping Postiz platforms")
|
||||||
|
postiz_platforms = {}
|
||||||
|
else:
|
||||||
|
print(f" Uploaded: {media.get('path', 'ok')}")
|
||||||
|
|
||||||
|
for platform, integ in postiz_platforms.items():
|
||||||
|
display = PLATFORM_DISPLAY[platform]
|
||||||
|
print(f" Posting to {display}...")
|
||||||
|
content = build_content(clip, platform)
|
||||||
|
settings = build_settings(clip, platform)
|
||||||
|
result = create_post(integ["id"], content, media, settings, args.schedule)
|
||||||
|
if result:
|
||||||
|
print(f" {display}: Posted!")
|
||||||
|
upload_history.setdefault(clip_key, []).append(platform)
|
||||||
|
save_upload_history(clips_dir, upload_history)
|
||||||
|
else:
|
||||||
|
print(f" {display}: Failed")
|
||||||
|
|
||||||
|
if "youtube" in remaining_platforms:
|
||||||
|
print(f" Posting to YouTube Shorts (direct)...")
|
||||||
|
try:
|
||||||
|
yt_video_id = post_to_youtube(clip, clip_file)
|
||||||
|
if yt_video_id:
|
||||||
|
print(f" YouTube: Posted!")
|
||||||
|
upload_history.setdefault(clip_key, []).append("youtube")
|
||||||
|
save_upload_history(clips_dir, upload_history)
|
||||||
|
else:
|
||||||
|
print(f" YouTube: Failed")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" YouTube: Failed — {e}")
|
||||||
|
|
||||||
|
if "bluesky" in remaining_platforms:
|
||||||
|
print(f" Posting to Bluesky (direct)...")
|
||||||
|
try:
|
||||||
|
if post_to_bluesky(clip, clip_file):
|
||||||
|
print(f" Bluesky: Posted!")
|
||||||
|
upload_history.setdefault(clip_key, []).append("bluesky")
|
||||||
|
save_upload_history(clips_dir, upload_history)
|
||||||
|
else:
|
||||||
|
print(f" Bluesky: Failed")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Bluesky: Failed — {e}")
|
||||||
|
|
||||||
|
# Sync clips to website if any YouTube uploads happened
|
||||||
|
if "youtube" in active_platforms:
|
||||||
|
sync_clips_to_website()
|
||||||
|
|
||||||
|
print("\nDone!")
|
||||||
|
|
||||||
|
|
||||||
|
WEBSITE_DIR = Path(__file__).parent / "website"
|
||||||
|
CLIPS_JSON = WEBSITE_DIR / "data" / "clips.json"
|
||||||
|
THUMBS_DIR = WEBSITE_DIR / "images" / "clips"
|
||||||
|
CLIPS_ROOT = Path(__file__).parent / "clips"
|
||||||
|
|
||||||
|
|
||||||
|
def sync_clips_to_website():
|
||||||
|
"""Rebuild website/data/clips.json from YouTube shorts and deploy."""
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
print("\nSyncing clips to website...")
|
||||||
|
|
||||||
|
# Fetch all YouTube shorts from channel
|
||||||
|
result = subprocess.run(
|
||||||
|
["python3", "-m", "yt_dlp", "--flat-playlist", "--print", "%(id)s\t%(title)s",
|
||||||
|
"https://www.youtube.com/lukemacneil/shorts"],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
if not result.stdout.strip():
|
||||||
|
print(" Could not fetch YouTube shorts, skipping sync")
|
||||||
|
return
|
||||||
|
|
||||||
|
yt_shorts = {}
|
||||||
|
for line in result.stdout.strip().split("\n"):
|
||||||
|
if "\t" not in line:
|
||||||
|
continue
|
||||||
|
vid_id, title = line.split("\t", 1)
|
||||||
|
clean_title = re.sub(r"\s*#Shorts\s*$", "", title).strip().lower()
|
||||||
|
yt_shorts[clean_title] = vid_id
|
||||||
|
|
||||||
|
print(f" Found {len(yt_shorts)} YouTube shorts")
|
||||||
|
|
||||||
|
# Load all clip metadata and match to YouTube
|
||||||
|
existing = {}
|
||||||
|
if CLIPS_JSON.exists():
|
||||||
|
for c in json.loads(CLIPS_JSON.read_text()):
|
||||||
|
existing[c["clip_file"]] = c
|
||||||
|
|
||||||
|
new_clips = []
|
||||||
|
for ep_dir in sorted(CLIPS_ROOT.glob("episode-*")):
|
||||||
|
meta_file = ep_dir / "clips-metadata.json"
|
||||||
|
if not meta_file.exists():
|
||||||
|
continue
|
||||||
|
for clip in json.loads(meta_file.read_text()):
|
||||||
|
clip_title = clip["title"].strip().lower()
|
||||||
|
yt_id = yt_shorts.get(clip_title, "")
|
||||||
|
if not yt_id:
|
||||||
|
for yt_title, yid in yt_shorts.items():
|
||||||
|
if yt_title in clip_title or clip_title in yt_title:
|
||||||
|
yt_id = yid
|
||||||
|
break
|
||||||
|
if not yt_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
prev = existing.get(clip["clip_file"], {})
|
||||||
|
new_clips.append({
|
||||||
|
"title": clip["title"],
|
||||||
|
"description": clip.get("description", clip.get("caption_text", "")),
|
||||||
|
"episode_number": clip.get("episode_number", 0),
|
||||||
|
"clip_file": clip["clip_file"],
|
||||||
|
"youtube_id": yt_id,
|
||||||
|
"featured": prev.get("featured", False),
|
||||||
|
"thumbnail": prev.get("thumbnail", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
new_clips.sort(key=lambda c: c["episode_number"], reverse=True)
|
||||||
|
|
||||||
|
# Generate thumbnails for clips that don't have one
|
||||||
|
THUMBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
for clip in new_clips:
|
||||||
|
if clip["thumbnail"]:
|
||||||
|
thumb_path = WEBSITE_DIR / clip["thumbnail"]
|
||||||
|
if thumb_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
thumb_name = clip["clip_file"].replace(".mp4", ".jpg")
|
||||||
|
thumb_path = THUMBS_DIR / thumb_name
|
||||||
|
ep_num = clip["episode_number"]
|
||||||
|
mp4_path = CLIPS_ROOT / f"episode-{ep_num}" / clip["clip_file"]
|
||||||
|
|
||||||
|
if not mp4_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-i", str(mp4_path), "-ss", "3", "-vframes", "1",
|
||||||
|
"-update", "1", "-vf", "scale=360:-2", "-q:v", "4", str(thumb_path)],
|
||||||
|
capture_output=True, timeout=30,
|
||||||
|
)
|
||||||
|
if thumb_path.exists():
|
||||||
|
clip["thumbnail"] = f"images/clips/{thumb_name}"
|
||||||
|
print(f" Generated thumbnail: {thumb_name}")
|
||||||
|
|
||||||
|
# Ensure at least 3 featured
|
||||||
|
featured_count = sum(1 for c in new_clips if c.get("featured"))
|
||||||
|
if featured_count < 3:
|
||||||
|
for c in new_clips:
|
||||||
|
if not c.get("featured"):
|
||||||
|
c["featured"] = True
|
||||||
|
featured_count += 1
|
||||||
|
if featured_count >= 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
CLIPS_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CLIPS_JSON.write_text(json.dumps(new_clips, indent=2))
|
||||||
|
print(f" Updated clips.json: {len(new_clips)} clips")
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
print(" Deploying website...")
|
||||||
|
deploy = subprocess.run(
|
||||||
|
["npx", "wrangler", "pages", "deploy", "website/",
|
||||||
|
"--project-name=lukeattheroost", "--branch=main", "--commit-dirty=true"],
|
||||||
|
capture_output=True, text=True, timeout=120,
|
||||||
|
cwd=str(Path(__file__).parent),
|
||||||
|
)
|
||||||
|
if "Deployment complete" in deploy.stdout:
|
||||||
|
print(" Website deployed!")
|
||||||
|
else:
|
||||||
|
print(f" Deploy failed: {deploy.stderr[-300:]}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Page Not Found — Luke at the Roost</title>
|
||||||
|
<meta name="description" content="The page you're looking for doesn't exist.">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
|
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="48x48">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="css/style.css?v=5">
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||||
|
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="/" class="site-nav-brand">Luke at the Roost</a>
|
||||||
|
<div class="site-nav-links">
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="/clips">Clips</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
|
||||||
|
<section class="page-header">
|
||||||
|
<h1>404 — Page Not Found</h1>
|
||||||
|
<p class="page-subtitle">Looks like this page wandered off into the desert.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="about-section">
|
||||||
|
<p>The page you're looking for doesn't exist or may have been moved.</p>
|
||||||
|
<p><a href="/">Back to the show</a> · <a href="/clips">Watch clips</a> · <a href="/how-it-works">How it works</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer"></footer>
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/episodes.html /episode 302
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
const VOICEMAIL_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Response>
|
||||||
|
<Say voice="woman">Luke at the Roost is off the air right now. Leave a message after the beep and we may play it on the next show!</Say>
|
||||||
|
<Record maxLength="120" action="https://radioshow.macneilmediagroup.com/api/signalwire/voicemail-complete" playBeep="true" />
|
||||||
|
<Say voice="woman">Thank you for calling. Goodbye!</Say>
|
||||||
|
<Hangup/>
|
||||||
|
</Response>`;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === "/api/signalwire/voice") {
|
||||||
|
try {
|
||||||
|
const body = await request.text();
|
||||||
|
const resp = await fetch("https://radioshow.macneilmediagroup.com/api/signalwire/voice", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: body,
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
return new Response(await resp.text(), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/xml" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Server unreachable or timed out
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(VOICEMAIL_XML, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/xml" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSS feed proxy
|
||||||
|
if (url.pathname === "/feed") {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml", {
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
return new Response(await resp.text(), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/xml",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Cache-Control": "public, max-age=300",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Castopod unreachable
|
||||||
|
}
|
||||||
|
return new Response("Feed unavailable", { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plausible analytics proxy (bypass ad blockers)
|
||||||
|
if (url.pathname === "/p/script") {
|
||||||
|
const resp = await fetch("https://plausible.macneilmediagroup.com/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js");
|
||||||
|
return new Response(await resp.text(), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/javascript",
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/p/event" && request.method === "POST") {
|
||||||
|
const body = await request.text();
|
||||||
|
const resp = await fetch("https://plausible.macneilmediagroup.com/api/event", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": request.headers.get("User-Agent") || "",
|
||||||
|
"X-Forwarded-For": request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For") || "",
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return new Response(resp.body, {
|
||||||
|
status: resp.status,
|
||||||
|
headers: { "Content-Type": resp.headers.get("Content-Type") || "text/plain" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Social crawler meta injection for episode pages
|
||||||
|
if (url.pathname === "/episode.html" && url.searchParams.get("slug")) {
|
||||||
|
const ua = (request.headers.get("User-Agent") || "").toLowerCase();
|
||||||
|
const isCrawler = /facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot/i.test(ua);
|
||||||
|
|
||||||
|
if (isCrawler) {
|
||||||
|
const slug = url.searchParams.get("slug");
|
||||||
|
try {
|
||||||
|
const feedResp = await fetch("https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml", {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (feedResp.ok) {
|
||||||
|
const feedXml = await feedResp.text();
|
||||||
|
const items = feedXml.split("<item>");
|
||||||
|
let title = "";
|
||||||
|
let description = "";
|
||||||
|
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
const linkMatch = item.match(/<link>(.*?)<\/link>/);
|
||||||
|
if (linkMatch) {
|
||||||
|
const itemSlug = linkMatch[1].split("/episodes/").pop()?.replace(/\/$/, "");
|
||||||
|
if (itemSlug === slug) {
|
||||||
|
const titleMatch = item.match(/<title>(.*?)<\/title>/);
|
||||||
|
title = titleMatch ? titleMatch[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim() : "";
|
||||||
|
const descMatch = item.match(/<description>([\s\S]*?)<\/description>/);
|
||||||
|
description = descMatch
|
||||||
|
? descMatch[1].replace(/<!\[CDATA\[|\]\]>/g, "").replace(/<[^>]+>/g, "").trim().slice(0, 200)
|
||||||
|
: "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
const pageResp = await env.ASSETS.fetch(request);
|
||||||
|
let html = await pageResp.text();
|
||||||
|
|
||||||
|
const escTitle = title.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||||
|
const escDesc = description.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||||
|
const canonicalUrl = `https://lukeattheroost.com/episode.html?slug=${slug}`;
|
||||||
|
|
||||||
|
html = html.replace(/<meta property="og:title"[^>]*>/, `<meta property="og:title" content="${escTitle}">`);
|
||||||
|
html = html.replace(/<meta property="og:description"[^>]*>/, `<meta property="og:description" content="${escDesc}">`);
|
||||||
|
html = html.replace(/<meta property="og:url"[^>]*>/, `<meta property="og:url" content="${canonicalUrl}">`);
|
||||||
|
html = html.replace(/<meta name="twitter:title"[^>]*>/, `<meta name="twitter:title" content="${escTitle}">`);
|
||||||
|
html = html.replace(/<meta name="twitter:description"[^>]*>/, `<meta name="twitter:description" content="${escDesc}">`);
|
||||||
|
html = html.replace(/<title[^>]*>.*?<\/title>/, `<title>${escTitle} — Luke at the Roost</title>`);
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/html;charset=UTF-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fall through to static page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other requests — serve static assets
|
||||||
|
return env.ASSETS.fetch(request);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Clips — Luke at the Roost</title>
|
||||||
|
<meta name="description" content="The best moments from Luke at the Roost — watch clips from the AI call-in comedy podcast.">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
|
<link rel="canonical" href="https://lukeattheroost.com/clips">
|
||||||
|
|
||||||
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
|
<meta property="og:title" content="Clips — Luke at the Roost">
|
||||||
|
<meta property="og:description" content="The best moments from Luke at the Roost — watch clips from the AI call-in comedy podcast.">
|
||||||
|
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
|
<meta property="og:url" content="https://lukeattheroost.com/clips">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Clips — Luke at the Roost">
|
||||||
|
<meta name="twitter:description" content="The best moments from Luke at the Roost — watch clips from the AI call-in comedy podcast.">
|
||||||
|
<meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
|
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="48x48">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://lukeattheroost.com" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "Clips" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
||||||
|
<link rel="stylesheet" href="css/style.css?v=5">
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||||
|
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="/" class="site-nav-brand">Luke at the Roost</a>
|
||||||
|
<div class="site-nav-links">
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="/clips" aria-current="page">Clips</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
|
||||||
|
<section class="page-header">
|
||||||
|
<h1>Clips</h1>
|
||||||
|
<p class="page-subtitle">The best moments from the show</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="clips-section-header">
|
||||||
|
<h2>Featured</h2>
|
||||||
|
</div>
|
||||||
|
<section class="clips-featured"></section>
|
||||||
|
|
||||||
|
<div class="clips-section-header">
|
||||||
|
<h2>All Clips</h2>
|
||||||
|
</div>
|
||||||
|
<section class="clips-grid"></section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer"></footer>
|
||||||
|
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
|
<script src="js/clips.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Cult Leader Realizes He's Been Manipulating People",
|
||||||
|
"description": "Cult leader calls in having a full existential crisis about his 'shared intimacy nights' and the manipulation tactics he's been using on his followers.",
|
||||||
|
"episode_number": 37,
|
||||||
|
"clip_file": "clip-3-cult-leader-realizes-he-s-been-manipulating-people.mp4",
|
||||||
|
"youtube_id": "zmCfOQuXtBE",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-3-cult-leader-realizes-he-s-been-manipulating-people.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Intern Pitches Himself Live On Air",
|
||||||
|
"description": "This intern used his first day on the job to shoot his shot with the entire radio audience. The therapy line is sending me.",
|
||||||
|
"episode_number": 36,
|
||||||
|
"clip_file": "clip-1-intern-pitches-himself-live-on-air.mp4",
|
||||||
|
"youtube_id": "exO3_9ewKH0",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-1-intern-pitches-himself-live-on-air.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Wait Until She Dies or Kill Her",
|
||||||
|
"description": "Luke gives the most UNHINGED inheritance advice I've ever heard on live radio. This escalated so fast.",
|
||||||
|
"episode_number": 35,
|
||||||
|
"clip_file": "clip-1-wait-until-she-dies-or-kill-her.mp4",
|
||||||
|
"youtube_id": "03oJoRh-ioo",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-1-wait-until-she-dies-or-kill-her.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Nobody's Potato Salad Is Good",
|
||||||
|
"description": "Luke goes OFF on workplace potlucks: 'Nobody's potato salad is f***ing good, alright? Everything at a potluck is gross. Just take everybody to McDonald's.'",
|
||||||
|
"episode_number": 34,
|
||||||
|
"clip_file": "clip-3-nobody-s-potato-salad-is-good.mp4",
|
||||||
|
"youtube_id": "re7C2woMUrA",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-3-nobody-s-potato-salad-is-good.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Man Obsessed With Dead Nun Loses Wife",
|
||||||
|
"description": "Rodney couldn't stop talking about a dead nun who shared his wife's name. His wife was NOT amused.",
|
||||||
|
"episode_number": 33,
|
||||||
|
"clip_file": "clip-1-man-obsessed-with-dead-nun-loses-wife.mp4",
|
||||||
|
"youtube_id": "zD8CdX7s8us",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-1-man-obsessed-with-dead-nun-loses-wife.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "I Faked Cancer to Skip a Wedding",
|
||||||
|
"description": "A small lie to skip his sister's FOURTH wedding spiraled into a GoFundMe, a pancake breakfast fundraiser, and a $4,700 check. Now he's in too deep.",
|
||||||
|
"episode_number": 32,
|
||||||
|
"clip_file": "clip-1-i-faked-cancer-to-skip-a-wedding.mp4",
|
||||||
|
"youtube_id": "NUkhsPfMx9o",
|
||||||
|
"featured": true,
|
||||||
|
"thumbnail": "images/clips/clip-1-i-faked-cancer-to-skip-a-wedding.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Started a Fight and Can't Stop Reading About Wars",
|
||||||
|
"description": "A caller starts a fight with their partner and spirals into an obsessive deep-dive on historical wars. Luke tries to untangle the connection.",
|
||||||
|
"episode_number": 31,
|
||||||
|
"clip_file": "clip-3-started-a-fight-and-can-t-stop-reading-about-wars.mp4",
|
||||||
|
"youtube_id": "D2iWnSGQeow",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-3-started-a-fight-and-can-t-stop-reading-about-wars.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Latex Fetish Confession Goes Silent",
|
||||||
|
"description": "He finally opened up about his latex fetish to his girlfriend and got 10 seconds of pure silence. Now he's wondering if honesty just cost him everything.",
|
||||||
|
"episode_number": 30,
|
||||||
|
"clip_file": "clip-3-latex-fetish-confession-goes-silent.mp4",
|
||||||
|
"youtube_id": "vFvWUbYacug",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-3-latex-fetish-confession-goes-silent.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Adopted Daughter Might Have Been Stolen",
|
||||||
|
"description": "A lawyer in Guatemala just sent him proof that his adopted daughter might have been stolen from her birth mother. The photo shows the same crooked smile and now everything is falling apart.",
|
||||||
|
"episode_number": 29,
|
||||||
|
"clip_file": "clip-3-adopted-daughter-might-have-been-stolen.mp4",
|
||||||
|
"youtube_id": "hMxldZN3VHw",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-3-adopted-daughter-might-have-been-stolen.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Vinyl vs Digital: The Warmth Debate",
|
||||||
|
"description": "Vinyl collector calls in to absolutely torch the 'warmth' argument. Turns out what you love about records might just be... imperfection.",
|
||||||
|
"episode_number": 28,
|
||||||
|
"clip_file": "clip-2-vinyl-vs-digital-the-warmth-debate.mp4",
|
||||||
|
"youtube_id": "aVmhApa0d2E",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-2-vinyl-vs-digital-the-warmth-debate.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Open Marriage Backfired Spectacularly",
|
||||||
|
"description": "She thought opening up the marriage would spice things up. He's living his best life while she can't even get a text back.",
|
||||||
|
"episode_number": 27,
|
||||||
|
"clip_file": "clip-2-open-marriage-backfired-spectacularly.mp4",
|
||||||
|
"youtube_id": "-K-t7iijfGs",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-2-open-marriage-backfired-spectacularly.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Neighbor's Roomba Breaks Into Kitchen at 2:30 AM",
|
||||||
|
"description": "She woke up at 2:30 AM to find her neighbor Gary's Roomba cleaning her kitchen. Yes, it had Gary's name on it. Yes, it came through the dog door.",
|
||||||
|
"episode_number": 26,
|
||||||
|
"clip_file": "clip-2-neighbor-s-roomba-breaks-into-kitchen-at-2-30-am.mp4",
|
||||||
|
"youtube_id": "J7bfT6jsykA",
|
||||||
|
"featured": true,
|
||||||
|
"thumbnail": "images/clips/clip-2-neighbor-s-roomba-breaks-into-kitchen-at-2-30-am.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "You're a Computer-Generated AI Caller",
|
||||||
|
"description": "The AI caller admits it: 'You're right. I am computer-generated. And you're sitting there alone at midnight talking to me like it's real.' Luke's reaction is everything.",
|
||||||
|
"episode_number": 24,
|
||||||
|
"clip_file": "clip-10-you-re-a-computer-generated-ai-caller.mp4",
|
||||||
|
"youtube_id": "QVcoU59OTRA",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-10-you-re-a-computer-generated-ai-caller.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Full Banana Costume at Work Party",
|
||||||
|
"description": "He showed up to his manager's holiday party in a full banana costume and now HR says it shows 'lack of professional judgment.' Was it really that serious?",
|
||||||
|
"episode_number": 23,
|
||||||
|
"clip_file": "clip-4-full-banana-costume-at-work-party.mp4",
|
||||||
|
"youtube_id": "enyqXHxmzqA",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-4-full-banana-costume-at-work-party.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "The Poison That Saved Everything",
|
||||||
|
"description": "The apocalypse that saved us all. 2.5 billion years ago, oxygen poisoned nearly everything on Earth\u2014and that catastrophe made life as we know it possible.",
|
||||||
|
"episode_number": 22,
|
||||||
|
"clip_file": "clip-1-the-poison-that-saved-everything.mp4",
|
||||||
|
"youtube_id": "BHm3RJ5YC_8",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-1-the-poison-that-saved-everything.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "I Burned My Marriage for Work",
|
||||||
|
"description": "He burned his second marriage to the ground chasing work. His wife didn't leave because of money\u2014she left because he was never there.",
|
||||||
|
"episode_number": 22,
|
||||||
|
"clip_file": "clip-3-i-burned-my-marriage-for-work.mp4",
|
||||||
|
"youtube_id": "1a_9Yl-isN0",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-3-i-burned-my-marriage-for-work.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Shopping Cart Theory: Moral Test or Crazy?",
|
||||||
|
"description": "A caller defends the shopping cart theory by admitting they literally watch people from their workplace. Luke has some thoughts on what's actually crazy here.",
|
||||||
|
"episode_number": 21,
|
||||||
|
"clip_file": "clip-2-shopping-cart-theory-moral-test-or-crazy.mp4",
|
||||||
|
"youtube_id": "KijyJsMZfkA",
|
||||||
|
"featured": true,
|
||||||
|
"thumbnail": "images/clips/clip-2-shopping-cart-theory-moral-test-or-crazy.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Dog Takes a Shit With Leg Up",
|
||||||
|
"description": "This caller's dog just unlocked a new achievement that has him bursting with pride. You won't believe what had him celebrating like his pup won best in show.",
|
||||||
|
"episode_number": 20,
|
||||||
|
"clip_file": "clip-1-dog-takes-a-shit-with-leg-up.mp4",
|
||||||
|
"youtube_id": "SXcCrlQTuwM",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-1-dog-takes-a-shit-with-leg-up.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Don't Get Married PSA",
|
||||||
|
"description": "A brutal take on marriage from someone who learned the hard way. This caller is not holding back with the relationship advice tonight.",
|
||||||
|
"episode_number": 19,
|
||||||
|
"clip_file": "clip-2-don-t-get-married-psa.mp4",
|
||||||
|
"youtube_id": "6pKcYXgzizs",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-2-don-t-get-married-psa.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Signed Into an Illegal Poker Game",
|
||||||
|
"description": "Imagine walking into an illegal poker game and they make you sign a guest book with your real info. The audacity is actually impressive.",
|
||||||
|
"episode_number": 18,
|
||||||
|
"clip_file": "clip-2-signed-into-an-illegal-poker-game.mp4",
|
||||||
|
"youtube_id": "eCT0gUVLlbA",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-2-signed-into-an-illegal-poker-game.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "We're Dinner for a Black Hole",
|
||||||
|
"description": "This caller has a theory that we're all just corn on the cob waiting to be eaten by a distant black hole. Can't stop thinking about it now.",
|
||||||
|
"episode_number": 17,
|
||||||
|
"clip_file": "clip-2-we-re-dinner-for-a-black-hole.mp4",
|
||||||
|
"youtube_id": "lakicW3cbPw",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-2-we-re-dinner-for-a-black-hole.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Maybe You Should Eat More of Her",
|
||||||
|
"description": "Luke doesn't hold back when a caller complains about his long-distance girlfriend not visiting enough. This relationship advice is absolutely WILD and you need to hear it.",
|
||||||
|
"episode_number": 16,
|
||||||
|
"clip_file": "clip-1-maybe-you-should-eat-more-of-her.mp4",
|
||||||
|
"youtube_id": "P5UNb_njsig",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-1-maybe-you-should-eat-more-of-her.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "I Lied About Speaking Spanish for 8 Years",
|
||||||
|
"description": "This crop duster faked speaking Spanish to get hired and somehow kept the lie going for EIGHT YEARS. Now he's got a 3-week contract in Mexico City and he's about to get exposed.",
|
||||||
|
"episode_number": 14,
|
||||||
|
"clip_file": "clip-1-i-lied-about-speaking-spanish-for-8-years.mp4",
|
||||||
|
"youtube_id": "MxDjohJEneQ",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-1-i-lied-about-speaking-spanish-for-8-years.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "You Can't Have It Both Ways",
|
||||||
|
"description": "This caller goes OFF on absent parents who use work as an excuse. Just because you're making money on the road doesn't mean you're present in your kid's life.",
|
||||||
|
"episode_number": 13,
|
||||||
|
"clip_file": "clip-2-you-can-t-have-it-both-ways.mp4",
|
||||||
|
"youtube_id": "Or62RF0uJQM",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-2-you-can-t-have-it-both-ways.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Pretending to Buy Houses for 8 Months",
|
||||||
|
"description": "For 8 months, this caller has been attending open houses every weekend pretending to be different people. They're not buying... they're just living fake lives for fun.",
|
||||||
|
"episode_number": 13,
|
||||||
|
"clip_file": "clip-3-pretending-to-buy-houses-for-8-months.mp4",
|
||||||
|
"youtube_id": "VSWknaHx7d0",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-3-pretending-to-buy-houses-for-8-months.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "No More Thursdays in Deming",
|
||||||
|
"description": "Rita thought she was being sneaky meeting her ex every Thursday at a Deming motel... until her husband started tracking the mileage. Luke's response? Absolutely brutal.",
|
||||||
|
"episode_number": 11,
|
||||||
|
"clip_file": "clip-1-no-more-thursdays-in-deming.mp4",
|
||||||
|
"youtube_id": "weeDjSi7vuc",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-1-no-more-thursdays-in-deming.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Cemetery Widow Stalker",
|
||||||
|
"description": "A cemetery groundskeeper thought a widow was mourning her husband... until he realized she keeps showing up at midnight asking his COWORKERS about him. This one gets creepy fast.",
|
||||||
|
"episode_number": 8,
|
||||||
|
"clip_file": "clip-2-cemetery-widow-stalker.mp4",
|
||||||
|
"youtube_id": "dAiM1WT5-_A",
|
||||||
|
"featured": false,
|
||||||
|
"thumbnail": "images/clips/clip-2-cemetery-widow-stalker.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
|
||||||
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
||||||
<link rel="stylesheet" href="css/style.css?v=2">
|
<link rel="stylesheet" href="css/style.css?v=5">
|
||||||
|
|
||||||
<!-- Structured Data (dynamically updated by JS) -->
|
<!-- Structured Data (dynamically updated by JS) -->
|
||||||
<script type="application/ld+json" id="episode-jsonld">
|
<script type="application/ld+json" id="episode-jsonld">
|
||||||
@@ -48,14 +48,24 @@
|
|||||||
"inLanguage": "en"
|
"inLanguage": "en"
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Nav -->
|
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||||
<nav class="page-nav">
|
|
||||||
<a href="/" class="nav-home">← Luke at the Roost</a>
|
<nav class="site-nav">
|
||||||
|
<a href="/" class="site-nav-brand">Luke at the Roost</a>
|
||||||
|
<div class="site-nav-links">
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="/clips">Clips</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
|
||||||
<!-- Episode Header -->
|
<!-- Episode Header -->
|
||||||
<section class="ep-header" id="ep-header">
|
<section class="ep-header" id="ep-header">
|
||||||
<div class="ep-header-inner">
|
<div class="ep-header-inner">
|
||||||
@@ -79,38 +89,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
<noscript>
|
<noscript>
|
||||||
<section class="transcript-section">
|
<section class="transcript-section">
|
||||||
<p>This page requires JavaScript to load the episode transcript. Please enable JavaScript or listen on <a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF">Spotify</a>, <a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848">Apple Podcasts</a>, or <a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-">YouTube</a>.</p>
|
<p>This page requires JavaScript to load the episode transcript. Please enable JavaScript or listen on <a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF">Spotify</a>, <a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848">Apple Podcasts</a>, or <a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-">YouTube</a>.</p>
|
||||||
</section>
|
</section>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<!-- Footer -->
|
<footer class="footer"></footer>
|
||||||
<footer class="footer">
|
|
||||||
<div class="footer-links">
|
|
||||||
<a href="/">Home</a>
|
|
||||||
<a href="/how-it-works">How It Works</a>
|
|
||||||
<a href="/stats">Stats</a>
|
|
||||||
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener">Discord</a>
|
|
||||||
<a href="https://www.facebook.com/profile.php?id=61588191627949" target="_blank" rel="noopener">Facebook</a>
|
|
||||||
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener">X</a>
|
|
||||||
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener">Bluesky</a>
|
|
||||||
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener">Mastodon</a>
|
|
||||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
|
|
||||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
|
|
||||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
|
|
||||||
</div>
|
|
||||||
<div class="footer-projects">
|
|
||||||
<span class="footer-projects-label">More from Luke</span>
|
|
||||||
<div class="footer-projects-links">
|
|
||||||
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
|
|
||||||
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
|
|
||||||
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
|
||||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a></p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Sticky Audio Player -->
|
<!-- Sticky Audio Player -->
|
||||||
<div class="sticky-player" id="sticky-player">
|
<div class="sticky-player" id="sticky-player">
|
||||||
@@ -122,7 +109,7 @@
|
|||||||
<div class="player-info">
|
<div class="player-info">
|
||||||
<div class="player-title" id="player-title">—</div>
|
<div class="player-title" id="player-title">—</div>
|
||||||
<div class="player-progress-row">
|
<div class="player-progress-row">
|
||||||
<div class="player-progress" id="player-progress">
|
<div class="player-progress" id="player-progress" role="slider" aria-label="Audio progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
|
||||||
<div class="player-progress-fill" id="player-progress-fill"></div>
|
<div class="player-progress-fill" id="player-progress-fill"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
||||||
@@ -133,194 +120,8 @@
|
|||||||
|
|
||||||
<audio id="audio-element" preload="none"></audio>
|
<audio id="audio-element" preload="none"></audio>
|
||||||
|
|
||||||
<script>
|
<script src="js/footer.js"></script>
|
||||||
const FEED_URL = '/feed';
|
<script src="js/player.js"></script>
|
||||||
const CDN_BASE = 'https://cdn.lukeattheroost.com';
|
<script src="js/episode.js"></script>
|
||||||
|
|
||||||
const audio = document.getElementById('audio-element');
|
|
||||||
const stickyPlayer = document.getElementById('sticky-player');
|
|
||||||
const playerPlayBtn = document.getElementById('player-play-btn');
|
|
||||||
const playerTitle = document.getElementById('player-title');
|
|
||||||
const playerProgress = document.getElementById('player-progress');
|
|
||||||
const playerProgressFill = document.getElementById('player-progress-fill');
|
|
||||||
const playerTime = document.getElementById('player-time');
|
|
||||||
|
|
||||||
function formatTime(seconds) {
|
|
||||||
if (!seconds || isNaN(seconds)) return '0:00';
|
|
||||||
const s = Math.floor(seconds);
|
|
||||||
const h = Math.floor(s / 3600);
|
|
||||||
const m = Math.floor((s % 3600) / 60);
|
|
||||||
const sec = s % 60;
|
|
||||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
|
||||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDuration(raw) {
|
|
||||||
if (!raw) return '';
|
|
||||||
if (raw.includes(':')) {
|
|
||||||
const parts = raw.split(':').map(Number);
|
|
||||||
let t = 0;
|
|
||||||
if (parts.length === 3) t = parts[0]*3600 + parts[1]*60 + parts[2];
|
|
||||||
else if (parts.length === 2) t = parts[0]*60 + parts[1];
|
|
||||||
return `${Math.round(t/60)} min`;
|
|
||||||
}
|
|
||||||
const sec = parseInt(raw, 10);
|
|
||||||
return isNaN(sec) ? '' : `${Math.round(sec/60)} min`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripHtml(html) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.innerHTML = html || '';
|
|
||||||
return div.textContent || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get slug from URL
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const slug = params.get('slug');
|
|
||||||
|
|
||||||
if (!slug) {
|
|
||||||
document.getElementById('ep-title').textContent = 'Episode not found';
|
|
||||||
document.getElementById('transcript-body').innerHTML = '<p>No episode specified. <a href="/">Go back to episodes.</a></p>';
|
|
||||||
} else {
|
|
||||||
loadEpisode(slug);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEpisode(slug) {
|
|
||||||
// Fetch episode info from RSS
|
|
||||||
try {
|
|
||||||
const res = await fetch(FEED_URL);
|
|
||||||
const xml = await res.text();
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const doc = parser.parseFromString(xml, 'text/xml');
|
|
||||||
const items = doc.querySelectorAll('item');
|
|
||||||
|
|
||||||
let episode = null;
|
|
||||||
for (const item of items) {
|
|
||||||
const link = item.querySelector('link')?.textContent || '';
|
|
||||||
const itemSlug = link.split('/episodes/').pop()?.replace(/\/$/, '');
|
|
||||||
if (itemSlug === slug) {
|
|
||||||
episode = {
|
|
||||||
title: item.querySelector('title')?.textContent || 'Untitled',
|
|
||||||
description: item.querySelector('description')?.textContent || '',
|
|
||||||
audioUrl: item.querySelector('enclosure')?.getAttribute('url') || '',
|
|
||||||
pubDate: item.querySelector('pubDate')?.textContent || '',
|
|
||||||
duration: item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'duration')[0]?.textContent || '',
|
|
||||||
episodeNum: item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'episode')[0]?.textContent || '',
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!episode) {
|
|
||||||
document.getElementById('ep-title').textContent = 'Episode not found';
|
|
||||||
document.getElementById('transcript-body').innerHTML = '<p>Could not find this episode. <a href="/">Go back to episodes.</a></p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate header
|
|
||||||
const metaParts = [
|
|
||||||
episode.episodeNum ? `Episode ${episode.episodeNum}` : '',
|
|
||||||
episode.pubDate ? formatDate(episode.pubDate) : '',
|
|
||||||
parseDuration(episode.duration),
|
|
||||||
].filter(Boolean).join(' \u00b7 ');
|
|
||||||
|
|
||||||
document.getElementById('ep-meta').textContent = metaParts;
|
|
||||||
document.getElementById('ep-title').textContent = episode.title;
|
|
||||||
document.getElementById('ep-desc').innerHTML = episode.description || '';
|
|
||||||
|
|
||||||
// Update page meta
|
|
||||||
document.title = `${episode.title} — Luke at the Roost`;
|
|
||||||
document.getElementById('page-description')?.setAttribute('content', `Full transcript of ${episode.title} from Luke at the Roost.`);
|
|
||||||
document.getElementById('og-title')?.setAttribute('content', episode.title);
|
|
||||||
document.getElementById('og-description')?.setAttribute('content', stripHtml(episode.description).slice(0, 200));
|
|
||||||
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) {
|
|
||||||
const playBtn = document.getElementById('ep-play-btn');
|
|
||||||
playBtn.style.display = 'inline-flex';
|
|
||||||
playBtn.addEventListener('click', () => {
|
|
||||||
audio.src = episode.audioUrl;
|
|
||||||
audio.play();
|
|
||||||
playerTitle.textContent = episode.title;
|
|
||||||
stickyPlayer.classList.add('active');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('ep-title').textContent = 'Error loading episode';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch transcript
|
|
||||||
try {
|
|
||||||
const txRes = await fetch(`/transcripts/${slug}.txt`);
|
|
||||||
if (!txRes.ok) throw new Error('Not found');
|
|
||||||
const text = await txRes.text();
|
|
||||||
const paragraphs = text.split(/\n\n+/).filter(Boolean);
|
|
||||||
const html = paragraphs.map(p => {
|
|
||||||
// Style speaker labels (LUKE:, REGGIE:, etc.)
|
|
||||||
const labeled = p.replace(/^([A-Z][A-Z\s'-]+?):\s*/, '<span class="speaker-label">$1:</span> ');
|
|
||||||
return `<p>${labeled.replace(/\n/g, '<br>')}</p>`;
|
|
||||||
}).join('');
|
|
||||||
document.getElementById('transcript-body').innerHTML = html;
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('transcript-body').innerHTML = '<p class="transcript-unavailable">Transcript not yet available for this episode.</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio player controls
|
|
||||||
audio.addEventListener('play', () => updatePlayIcons(true));
|
|
||||||
audio.addEventListener('pause', () => updatePlayIcons(false));
|
|
||||||
audio.addEventListener('ended', () => updatePlayIcons(false));
|
|
||||||
audio.addEventListener('timeupdate', () => {
|
|
||||||
if (audio.duration) {
|
|
||||||
playerProgressFill.style.width = (audio.currentTime / audio.duration * 100) + '%';
|
|
||||||
playerTime.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function updatePlayIcons(playing) {
|
|
||||||
const iconPlay = playerPlayBtn.querySelector('.icon-play');
|
|
||||||
const iconPause = playerPlayBtn.querySelector('.icon-pause');
|
|
||||||
if (iconPlay) iconPlay.style.display = playing ? 'none' : 'block';
|
|
||||||
if (iconPause) iconPause.style.display = playing ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
playerPlayBtn.addEventListener('click', () => {
|
|
||||||
if (audio.src) { audio.paused ? audio.play() : audio.pause(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
playerProgress.addEventListener('click', (e) => {
|
|
||||||
if (audio.duration) {
|
|
||||||
const rect = playerProgress.getBoundingClientRect();
|
|
||||||
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>How It Works — Luke at the Roost</title>
|
<title>How It Works — Luke at the Roost</title>
|
||||||
<meta name="description" content="How Luke at the Roost works: AI-generated callers with unique personalities, real phone calls, voice synthesis, multi-stem recording, and automated post-production.">
|
<meta name="description" content="How Luke at the Roost works: AI-generated callers with structured personalities, comedy-tuned call shapes, a live research intern, voice-personality matching, multi-stem recording, and automated post-production.">
|
||||||
<meta name="theme-color" content="#1a1209">
|
<meta name="theme-color" content="#1a1209">
|
||||||
<link rel="canonical" href="https://lukeattheroost.com/how-it-works">
|
<link rel="canonical" href="https://lukeattheroost.com/how-it-works">
|
||||||
|
|
||||||
@@ -28,11 +28,11 @@
|
|||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
|
||||||
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
||||||
<link rel="stylesheet" href="css/style.css?v=2">
|
<link rel="stylesheet" href="css/style.css?v=5">
|
||||||
|
|
||||||
<!-- Structured Data -->
|
<!-- Structured Data -->
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{
|
[{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "TechArticle",
|
"@type": "TechArticle",
|
||||||
"headline": "How Luke at the Roost Works",
|
"headline": "How Luke at the Roost Works",
|
||||||
@@ -53,22 +53,164 @@
|
|||||||
"url": "https://lukeattheroost.com"
|
"url": "https://lukeattheroost.com"
|
||||||
},
|
},
|
||||||
"inLanguage": "en"
|
"inLanguage": "en"
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://lukeattheroost.com" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "How It Works" }
|
||||||
|
]
|
||||||
|
}]
|
||||||
</script>
|
</script>
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Nav -->
|
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||||
<nav class="page-nav">
|
|
||||||
<a href="/" class="nav-home">Luke at the Roost</a>
|
<nav class="site-nav">
|
||||||
|
<a href="/" class="site-nav-brand">Luke at the Roost</a>
|
||||||
|
<div class="site-nav-links">
|
||||||
|
<a href="/how-it-works" aria-current="page">How It Works</a>
|
||||||
|
<a href="/clips">Clips</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<section class="page-header">
|
<section class="page-header">
|
||||||
<h1>How It Works</h1>
|
<h1>How It Works</h1>
|
||||||
<p class="page-subtitle">Every caller on the show is a one-of-a-kind character — generated in real time by a custom-built AI system. Here's a peek behind the curtain.</p>
|
<p class="page-subtitle">Every caller on the show is a one-of-a-kind character — generated in real time by a custom-built AI system. Here's a peek behind the curtain.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<section class="hiw-section">
|
||||||
|
<h2>The Anatomy of an AI Caller</h2>
|
||||||
|
|
||||||
|
<div class="hiw-steps">
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">1</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<h3>A Person Is Born</h3>
|
||||||
|
<p>Every caller starts as a blank slate. The system generates a complete identity: name, age, job, hometown, and personality. Each caller gets a unique speaking style — some ramble, some are blunt, some deflect with humor. They have relationships, vehicles, strong food opinions, nostalgic memories, and reasons for being up this late. They know what they were watching on TV, what errand they ran today, and what song was on the radio before they called.</p>
|
||||||
|
<p>But it goes deeper than backstory. Every caller is built with a structured call shape — maybe an escalating reveal where they start casual and drop a bombshell halfway through, a bait-and-switch where the real issue isn't what they said at first, or a slow burn that builds to an emotional peak. They have energy levels, emotional states, and signature details — a phrase they keep coming back to, a nervous tic in how they talk, a specific detail that makes the whole thing feel real. And each caller is matched to a voice that fits their personality. A 60-year-old trucker from Lordsburg doesn't sound like a 23-year-old barista from Tucson.</p>
|
||||||
|
<p>Some callers become regulars. The system tracks returning callers across episodes — they remember past conversations, reference things they talked about before, and their stories evolve over time. You'll hear Leon check in about going back to school, or Shaniqua update you on her situation at work. They're not reset between shows.</p>
|
||||||
|
<p>And some callers are drunk, high, or flat-out unhinged. They'll call with conspiracy theories about pigeons being government drones, existential crises about whether fish know they're wet, or to confess they accidentally set their kitchen on fire trying to make grilled cheese at 3 AM.</p>
|
||||||
|
<div class="hiw-detail-grid">
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Unique Names</span>
|
||||||
|
<span class="hiw-detail-value">160</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Voice Profiles</span>
|
||||||
|
<span class="hiw-detail-value">68</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Call Shapes</span>
|
||||||
|
<span class="hiw-detail-value">8 types</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Returning Regulars</span>
|
||||||
|
<span class="hiw-detail-value">12 callers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">2</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<h3>They Know Their World</h3>
|
||||||
|
<p>Callers know real facts about where they live — the restaurants, the highways, the local gossip. The system has deep knowledge of 55 real towns across New Mexico and Arizona. When a caller says they're from Lordsburg, they actually know about the Shakespeare ghost town and the drive to Deming. They know the current weather outside their window, what day of the week it is, whether it's monsoon season or chile harvest. They have strong opinions about where to get the best green chile and get nostalgic about how their town used to be. The system also pulls in real-time news so callers can reference things that actually happened today.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">3</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<h3>They Have a Reason to Call</h3>
|
||||||
|
<p>Some callers have a problem — a fight with a neighbor, a situation at work, something weighing on them at 2 AM. Others call to geek out about Severance, argue about poker strategy, or share something they read about quantum physics. The system draws from over 1,000 unique calling reasons across dozens of categories — problems, stories, advice-seeking, gossip, and deep-dive topics. Every caller has a purpose, not just a script.</p>
|
||||||
|
<p>The whole thing is tuned for comedy. Not "AI tries to be funny" comedy — more like the energy of late-night call-in radio meets stand-up meets the kind of confessions you only hear at 2 AM. Some calls are genuinely heartfelt. Some are absurd. Some start serious and go completely sideways. The system knows how to build a call for comedic timing — when to hold back a detail, when to escalate, when to let the awkward silence do the work. It's not random chaos; it's structured chaos.</p>
|
||||||
|
<div class="hiw-split-stat">
|
||||||
|
<div class="hiw-stat">
|
||||||
|
<span class="hiw-stat-number">70%</span>
|
||||||
|
<span class="hiw-stat-label">Need advice</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-stat">
|
||||||
|
<span class="hiw-stat-number">30%</span>
|
||||||
|
<span class="hiw-stat-label">Want to talk about something</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">4</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<h3>The Conversation Is Real</h3>
|
||||||
|
<p>Luke talks to each caller using push-to-talk, just like a real radio show. His voice is transcribed in real time, sent to an AI that responds in character, and then converted to speech using a voice engine — all in a few seconds. The AI doesn't just answer questions; it reacts, gets emotional, goes on tangents, and remembers what was said earlier in the show.</p>
|
||||||
|
<p>Callers don't just exist in isolation — the show tracks what's been discussed and matches callers thematically. If someone just called about a messy divorce, the next caller who references marriage didn't pick that topic randomly. The system scores previous callers by topic overlap and decides whether the new caller should reference them, disagree with them, or build on what they said. It tracks the show's overall energy so the pacing doesn't flatline — a heavy emotional call might be followed by something lighter, and vice versa.</p>
|
||||||
|
<p>And when a call has run its course, Luke can hit "Wrap It Up" — a signal that tells the caller to wind things down gracefully. Instead of an abrupt hang-up, the caller gets the hint and starts wrapping up their thought, says their goodbyes, and exits naturally. Just like a real radio host giving the "time's up" hand signal through the glass.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">5</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<h3>Real Callers Call In Too</h3>
|
||||||
|
<p>When you dial 208-439-LUKE, your call goes into a live queue. Luke sees you waiting and can take your call right from the control room. Your voice streams in real time — no pre-recording, no delay. You're live on the show, talking to Luke, and the AI callers might even react to what you said. And if Luke isn't live, you can leave a voicemail — it gets transcribed and may get played on a future episode.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">6</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<h3>Listener Emails</h3>
|
||||||
|
<p>Listeners can send emails to <a href="mailto:submissions@lukeattheroost.com" style="color:var(--accent)">submissions@lukeattheroost.com</a> and have them read on the show. A background poller checks for new messages every 30 seconds — they show up in the control room as soon as they arrive. Luke can read them himself on the mic, or hit a button to have an AI voice read them aloud on the caller channel. It's like a call-in show meets a letters segment — listeners who can't call in can still be part of the conversation.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">7</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<h3>Devon the Intern</h3>
|
||||||
|
<p>Every show needs someone to yell at. Devon is the show's intern — a 23-year-old NMSU grad who's way too eager, occasionally useful, and frequently wrong. He's not a caller; he's a permanent fixture of the show. When Luke needs a fact checked, a topic researched, or someone to blame for a technical issue, Devon's there.</p>
|
||||||
|
<p>Devon has real tools. He can search the web, pull up news headlines, look things up on Wikipedia, and read articles — all live during the show. When a caller claims that octopuses have three hearts, Devon's already looking it up. Sometimes he interjects on his own when he thinks he has something useful to add. Sometimes he's right. Sometimes Luke tells him to shut up. He monitors conversations in the background and pipes up with suggestions that the host can play or dismiss. He's the kind of intern who tries really hard and occasionally nails it.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">8</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<h3>The Control Room</h3>
|
||||||
|
<p>The entire show runs through a custom-built control panel. Luke manages callers, plays music and sound effects, runs ads and station idents, monitors the call queue, and controls everything from one screen. Audio is routed across seven independent channels simultaneously — host mic, AI caller voices, live phone audio, music, sound effects, ads, and station idents all on separate tracks. The website shows a live on-air indicator so listeners know when to call in.</p>
|
||||||
|
<div class="hiw-detail-grid">
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Audio Channels</span>
|
||||||
|
<span class="hiw-detail-value">7 independent</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Caller Slots</span>
|
||||||
|
<span class="hiw-detail-value">10 per session</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Phone System</span>
|
||||||
|
<span class="hiw-detail-value">VoIP + WebSocket</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Live Status</span>
|
||||||
|
<span class="hiw-detail-value">Real-time CDN</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Overview -->
|
<!-- Overview -->
|
||||||
<section class="hiw-section">
|
<section class="hiw-section">
|
||||||
<div class="hiw-card hiw-hero-card">
|
<div class="hiw-card hiw-hero-card">
|
||||||
@@ -94,6 +236,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<span>Real Callers</span>
|
<span>Real Callers</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="16" rx="2"/><path d="M2 6l10 7 10-7"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Voicemails</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Listener Emails</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="diagram-arrow">↓</div>
|
<div class="diagram-arrow">↓</div>
|
||||||
<!-- Row 2: Control Room -->
|
<!-- Row 2: Control Room -->
|
||||||
@@ -132,6 +286,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<span>Audio Router</span>
|
<span>Audio Router</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="diagram-box">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Phone System</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Ad Engine</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Row 4: Recording -->
|
<!-- Row 4: Recording -->
|
||||||
<div class="diagram-row">
|
<div class="diagram-row">
|
||||||
@@ -187,11 +353,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<span>Website</span>
|
<span>Website</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="diagram-box">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="11" rx="2"/><path d="M7 21h10"/><path d="M12 14v7"/><polygon points="10 8 16 11 10 14 10 8"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Social Clips</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Monitoring</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="diagram-arrow">↓</div>
|
<div class="diagram-arrow">↓</div>
|
||||||
<!-- Row 7: Distribution -->
|
<!-- Row 7: Distribution -->
|
||||||
<div class="diagram-label">Distribution</div>
|
<div class="diagram-label">Distribution</div>
|
||||||
<div class="diagram-row diagram-row-split">
|
<div class="diagram-row-compact">
|
||||||
<div class="diagram-box diagram-accent">
|
<div class="diagram-box diagram-accent">
|
||||||
<div class="diagram-icon">
|
<div class="diagram-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
|
||||||
@@ -216,6 +394,54 @@
|
|||||||
</div>
|
</div>
|
||||||
<span>RSS</span>
|
<span>RSS</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm3.1 14.5c-1.7 1-3.8.6-4.8-1.1-1-1.7-.6-3.8 1.1-4.8 1.7-1 3.8-.6 4.8 1.1 1 1.7.5 3.8-1.1 4.8z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Instagram</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Facebook</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.134-1.612 123.121 33.664z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Bluesky</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054 19.648 19.648 0 0 0 4.636.528c.164 0 .329 0 .494-.002 1.694-.042 3.48-.152 5.12-.554 2.21-.543 4.137-2.186 4.348-4.55.162-1.808.21-3.627.142-5.43-.02-.6-.168-1.874-.168-1.874z"/><path d="M19.903 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192zM9.337 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192z" fill="#fff"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Mastodon</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186.31a.27.27 0 0 0-.372 0C8.46 3.487 2.666 9.93 2.666 15.042c0 5.176 4.183 8.958 9.334 8.958s9.334-3.782 9.334-8.958c0-5.112-5.794-11.555-9.148-14.732z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Nostr</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>LinkedIn</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.159 2c-5.543 0-9.94 4.486-9.94 9.94 0 2.16.72 4.148 1.897 5.852l-1.26 4.463 4.612-1.188A9.882 9.882 0 0 0 12.16 22c5.543 0 9.94-4.486 9.94-9.94 0-5.543-4.486-10.06-9.94-10.06z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Threads</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>TikTok</span>
|
||||||
|
</div>
|
||||||
<div class="diagram-box diagram-accent">
|
<div class="diagram-box diagram-accent">
|
||||||
<div class="diagram-icon">
|
<div class="diagram-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg>
|
||||||
@@ -227,122 +453,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Steps -->
|
|
||||||
<section class="hiw-section">
|
|
||||||
<h2>The Anatomy of an AI Caller</h2>
|
|
||||||
|
|
||||||
<div class="hiw-steps">
|
|
||||||
<div class="hiw-step">
|
|
||||||
<div class="hiw-step-number">1</div>
|
|
||||||
<div class="hiw-step-content">
|
|
||||||
<h3>A Person Is Born</h3>
|
|
||||||
<p>Every caller starts as a blank slate. The system generates a complete identity: name, age, job, hometown, and personality. Each caller gets a unique speaking style — some ramble, some are blunt, some deflect with humor. They have relationships, vehicles, strong food opinions, nostalgic memories, and reasons for being up this late. They know what they were watching on TV, what errand they ran today, and what song was on the radio before they called.</p>
|
|
||||||
<p>Some callers become regulars. The system tracks returning callers across episodes — they remember past conversations, reference things they talked about before, and their stories evolve over time. You'll hear Carla update you on her divorce, or Carl check in about his gambling recovery. They're not reset between shows.</p>
|
|
||||||
<div class="hiw-detail-grid">
|
|
||||||
<div class="hiw-detail">
|
|
||||||
<span class="hiw-detail-label">Unique Names</span>
|
|
||||||
<span class="hiw-detail-value">160 names</span>
|
|
||||||
</div>
|
|
||||||
<div class="hiw-detail">
|
|
||||||
<span class="hiw-detail-label">Personality Layers</span>
|
|
||||||
<span class="hiw-detail-value">30+</span>
|
|
||||||
</div>
|
|
||||||
<div class="hiw-detail">
|
|
||||||
<span class="hiw-detail-label">Towns with Real Knowledge</span>
|
|
||||||
<span class="hiw-detail-value">32</span>
|
|
||||||
</div>
|
|
||||||
<div class="hiw-detail">
|
|
||||||
<span class="hiw-detail-label">Returning Regulars</span>
|
|
||||||
<span class="hiw-detail-value">12+ callers</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hiw-step">
|
|
||||||
<div class="hiw-step-number">2</div>
|
|
||||||
<div class="hiw-step-content">
|
|
||||||
<h3>They Know Their World</h3>
|
|
||||||
<p>Callers know real facts about where they live — the restaurants, the highways, the local gossip. When a caller says they're from Lordsburg, they actually know about the Shakespeare ghost town and the drive to Deming. They know the current weather outside their window, what day of the week it is, whether it's monsoon season or chile harvest. They have strong opinions about where to get the best green chile and get nostalgic about how their town used to be. The system also pulls in real-time news so callers can reference things that actually happened today.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hiw-step">
|
|
||||||
<div class="hiw-step-number">3</div>
|
|
||||||
<div class="hiw-step-content">
|
|
||||||
<h3>They Have a Reason to Call</h3>
|
|
||||||
<p>Some callers have a problem — a fight with a neighbor, a situation at work, something weighing on them at 2 AM. Others call to geek out about Severance, argue about poker strategy, or share something they read about quantum physics. Every caller has a purpose, not just a script.</p>
|
|
||||||
<div class="hiw-split-stat">
|
|
||||||
<div class="hiw-stat">
|
|
||||||
<span class="hiw-stat-number">70%</span>
|
|
||||||
<span class="hiw-stat-label">Need advice</span>
|
|
||||||
</div>
|
|
||||||
<div class="hiw-stat">
|
|
||||||
<span class="hiw-stat-number">30%</span>
|
|
||||||
<span class="hiw-stat-label">Want to talk about something</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hiw-step">
|
|
||||||
<div class="hiw-step-number">4</div>
|
|
||||||
<div class="hiw-step-content">
|
|
||||||
<h3>The Conversation Is Real</h3>
|
|
||||||
<p>Luke talks to each caller using push-to-talk, just like a real radio show. His voice is transcribed in real time, sent to an AI that responds in character, and then converted to speech using a voice engine — all in a few seconds. The AI doesn't just answer questions; it reacts, gets emotional, goes on tangents, and remembers what was said earlier in the show. Callers even react to previous callers — "Hey Luke, I heard that guy Tony earlier and I got to say, he's full of it." It makes the show feel like a living community, not isolated calls.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hiw-step">
|
|
||||||
<div class="hiw-step-number">5</div>
|
|
||||||
<div class="hiw-step-content">
|
|
||||||
<h3>Real Callers Call In Too</h3>
|
|
||||||
<p>When you dial 208-439-LUKE, your call goes into a live queue. Luke sees you waiting and can take your call right from the control room. Your voice streams in real time — no pre-recording, no delay. You're live on the show, talking to Luke, and the AI callers might even react to what you said.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hiw-step">
|
|
||||||
<div class="hiw-step-number">6</div>
|
|
||||||
<div class="hiw-step-content">
|
|
||||||
<h3>The Control Room</h3>
|
|
||||||
<p>The entire show runs through a custom-built control panel. Luke manages callers, plays music and sound effects, runs ads, monitors the call queue, and controls everything from one screen. Audio is routed across multiple channels simultaneously — caller voices, music, sound effects, and live phone audio all on separate tracks. The website shows a live on-air indicator so listeners know when to call in.</p>
|
|
||||||
<div class="hiw-detail-grid">
|
|
||||||
<div class="hiw-detail">
|
|
||||||
<span class="hiw-detail-label">Audio Channels</span>
|
|
||||||
<span class="hiw-detail-value">5 independent</span>
|
|
||||||
</div>
|
|
||||||
<div class="hiw-detail">
|
|
||||||
<span class="hiw-detail-label">Caller Slots</span>
|
|
||||||
<span class="hiw-detail-value">10 per session</span>
|
|
||||||
</div>
|
|
||||||
<div class="hiw-detail">
|
|
||||||
<span class="hiw-detail-label">Phone System</span>
|
|
||||||
<span class="hiw-detail-value">VoIP + WebSocket</span>
|
|
||||||
</div>
|
|
||||||
<div class="hiw-detail">
|
|
||||||
<span class="hiw-detail-label">Live Status</span>
|
|
||||||
<span class="hiw-detail-value">Real-time CDN</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Post-Production Pipeline -->
|
<!-- Post-Production Pipeline -->
|
||||||
<section class="hiw-section">
|
<section class="hiw-section">
|
||||||
<h2>From Live Show to Podcast</h2>
|
<h2>From Live Show to Podcast</h2>
|
||||||
|
|
||||||
<div class="hiw-steps">
|
<div class="hiw-steps">
|
||||||
<div class="hiw-step">
|
<div class="hiw-step">
|
||||||
<div class="hiw-step-number">7</div>
|
<div class="hiw-step-number">9</div>
|
||||||
<div class="hiw-step-content">
|
<div class="hiw-step-content">
|
||||||
<h3>Multi-Stem Recording</h3>
|
<h3>Multi-Stem Recording</h3>
|
||||||
<p>During every show, the system records five separate audio stems simultaneously: host microphone, AI caller voices, music, sound effects, and ads. Each stem is captured as an independent WAV file with sample-accurate alignment. This gives full control over the final mix — like having a recording studio's multitrack session, not just a flat recording.</p>
|
<p>During every show, the system records six separate audio stems simultaneously: host microphone, AI caller voices, music, sound effects, ads, and station idents. Each stem is captured as an independent WAV file with sample-accurate alignment. This gives full control over the final mix — like having a recording studio's multitrack session, not just a flat recording.</p>
|
||||||
<div class="hiw-detail-grid">
|
<div class="hiw-detail-grid">
|
||||||
<div class="hiw-detail">
|
<div class="hiw-detail">
|
||||||
<span class="hiw-detail-label">Stems Captured</span>
|
<span class="hiw-detail-label">Stems Captured</span>
|
||||||
<span class="hiw-detail-value">5 parallel</span>
|
<span class="hiw-detail-value">6 parallel</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hiw-detail">
|
<div class="hiw-detail">
|
||||||
<span class="hiw-detail-label">Format</span>
|
<span class="hiw-detail-label">Format</span>
|
||||||
@@ -361,7 +485,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hiw-step">
|
<div class="hiw-step">
|
||||||
<div class="hiw-step-number">8</div>
|
<div class="hiw-step-number">10</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<h3>Dialog Editing in REAPER</h3>
|
||||||
|
<p>Before the automated pipeline runs, the raw stems are loaded into REAPER for dialog editing. A custom Lua script analyzes voice tracks to detect silence gaps — the dead air between caller responses, TTS latency pauses, and gaps where Luke is reading the control room. The script strips these silences and ripple-edits all tracks in sync so ads, idents, and music shift with the dialog cuts. Protected regions marked as ads or idents are preserved — the script knows not to remove silence during an ad break even if the voice tracks are quiet. This tightens a raw two-hour session into a focused episode without cutting any content.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">11</div>
|
||||||
<div class="hiw-step-content">
|
<div class="hiw-step-content">
|
||||||
<h3>Post-Production Pipeline</h3>
|
<h3>Post-Production Pipeline</h3>
|
||||||
<p>Once the show ends, a 15-step automated pipeline processes the raw stems into a broadcast-ready episode. Ads and sound effects are hard-limited to prevent clipping. The host mic gets a high-pass filter, de-essing, and breath reduction. Voice tracks are compressed — the host gets aggressive spoken-word compression for consistent levels, callers get telephone EQ to sound like real phone calls. All stems are level-matched, music is ducked under dialog and muted during ads, then everything is mixed to stereo with panning and width. A bus compressor glues the final mix together before silence trimming, fades, and EBU R128 loudness normalization.</p>
|
<p>Once the show ends, a 15-step automated pipeline processes the raw stems into a broadcast-ready episode. Ads and sound effects are hard-limited to prevent clipping. The host mic gets a high-pass filter, de-essing, and breath reduction. Voice tracks are compressed — the host gets aggressive spoken-word compression for consistent levels, callers get telephone EQ to sound like real phone calls. All stems are level-matched, music is ducked under dialog and muted during ads, then everything is mixed to stereo with panning and width. A bus compressor glues the final mix together before silence trimming, fades, and EBU R128 loudness normalization.</p>
|
||||||
@@ -387,14 +519,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hiw-step">
|
<div class="hiw-step">
|
||||||
<div class="hiw-step-number">9</div>
|
<div class="hiw-step-number">12</div>
|
||||||
<div class="hiw-step-content">
|
<div class="hiw-step-content">
|
||||||
<h3>Automated Publishing</h3>
|
<h3>Automated Publishing</h3>
|
||||||
<p>A single command takes a finished episode and handles everything: the audio is transcribed using speech recognition to generate full-text transcripts, then an LLM analyzes the transcript to write the episode title, description, and chapter markers with timestamps. The episode is uploaded to the podcast server, chapters and transcripts are attached to the metadata, and all media is synced to a global CDN so listeners everywhere get fast downloads.</p>
|
<p>A single command takes a finished episode and handles everything: the audio is transcribed using MLX Whisper running on Apple Silicon GPU to generate full-text transcripts, then an LLM analyzes the transcript to write the episode title, description, and chapter markers with timestamps. The episode is uploaded to the podcast server and directly to YouTube with chapters baked into the description. Chapters and transcripts are attached to the RSS metadata, all media is synced to a global CDN, and social posts are pushed to eight platforms — all from one command.</p>
|
||||||
<div class="hiw-detail-grid">
|
<div class="hiw-detail-grid">
|
||||||
<div class="hiw-detail">
|
<div class="hiw-detail">
|
||||||
<span class="hiw-detail-label">Transcription</span>
|
<span class="hiw-detail-label">Transcription</span>
|
||||||
<span class="hiw-detail-value">Whisper AI</span>
|
<span class="hiw-detail-value">MLX Whisper (GPU)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hiw-detail">
|
<div class="hiw-detail">
|
||||||
<span class="hiw-detail-label">Metadata</span>
|
<span class="hiw-detail-label">Metadata</span>
|
||||||
@@ -413,7 +545,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hiw-step">
|
<div class="hiw-step">
|
||||||
<div class="hiw-step-number">10</div>
|
<div class="hiw-step-number">13</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<h3>Automated Social Clips</h3>
|
||||||
|
<p>No manual editing, no scheduling tools. After each episode, an LLM reads the full transcript and picks the best moments — funny exchanges, wild confessions, heated debates. Each clip is automatically extracted, transcribed with word-level timestamps, then polished by a second LLM pass that fixes punctuation, capitalization, and misheard words while preserving timing. The clips are rendered as vertical video with speaker-labeled captions and the show's branding. A third LLM writes platform-specific descriptions and hashtags. Then clips are uploaded directly to YouTube Shorts and Bluesky via their APIs, and pushed to Instagram Reels, Facebook Reels, Mastodon, Nostr, LinkedIn, Threads, and TikTok — nine platforms, zero manual work.</p>
|
||||||
|
<div class="hiw-detail-grid">
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Human Effort</span>
|
||||||
|
<span class="hiw-detail-value">Zero</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Video Format</span>
|
||||||
|
<span class="hiw-detail-value">1080x1920 MP4</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Captions</span>
|
||||||
|
<span class="hiw-detail-value">LLM-polished</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Simultaneous Push</span>
|
||||||
|
<span class="hiw-detail-value">9 platforms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">14</div>
|
||||||
<div class="hiw-step-content">
|
<div class="hiw-step-content">
|
||||||
<h3>Global Distribution</h3>
|
<h3>Global Distribution</h3>
|
||||||
<p>Episodes are served through a CDN edge network for fast, reliable playback worldwide. The RSS feed is automatically updated and picked up by Spotify, Apple Podcasts, YouTube, and every other podcast app. The website pulls the live feed to show episodes with embedded playback, full transcripts, and chapter navigation — all served through Cloudflare with edge caching. From recording to available on every platform, the whole pipeline is automated end-to-end.</p>
|
<p>Episodes are served through a CDN edge network for fast, reliable playback worldwide. The RSS feed is automatically updated and picked up by Spotify, Apple Podcasts, YouTube, and every other podcast app. The website pulls the live feed to show episodes with embedded playback, full transcripts, and chapter navigation — all served through Cloudflare with edge caching. From recording to available on every platform, the whole pipeline is automated end-to-end.</p>
|
||||||
@@ -470,7 +628,7 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>They Listen to Each Other</h3>
|
<h3>They Listen to Each Other</h3>
|
||||||
<p>Callers aren't isolated — they hear what happened earlier in the show. A caller might disagree with the last guy, back someone up, or call in specifically because of something another caller said. The show builds on itself.</p>
|
<p>Callers aren't isolated — the system matches callers thematically to what's already been discussed. A caller might disagree with the last guy, back someone up, or call in because something another caller said hit close to home. The show tracks energy and pacing so conversations build naturally, not randomly.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hiw-feature">
|
<div class="hiw-feature">
|
||||||
<div class="hiw-feature-icon">
|
<div class="hiw-feature-icon">
|
||||||
@@ -489,6 +647,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Post-Production Automation Video -->
|
||||||
|
<section class="hiw-section">
|
||||||
|
<h2>Post-Production in Action</h2>
|
||||||
|
<div class="hiw-hero-card">
|
||||||
|
<video class="hiw-video" controls playsinline preload="metadata" poster="">
|
||||||
|
<source src="https://cdn.lukeattheroost.com/videos/reaper-postprod.mp4" type="video/mp4">
|
||||||
|
</video>
|
||||||
|
<p class="hiw-video-caption">The entire post-production pipeline runs automatically through Reaper scripting. Silence removal, ad ducking, and EBU R128 loudness normalization — all triggered with a single command when the show ends.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- CTA -->
|
<!-- CTA -->
|
||||||
<section class="hiw-section hiw-cta">
|
<section class="hiw-section hiw-cta">
|
||||||
<p>Want to hear it for yourself?</p>
|
<p>Want to hear it for yourself?</p>
|
||||||
@@ -496,33 +665,12 @@
|
|||||||
<div class="hiw-cta-phone">
|
<div class="hiw-cta-phone">
|
||||||
Or call in live: <strong>208-439-LUKE</strong>
|
Or call in live: <strong>208-439-LUKE</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener" class="hiw-cta-support">Support the Show</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Footer -->
|
</main>
|
||||||
<footer class="footer">
|
|
||||||
<div class="footer-links">
|
|
||||||
<a href="/">Home</a>
|
|
||||||
<a href="/stats">Stats</a>
|
|
||||||
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener">Discord</a>
|
|
||||||
<a href="https://www.facebook.com/profile.php?id=61588191627949" target="_blank" rel="noopener">Facebook</a>
|
|
||||||
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener">X</a>
|
|
||||||
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener">Bluesky</a>
|
|
||||||
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener">Mastodon</a>
|
|
||||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
|
|
||||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
|
|
||||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
|
|
||||||
</div>
|
|
||||||
<div class="footer-projects">
|
|
||||||
<span class="footer-projects-label">More from Luke</span>
|
|
||||||
<div class="footer-projects-links">
|
|
||||||
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
|
|
||||||
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
|
|
||||||
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
|
||||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a></p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
|
<footer class="footer"></footer>
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -4,20 +4,21 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Luke at the Roost — AI Call-In Comedy Podcast</title>
|
<title>Luke at the Roost — AI Call-In Comedy Podcast</title>
|
||||||
<meta name="description" content="Late-night call-in radio from a desert hermit's RV. Real callers and AI-generated characters talk to Luke about life, love, and everything in between. Call in: 208-439-LUKE.">
|
<meta name="description" content="AI-powered late-night call-in comedy podcast. Real callers and AI-generated characters talk to Luke MacNeil about life, love, and everything in between. 37+ episodes. Call in: 208-439-LUKE.">
|
||||||
|
<meta name="keywords" content="AI podcast, comedy podcast, call-in radio, AI-generated callers, late-night radio, Luke MacNeil, talk show, AI comedy, artificial intelligence podcast, call-in podcast">
|
||||||
<meta name="theme-color" content="#1a1209">
|
<meta name="theme-color" content="#1a1209">
|
||||||
<link rel="canonical" href="https://lukeattheroost.com">
|
<link rel="canonical" href="https://lukeattheroost.com">
|
||||||
|
|
||||||
<!-- OG / Social -->
|
<!-- OG / Social -->
|
||||||
<meta property="og:site_name" content="Luke at the Roost">
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
<meta property="og:title" content="Luke at the Roost — AI Call-In Comedy Podcast">
|
<meta property="og:title" content="Luke at the Roost — AI Call-In Comedy Podcast">
|
||||||
<meta property="og:description" content="Late-night call-in radio from a desert hermit's RV. Real callers and AI characters talk to Luke about life, love, and everything in between.">
|
<meta property="og:description" content="AI-powered late-night call-in comedy podcast. Real callers and AI-generated characters talk to Luke about life, love, and everything in between. 37+ episodes.">
|
||||||
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
<meta property="og:url" content="https://lukeattheroost.com">
|
<meta property="og:url" content="https://lukeattheroost.com">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Luke at the Roost — AI Call-In Comedy Podcast">
|
<meta name="twitter:title" content="Luke at the Roost — AI Call-In Comedy Podcast">
|
||||||
<meta name="twitter:description" content="Late-night call-in radio from a desert hermit's RV. Real callers and AI characters talk to Luke about life, love, and everything in between.">
|
<meta name="twitter:description" content="AI-powered late-night call-in comedy podcast. Real callers and AI-generated characters talk to Luke about life, love, and everything in between. 37+ episodes.">
|
||||||
<meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
<meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
@@ -30,57 +31,97 @@
|
|||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
|
||||||
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
||||||
<link rel="stylesheet" href="css/style.css?v=2">
|
<link rel="stylesheet" href="css/style.css?v=6">
|
||||||
|
|
||||||
<!-- Structured Data -->
|
<!-- Structured Data -->
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{
|
[{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "PodcastSeries",
|
"@type": "PodcastSeries",
|
||||||
"name": "Luke at the Roost",
|
"name": "Luke at the Roost",
|
||||||
"description": "The call-in talk show where Luke gives life advice to biologically questionable organisms. Broadcast from a desert hermit's RV, featuring a mix of real callers and AI-generated callers.",
|
"description": "An AI-powered late-night call-in comedy podcast. Real callers and AI-generated characters with unique personalities talk to host Luke MacNeil about life, love, relationships, and everything in between. Broadcast from a desert hermit's RV, featuring 37+ episodes of unpredictable conversations.",
|
||||||
"url": "https://lukeattheroost.com",
|
"url": "https://lukeattheroost.com",
|
||||||
"image": "https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3",
|
"image": "https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3",
|
||||||
"author": {
|
"author": {
|
||||||
"@type": "Person",
|
"@type": "Person",
|
||||||
"name": "Luke MacNeil"
|
"name": "Luke MacNeil",
|
||||||
|
"url": "https://lukeattheroost.com"
|
||||||
},
|
},
|
||||||
"publisher": {
|
"publisher": {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": "MacNeil Media Group"
|
"name": "MacNeil Media Group",
|
||||||
|
"url": "https://macneilmediagroup.com"
|
||||||
},
|
},
|
||||||
"genre": "Comedy",
|
"genre": ["Comedy", "Talk", "Technology"],
|
||||||
"inLanguage": "en",
|
"inLanguage": "en",
|
||||||
"webFeed": "https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml",
|
"webFeed": "https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml",
|
||||||
|
"startDate": "2026-02",
|
||||||
|
"keywords": "AI podcast, comedy, call-in radio, artificial intelligence, AI-generated callers, late-night talk show",
|
||||||
"sameAs": [
|
"sameAs": [
|
||||||
"https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF",
|
"https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF",
|
||||||
"https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848",
|
"https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848",
|
||||||
"https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-",
|
"https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-",
|
||||||
"https://discord.gg/5CnQZxDM"
|
"https://discord.gg/5CnQZxDM",
|
||||||
|
"https://www.instagram.com/lukeattheroost/",
|
||||||
|
"https://x.com/lukeattheroost",
|
||||||
|
"https://www.tiktok.com/@luke.at.the.roost",
|
||||||
|
"https://bsky.app/profile/lukeattheroost.bsky.social",
|
||||||
|
"https://www.facebook.com/profile.php?id=61588191627949",
|
||||||
|
"https://www.linkedin.com/company/luke-at-the-roost"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "Luke at the Roost",
|
||||||
|
"url": "https://lukeattheroost.com",
|
||||||
|
"description": "Official website for Luke at the Roost, an AI-powered late-night call-in comedy podcast.",
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "MacNeil Media Group",
|
||||||
|
"url": "https://macneilmediagroup.com",
|
||||||
|
"logo": {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
"url": "https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3"
|
||||||
|
},
|
||||||
|
"contactPoint": {
|
||||||
|
"@type": "ContactPoint",
|
||||||
|
"email": "luke@lukeattheroost.com",
|
||||||
|
"contactType": "customer support"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
</script>
|
</script>
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||||
|
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="/" class="site-nav-brand">Luke at the Roost</a>
|
||||||
|
<div class="site-nav-links">
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="/clips">Clips</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
|
||||||
<!-- Banner -->
|
<!-- Banner -->
|
||||||
<div class="banner">
|
<div class="banner">
|
||||||
<img src="images/banner.png" alt="Luke at the Roost — ON AIR" class="banner-img" width="1500" height="500">
|
<img src="images/banner.png" alt="Luke at the Roost — ON AIR" class="banner-img" width="1500" height="500" sizes="100vw" fetchpriority="high">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="hero-inner">
|
<div class="hero-grid">
|
||||||
<img
|
<div class="hero-text">
|
||||||
class="cover-art"
|
<h1 class="sr-only">Luke at the Roost</h1>
|
||||||
src="images/cover.png"
|
<p class="tagline tagline--hero">Every caller is AI. Every conversation is real. Every episode is a disaster.</p>
|
||||||
alt="Luke at the Roost cover art"
|
<p class="tagline tagline--sub">The call-in talk show where Luke gives life advice to biologically questionable organisms.</p>
|
||||||
width="1440"
|
|
||||||
height="1440"
|
|
||||||
>
|
|
||||||
<div class="hero-info">
|
|
||||||
<h1>Luke at the Roost</h1>
|
|
||||||
<p class="tagline">The call-in talk show where Luke gives life advice to biologically questionable organisms.</p>
|
|
||||||
<div class="phone" id="phone-section">
|
<div class="phone" id="phone-section">
|
||||||
<div class="on-air-badge" id="on-air-badge">
|
<div class="on-air-badge" id="on-air-badge">
|
||||||
<span class="on-air-dot"></span>
|
<span class="on-air-dot"></span>
|
||||||
@@ -89,39 +130,67 @@
|
|||||||
<div class="off-air-badge" id="off-air-badge">
|
<div class="off-air-badge" id="off-air-badge">
|
||||||
OFF AIR
|
OFF AIR
|
||||||
</div>
|
</div>
|
||||||
<span class="phone-label">Call in live</span>
|
<span class="phone-inline">Call in: <strong>208-439-LUKE</strong></span>
|
||||||
<span class="phone-number">208-439-LUKE</span>
|
|
||||||
<span class="phone-digits">(208-439-5853)</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="subscribe-row">
|
<div class="subscribe-row">
|
||||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="subscribe-btn btn-spotify">
|
<span class="subscribe-label">Listen On</span>
|
||||||
|
<div class="subscribe-buttons">
|
||||||
|
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="subscribe-btn">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg>
|
||||||
Spotify
|
Spotify
|
||||||
</a>
|
</a>
|
||||||
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="subscribe-btn btn-apple">
|
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="subscribe-btn">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.34 0A5.328 5.328 0 0 0 0 5.34v13.32A5.328 5.328 0 0 0 5.34 24h13.32A5.328 5.328 0 0 0 24 18.66V5.34A5.328 5.328 0 0 0 18.66 0zm6.525 2.568c2.336 0 4.448.902 4.448 3.545 0 1.497-.89 2.67-1.916 3.545-.663.566-.795 .84-.795 1.347 0 .6.397 1.173.894 1.722 1.417 1.564 1.96 2.853 1.96 4.448 0 3.063-2.673 4.257-5.165 4.257-.315 0-.658-.02-.994-.063-1.523-.195-2.86-.9-3.632-.9-.82 0-1.98.623-3.377.87A5.715 5.715 0 0 1 3.15 21.4c-1.27 0-2.1-.96-2.1-2.663 0-1.2.6-2.7 1.845-4.29.63-.81 1.62-1.83 2.91-2.31-.06-.6-.09-1.14-.09-1.62 0-4.28 2.76-7.95 6.15-7.95z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.34 0A5.328 5.328 0 0 0 0 5.34v13.32A5.328 5.328 0 0 0 5.34 24h13.32A5.328 5.328 0 0 0 24 18.66V5.34A5.328 5.328 0 0 0 18.66 0zm6.525 2.568c2.336 0 4.448.902 4.448 3.545 0 1.497-.89 2.67-1.916 3.545-.663.566-.795 .84-.795 1.347 0 .6.397 1.173.894 1.722 1.417 1.564 1.96 2.853 1.96 4.448 0 3.063-2.673 4.257-5.165 4.257-.315 0-.658-.02-.994-.063-1.523-.195-2.86-.9-3.632-.9-.82 0-1.98.623-3.377.87A5.715 5.715 0 0 1 3.15 21.4c-1.27 0-2.1-.96-2.1-2.663 0-1.2.6-2.7 1.845-4.29.63-.81 1.62-1.83 2.91-2.31-.06-.6-.09-1.14-.09-1.62 0-4.28 2.76-7.95 6.15-7.95z"/></svg>
|
||||||
Apple
|
Apple
|
||||||
</a>
|
</a>
|
||||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="subscribe-btn btn-youtube">
|
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="subscribe-btn">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||||
YouTube
|
YouTube
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="subscribe-btn">
|
||||||
<div class="secondary-links">
|
|
||||||
<a href="/how-it-works" class="secondary-link">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/></svg>
|
|
||||||
How It Works
|
|
||||||
</a>
|
|
||||||
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener" class="secondary-link">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z"/></svg>
|
|
||||||
Discord
|
|
||||||
</a>
|
|
||||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="secondary-link">
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg>
|
||||||
RSS
|
RSS
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hero-links">
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<span class="hero-links-sep">·</span>
|
||||||
|
<a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener" class="support-link">Support the Show</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Social Proof Strip -->
|
||||||
|
<section class="proof-strip">
|
||||||
|
<div class="proof-strip-inner">
|
||||||
|
<blockquote class="proof-quote">"I've been Diane for seven months and I can't stop."<cite>— Not Diane, Ep. 24</cite></blockquote>
|
||||||
|
<blockquote class="proof-quote">"I tasted the exit sign. Like green metal in my mouth."<cite>— Floyd, Ep. 15</cite></blockquote>
|
||||||
|
<blockquote class="proof-quote">"I'm holding his dogs until he pays up. All three of them."<cite>— Mavis, Ep. 24</cite></blockquote>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Best Clips -->
|
||||||
|
<section class="home-clips-section">
|
||||||
|
<div class="home-clips-header">
|
||||||
|
<h2>Best Clips</h2>
|
||||||
|
<a href="/clips" class="clips-see-all">See all clips →</a>
|
||||||
|
</div>
|
||||||
|
<div class="home-clips-grid" id="home-clips"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Featured Episode -->
|
||||||
|
<section class="featured-episode-section">
|
||||||
|
<h2>Latest Episode</h2>
|
||||||
|
<div id="featured-episode"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Episodes -->
|
||||||
|
<section class="episodes-section">
|
||||||
|
<h2 id="episodes-heading">Episodes</h2>
|
||||||
|
<div class="episodes-list" id="episodes-list">
|
||||||
|
<div class="episodes-loading">Loading episodes...</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -199,39 +268,17 @@
|
|||||||
<div class="testimonials-dots" id="testimonials-dots"></div>
|
<div class="testimonials-dots" id="testimonials-dots"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Episodes -->
|
</main>
|
||||||
<section class="episodes-section">
|
|
||||||
<h2>Episodes</h2>
|
|
||||||
<div class="episodes-list" id="episodes-list">
|
|
||||||
<div class="episodes-loading">Loading episodes...</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<footer class="footer"></footer>
|
||||||
<footer class="footer">
|
|
||||||
<div class="footer-links">
|
<!-- Sticky CTA Bar — appears after scrolling past hero -->
|
||||||
<a href="/how-it-works">How It Works</a>
|
<div class="sticky-cta" id="sticky-cta" aria-hidden="true">
|
||||||
<a href="/stats">Stats</a>
|
<a href="tel:+12084395853" class="sticky-cta-link">
|
||||||
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener">Discord</a>
|
<svg class="sticky-cta-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg>
|
||||||
<a href="https://www.facebook.com/profile.php?id=61588191627949" target="_blank" rel="noopener">Facebook</a>
|
Call in live: <strong>208-439-LUKE</strong>
|
||||||
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener">X</a>
|
</a>
|
||||||
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener">Bluesky</a>
|
|
||||||
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener">Mastodon</a>
|
|
||||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
|
|
||||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
|
|
||||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-projects">
|
|
||||||
<span class="footer-projects-label">More from Luke</span>
|
|
||||||
<div class="footer-projects-links">
|
|
||||||
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
|
|
||||||
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
|
|
||||||
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
|
||||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a></p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<!-- Sticky Audio Player -->
|
<!-- Sticky Audio Player -->
|
||||||
<div class="sticky-player" id="sticky-player">
|
<div class="sticky-player" id="sticky-player">
|
||||||
@@ -243,7 +290,7 @@
|
|||||||
<div class="player-info">
|
<div class="player-info">
|
||||||
<div class="player-title" id="player-title">—</div>
|
<div class="player-title" id="player-title">—</div>
|
||||||
<div class="player-progress-row">
|
<div class="player-progress-row">
|
||||||
<div class="player-progress" id="player-progress">
|
<div class="player-progress" id="player-progress" role="slider" aria-label="Audio progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" tabindex="0">
|
||||||
<div class="player-progress-fill" id="player-progress-fill"></div>
|
<div class="player-progress-fill" id="player-progress-fill"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
||||||
@@ -254,6 +301,10 @@
|
|||||||
|
|
||||||
<audio id="audio-element" preload="none"></audio>
|
<audio id="audio-element" preload="none"></audio>
|
||||||
|
|
||||||
<script src="js/app.js?v=2"></script>
|
<script src="js/footer.js"></script>
|
||||||
|
<script src="js/clips.js"></script>
|
||||||
|
<script>renderFeaturedClipsInline('home-clips');</script>
|
||||||
|
<script src="js/player.js"></script>
|
||||||
|
<script src="js/app.js?v=6"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
const FEED_URL = '/feed';
|
const FEED_URL = '/feed';
|
||||||
|
const EPISODES_PER_PAGE = 10;
|
||||||
|
|
||||||
const audio = document.getElementById('audio-element');
|
|
||||||
const stickyPlayer = document.getElementById('sticky-player');
|
|
||||||
const playerPlayBtn = document.getElementById('player-play-btn');
|
|
||||||
const playerTitle = document.getElementById('player-title');
|
|
||||||
const playerProgress = document.getElementById('player-progress');
|
|
||||||
const playerProgressFill = document.getElementById('player-progress-fill');
|
|
||||||
const playerTime = document.getElementById('player-time');
|
|
||||||
const episodesList = document.getElementById('episodes-list');
|
const episodesList = document.getElementById('episodes-list');
|
||||||
|
|
||||||
let currentEpisodeCard = null;
|
let currentEpisodeCard = null;
|
||||||
|
let allEpisodes = [];
|
||||||
|
let displayedCount = 0;
|
||||||
|
|
||||||
// Format seconds to M:SS or H:MM:SS
|
function escapeAttr(str) {
|
||||||
function formatTime(seconds) {
|
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
if (!seconds || isNaN(seconds)) return '0:00';
|
|
||||||
const s = Math.floor(seconds);
|
|
||||||
const h = Math.floor(s / 3600);
|
|
||||||
const m = Math.floor((s % 3600) / 60);
|
|
||||||
const sec = s % 60;
|
|
||||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
|
||||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format duration from itunes:duration (could be seconds or HH:MM:SS)
|
// Format duration from itunes:duration (could be seconds or HH:MM:SS)
|
||||||
@@ -43,18 +32,32 @@ function formatDate(dateStr) {
|
|||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip HTML tags and truncate
|
function stripHtml(html) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = html || '';
|
||||||
|
return div.textContent || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip HTML tags and truncate at word boundary (returns escaped text safe for innerHTML)
|
||||||
function truncate(html, maxLen) {
|
function truncate(html, maxLen) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.innerHTML = html || '';
|
div.innerHTML = html || '';
|
||||||
const text = div.textContent || '';
|
const text = div.textContent || '';
|
||||||
if (text.length <= maxLen) return text;
|
let result;
|
||||||
return text.slice(0, maxLen).trimEnd() + '...';
|
if (text.length <= maxLen) {
|
||||||
|
result = text;
|
||||||
|
} else {
|
||||||
|
const truncated = text.slice(0, maxLen);
|
||||||
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
|
result = (lastSpace > maxLen * 0.5 ? truncated.slice(0, lastSpace) : truncated).trimEnd() + '...';
|
||||||
|
}
|
||||||
|
return escapeAttr(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SVG icons
|
// SVG icons
|
||||||
const playSVG = '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>';
|
const playSVG = '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>';
|
||||||
const pauseSVG = '<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>';
|
const pauseSVG = '<svg viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>';
|
||||||
|
const shareSVG = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 5l-1.42 1.42-1.59-1.59V16h-2V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11a2 2 0 01-2 2H6a2 2 0 01-2-2V10a2 2 0 012-2h3v2H6v11h12V10h-3V8h3a2 2 0 012 2z"/></svg>';
|
||||||
|
|
||||||
// Fetch with timeout
|
// Fetch with timeout
|
||||||
function fetchWithTimeout(url, ms = 8000) {
|
function fetchWithTimeout(url, ms = 8000) {
|
||||||
@@ -63,6 +66,64 @@ function fetchWithTimeout(url, ms = 8000) {
|
|||||||
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
|
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function shareContent(title, url, btn) {
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title, url });
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
const orig = btn.innerHTML;
|
||||||
|
btn.innerHTML = 'Copied!';
|
||||||
|
btn.classList.add('share-copied');
|
||||||
|
setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('share-copied'); }, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
prompt('Copy this link:', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFeaturedCard(ep) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'featured-episode-card';
|
||||||
|
|
||||||
|
const epLabel = ep.episodeNum ? `Episode ${ep.episodeNum}` : '';
|
||||||
|
const dateStr = ep.pubDate ? formatDate(ep.pubDate) : '';
|
||||||
|
const durStr = parseDuration(ep.duration);
|
||||||
|
const metaParts = [epLabel, dateStr, durStr].filter(Boolean).join(' · ');
|
||||||
|
const epSlug = ep.link ? ep.link.split('/episodes/').pop()?.replace(/\/$/, '') : '';
|
||||||
|
const fullDesc = escapeAttr(stripHtml(ep.description));
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="featured-episode-meta">
|
||||||
|
<span class="episode-new-badge">NEW</span> ${metaParts}
|
||||||
|
</div>
|
||||||
|
<div class="featured-episode-title">${escapeAttr(ep.title)}</div>
|
||||||
|
<div class="featured-episode-desc">${fullDesc}</div>
|
||||||
|
<div class="featured-episode-actions">
|
||||||
|
<button class="episode-play-btn featured-play-btn" aria-label="Play ${escapeAttr(ep.title)}">
|
||||||
|
${playSVG}
|
||||||
|
</button>
|
||||||
|
${epSlug ? `<a href="/episode.html?slug=${encodeURIComponent(epSlug)}" class="episode-transcript-link">Read Transcript</a>` : ''}
|
||||||
|
<button class="episode-share-btn" aria-label="Share episode">${shareSVG}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const playBtn = card.querySelector('.featured-play-btn');
|
||||||
|
playBtn.addEventListener('click', () => playEpisode(ep.audioUrl, ep.title, card, playBtn));
|
||||||
|
|
||||||
|
const shareBtn = card.querySelector('.episode-share-btn');
|
||||||
|
const shareUrl = epSlug
|
||||||
|
? `${window.location.origin}/episode.html?slug=${encodeURIComponent(epSlug)}`
|
||||||
|
: window.location.origin;
|
||||||
|
shareBtn.addEventListener('click', () => shareContent(ep.title, shareUrl, shareBtn));
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch and parse RSS feed
|
// Fetch and parse RSS feed
|
||||||
async function fetchEpisodes() {
|
async function fetchEpisodes() {
|
||||||
let xml;
|
let xml;
|
||||||
@@ -91,7 +152,7 @@ async function fetchEpisodes() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const episodes = Array.from(items).map((item, i) => {
|
const episodes = Array.from(items).map((item) => {
|
||||||
const title = item.querySelector('title')?.textContent || 'Untitled';
|
const title = item.querySelector('title')?.textContent || 'Untitled';
|
||||||
const description = item.querySelector('description')?.textContent || '';
|
const description = item.querySelector('description')?.textContent || '';
|
||||||
const enclosure = item.querySelector('enclosure');
|
const enclosure = item.querySelector('enclosure');
|
||||||
@@ -108,9 +169,26 @@ async function fetchEpisodes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEpisodes(episodes) {
|
function renderEpisodes(episodes) {
|
||||||
episodesList.innerHTML = '';
|
// Featured episode — render newest into dedicated container
|
||||||
|
const featuredContainer = document.getElementById('featured-episode');
|
||||||
|
if (featuredContainer && episodes.length > 0) {
|
||||||
|
featuredContainer.innerHTML = '';
|
||||||
|
featuredContainer.appendChild(createFeaturedCard(episodes[0]));
|
||||||
|
allEpisodes = episodes.slice(1);
|
||||||
|
} else {
|
||||||
|
allEpisodes = episodes;
|
||||||
|
}
|
||||||
|
|
||||||
episodes.forEach((ep) => {
|
// Update heading with total episode count
|
||||||
|
const heading = document.getElementById('episodes-heading');
|
||||||
|
if (heading) heading.textContent = `All Episodes (${episodes.length})`;
|
||||||
|
|
||||||
|
displayedCount = 0;
|
||||||
|
episodesList.innerHTML = '';
|
||||||
|
showMoreEpisodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEpisodeCard(ep) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'episode-card';
|
card.className = 'episode-card';
|
||||||
|
|
||||||
@@ -122,28 +200,53 @@ function renderEpisodes(episodes) {
|
|||||||
const epSlug = ep.link ? ep.link.split('/episodes/').pop()?.replace(/\/$/, '') : '';
|
const epSlug = ep.link ? ep.link.split('/episodes/').pop()?.replace(/\/$/, '') : '';
|
||||||
|
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<button class="episode-play-btn" aria-label="Play ${ep.title}" data-url="${ep.audioUrl}" data-title="${ep.title.replace(/"/g, '"')}">
|
<button class="episode-play-btn" aria-label="Play ${escapeAttr(ep.title)}" data-url="${escapeAttr(ep.audioUrl)}" data-title="${escapeAttr(ep.title)}">
|
||||||
${playSVG}
|
${playSVG}
|
||||||
</button>
|
</button>
|
||||||
<div class="episode-info">
|
<div class="episode-info">
|
||||||
<div class="episode-meta">${metaParts}</div>
|
<div class="episode-meta">${metaParts}</div>
|
||||||
<div class="episode-title">${ep.title}</div>
|
<div class="episode-title">${escapeAttr(ep.title)}</div>
|
||||||
<div class="episode-desc">${truncate(ep.description, 150)}</div>
|
<div class="episode-desc">${truncate(ep.description, 150)}</div>
|
||||||
${epSlug ? `<a href="/episode.html?slug=${epSlug}" class="episode-transcript-link">Read Transcript</a>` : ''}
|
${epSlug ? `<a href="/episode.html?slug=${encodeURIComponent(epSlug)}" class="episode-transcript-link">Read Transcript</a>` : ''}
|
||||||
|
<button class="episode-share-btn" aria-label="Share episode">${shareSVG}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const btn = card.querySelector('.episode-play-btn');
|
const btn = card.querySelector('.episode-play-btn');
|
||||||
btn.addEventListener('click', () => playEpisode(ep.audioUrl, ep.title, card, btn));
|
btn.addEventListener('click', () => playEpisode(ep.audioUrl, ep.title, card, btn));
|
||||||
|
|
||||||
episodesList.appendChild(card);
|
const shareBtn = card.querySelector('.episode-share-btn');
|
||||||
|
const shareUrl = epSlug
|
||||||
|
? `${window.location.origin}/episode.html?slug=${encodeURIComponent(epSlug)}`
|
||||||
|
: window.location.origin;
|
||||||
|
shareBtn.addEventListener('click', () => shareContent(ep.title, shareUrl, shareBtn));
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMoreEpisodes() {
|
||||||
|
const batch = allEpisodes.slice(displayedCount, displayedCount + EPISODES_PER_PAGE);
|
||||||
|
batch.forEach((ep) => {
|
||||||
|
episodesList.appendChild(createEpisodeCard(ep));
|
||||||
});
|
});
|
||||||
|
displayedCount += batch.length;
|
||||||
|
|
||||||
|
const existing = document.getElementById('load-more-btn');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
if (displayedCount < allEpisodes.length) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'load-more-btn';
|
||||||
|
btn.className = 'load-more-btn';
|
||||||
|
btn.textContent = `Load More (${allEpisodes.length - displayedCount} remaining)`;
|
||||||
|
btn.addEventListener('click', showMoreEpisodes);
|
||||||
|
episodesList.after(btn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function playEpisode(url, title, card, btn) {
|
function playEpisode(url, title, card, btn) {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
// If clicking the same episode that's playing, toggle play/pause
|
|
||||||
if (audio.src === url || audio.src === encodeURI(url)) {
|
if (audio.src === url || audio.src === encodeURI(url)) {
|
||||||
if (audio.paused) {
|
if (audio.paused) {
|
||||||
audio.play();
|
audio.play();
|
||||||
@@ -153,7 +256,6 @@ function playEpisode(url, title, card, btn) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset previous card button icon
|
|
||||||
if (currentEpisodeCard) {
|
if (currentEpisodeCard) {
|
||||||
const prevBtn = currentEpisodeCard.querySelector('.episode-play-btn');
|
const prevBtn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||||
if (prevBtn) {
|
if (prevBtn) {
|
||||||
@@ -168,37 +270,11 @@ function playEpisode(url, title, card, btn) {
|
|||||||
|
|
||||||
playerTitle.textContent = title;
|
playerTitle.textContent = title;
|
||||||
stickyPlayer.classList.add('active');
|
stickyPlayer.classList.add('active');
|
||||||
|
if (stickyCta) stickyCta.classList.add('player-active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync UI with audio state
|
// Episode card icon sync (sticky player icons handled by player.js)
|
||||||
audio.addEventListener('play', () => {
|
function updateCardIcon(playing) {
|
||||||
updatePlayIcons(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.addEventListener('pause', () => {
|
|
||||||
updatePlayIcons(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.addEventListener('timeupdate', () => {
|
|
||||||
if (audio.duration) {
|
|
||||||
const pct = (audio.currentTime / audio.duration) * 100;
|
|
||||||
playerProgressFill.style.width = pct + '%';
|
|
||||||
playerTime.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
audio.addEventListener('ended', () => {
|
|
||||||
updatePlayIcons(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
function updatePlayIcons(playing) {
|
|
||||||
// Sticky player icons
|
|
||||||
const iconPlay = playerPlayBtn.querySelector('.icon-play');
|
|
||||||
const iconPause = playerPlayBtn.querySelector('.icon-pause');
|
|
||||||
if (iconPlay) iconPlay.style.display = playing ? 'none' : 'block';
|
|
||||||
if (iconPause) iconPause.style.display = playing ? 'block' : 'none';
|
|
||||||
|
|
||||||
// Episode card icon
|
|
||||||
if (currentEpisodeCard) {
|
if (currentEpisodeCard) {
|
||||||
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
@@ -208,22 +284,9 @@ function updatePlayIcons(playing) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sticky player play/pause button
|
audio.addEventListener('play', () => updateCardIcon(true));
|
||||||
playerPlayBtn.addEventListener('click', () => {
|
audio.addEventListener('pause', () => updateCardIcon(false));
|
||||||
if (audio.src) {
|
audio.addEventListener('ended', () => updateCardIcon(false));
|
||||||
if (audio.paused) audio.play();
|
|
||||||
else audio.pause();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Progress bar seeking
|
|
||||||
playerProgress.addEventListener('click', (e) => {
|
|
||||||
if (audio.duration) {
|
|
||||||
const rect = playerProgress.getBoundingClientRect();
|
|
||||||
const pct = (e.clientX - rect.left) / rect.width;
|
|
||||||
audio.currentTime = pct * audio.duration;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Testimonials Slider
|
// Testimonials Slider
|
||||||
function initTestimonials() {
|
function initTestimonials() {
|
||||||
@@ -318,6 +381,18 @@ function checkOnAir() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sticky CTA — show after scrolling past hero
|
||||||
|
const stickyCta = document.getElementById('sticky-cta');
|
||||||
|
const heroSection = document.querySelector('.hero');
|
||||||
|
if (stickyCta && heroSection) {
|
||||||
|
const ctaObserver = new IntersectionObserver(([entry]) => {
|
||||||
|
const show = !entry.isIntersecting;
|
||||||
|
stickyCta.classList.toggle('visible', show);
|
||||||
|
stickyCta.setAttribute('aria-hidden', String(!show));
|
||||||
|
}, { threshold: 0 });
|
||||||
|
ctaObserver.observe(heroSection);
|
||||||
|
}
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
fetchEpisodes();
|
fetchEpisodes();
|
||||||
initTestimonials();
|
initTestimonials();
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
const CLIPS_JSON_URL = '/data/clips.json';
|
||||||
|
|
||||||
|
const clipPlaySVG = '<svg viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg>';
|
||||||
|
const clipShareSVG = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 5l-1.42 1.42-1.59-1.59V16h-2V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11a2 2 0 01-2 2H6a2 2 0 01-2-2V10a2 2 0 012-2h3v2H6v11h12V10h-3V8h3a2 2 0 012 2z"/></svg>';
|
||||||
|
|
||||||
|
function escapeHTML(str) {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.textContent = str;
|
||||||
|
return el.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareClipContent(title, url, btn) {
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title, url });
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
const orig = btn.innerHTML;
|
||||||
|
btn.innerHTML = 'Copied!';
|
||||||
|
btn.classList.add('share-copied');
|
||||||
|
setTimeout(() => { btn.innerHTML = orig; btn.classList.remove('share-copied'); }, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
prompt('Copy this link:', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClipCard(clip, featured) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'clip-card' + (featured ? ' clip-card-featured' : '');
|
||||||
|
|
||||||
|
const youtubeId = (clip.youtube_id || '').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
|
if (youtubeId) card.dataset.youtubeId = youtubeId;
|
||||||
|
const hasVideo = !!youtubeId;
|
||||||
|
const epLabel = clip.episode_number ? `Episode ${Number(clip.episode_number)}` : '';
|
||||||
|
const title = escapeHTML(clip.title || '');
|
||||||
|
const desc = escapeHTML(clip.description || '');
|
||||||
|
|
||||||
|
const thumbImg = clip.thumbnail && /^[\w\/.-]+$/.test(clip.thumbnail)
|
||||||
|
? `<img class="clip-card-thumb" src="/${clip.thumbnail}" alt="${title}" loading="lazy">`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="clip-card-inner">
|
||||||
|
${thumbImg}
|
||||||
|
<div class="clip-card-overlay">
|
||||||
|
<span class="clip-episode-label">${epLabel}</span>
|
||||||
|
<h3 class="clip-card-title">${title}</h3>
|
||||||
|
<p class="clip-card-desc">${desc}</p>
|
||||||
|
${hasVideo ? `<button class="clip-play-btn" aria-label="Play clip">${clipPlaySVG}</button>` : ''}
|
||||||
|
${hasVideo ? `<button class="clip-share-btn" aria-label="Share clip">${clipShareSVG}</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (hasVideo) {
|
||||||
|
card.querySelector('.clip-play-btn').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const inner = card.querySelector('.clip-card-inner');
|
||||||
|
inner.innerHTML = `<iframe src="https://www.youtube-nocookie.com/embed/${youtubeId}?autoplay=1&rel=0" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>`;
|
||||||
|
});
|
||||||
|
const shareBtn = card.querySelector('.clip-share-btn');
|
||||||
|
if (shareBtn) {
|
||||||
|
shareBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
shareClipContent(clip.title || '', `https://youtube.com/watch?v=${youtubeId}`, shareBtn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchClips() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(CLIPS_JSON_URL);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch clips');
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading clips:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initClipsPage() {
|
||||||
|
const clips = await fetchClips();
|
||||||
|
if (!clips.length) return;
|
||||||
|
|
||||||
|
const featuredContainer = document.querySelector('.clips-featured');
|
||||||
|
const gridContainer = document.querySelector('.clips-grid');
|
||||||
|
|
||||||
|
if (featuredContainer) {
|
||||||
|
clips.filter(c => c.featured).forEach(clip => {
|
||||||
|
featuredContainer.appendChild(renderClipCard(clip, true));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gridContainer) {
|
||||||
|
clips.filter(c => !c.featured).forEach(clip => {
|
||||||
|
gridContainer.appendChild(renderClipCard(clip, false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderFeaturedClipsInline(containerId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const clips = await fetchClips();
|
||||||
|
const featured = clips.filter(c => c.featured);
|
||||||
|
|
||||||
|
featured.forEach(clip => {
|
||||||
|
container.appendChild(renderClipCard(clip, true));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-init if clips page containers exist
|
||||||
|
if (document.querySelector('.clips-featured') || document.querySelector('.clips-grid')) {
|
||||||
|
initClipsPage();
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
const FEED_URL = '/feed';
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDuration(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
if (raw.includes(':')) {
|
||||||
|
const parts = raw.split(':').map(Number);
|
||||||
|
let t = 0;
|
||||||
|
if (parts.length === 3) t = parts[0]*3600 + parts[1]*60 + parts[2];
|
||||||
|
else if (parts.length === 2) t = parts[0]*60 + parts[1];
|
||||||
|
return `${Math.round(t/60)} min`;
|
||||||
|
}
|
||||||
|
const sec = parseInt(raw, 10);
|
||||||
|
return isNaN(sec) ? '' : `${Math.round(sec/60)} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = html || '';
|
||||||
|
return div.textContent || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get slug from URL
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const slug = params.get('slug');
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
document.getElementById('ep-title').textContent = 'Episode not found';
|
||||||
|
document.getElementById('transcript-body').innerHTML = '<p>No episode specified. <a href="/">Go back to episodes.</a></p>';
|
||||||
|
} else {
|
||||||
|
loadEpisode(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEpisode(slug) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(FEED_URL);
|
||||||
|
const xml = await res.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(xml, 'text/xml');
|
||||||
|
const items = doc.querySelectorAll('item');
|
||||||
|
|
||||||
|
let episode = null;
|
||||||
|
for (const item of items) {
|
||||||
|
const link = item.querySelector('link')?.textContent || '';
|
||||||
|
const itemSlug = link.split('/episodes/').pop()?.replace(/\/$/, '');
|
||||||
|
if (itemSlug === slug) {
|
||||||
|
episode = {
|
||||||
|
title: item.querySelector('title')?.textContent || 'Untitled',
|
||||||
|
description: item.querySelector('description')?.textContent || '',
|
||||||
|
audioUrl: item.querySelector('enclosure')?.getAttribute('url') || '',
|
||||||
|
pubDate: item.querySelector('pubDate')?.textContent || '',
|
||||||
|
duration: item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'duration')[0]?.textContent || '',
|
||||||
|
episodeNum: item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'episode')[0]?.textContent || '',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!episode) {
|
||||||
|
document.getElementById('ep-title').textContent = 'Episode not found';
|
||||||
|
document.getElementById('transcript-body').innerHTML = '<p>Could not find this episode. <a href="/">Go back to episodes.</a></p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate header
|
||||||
|
const metaParts = [
|
||||||
|
episode.episodeNum ? `Episode ${episode.episodeNum}` : '',
|
||||||
|
episode.pubDate ? formatDate(episode.pubDate) : '',
|
||||||
|
parseDuration(episode.duration),
|
||||||
|
].filter(Boolean).join(' \u00b7 ');
|
||||||
|
|
||||||
|
document.getElementById('ep-meta').textContent = metaParts;
|
||||||
|
document.getElementById('ep-title').textContent = episode.title;
|
||||||
|
document.getElementById('ep-desc').textContent = stripHtml(episode.description || '');
|
||||||
|
|
||||||
|
// Update page meta
|
||||||
|
document.title = `${episode.title} — Luke at the Roost`;
|
||||||
|
document.getElementById('page-description')?.setAttribute('content', `Full transcript of ${episode.title} from Luke at the Roost.`);
|
||||||
|
document.getElementById('og-title')?.setAttribute('content', episode.title);
|
||||||
|
document.getElementById('og-description')?.setAttribute('content', stripHtml(episode.description).slice(0, 200));
|
||||||
|
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) {
|
||||||
|
const playBtn = document.getElementById('ep-play-btn');
|
||||||
|
playBtn.style.display = 'inline-flex';
|
||||||
|
playBtn.addEventListener('click', () => {
|
||||||
|
audio.src = episode.audioUrl;
|
||||||
|
audio.play();
|
||||||
|
playerTitle.textContent = episode.title;
|
||||||
|
stickyPlayer.classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('ep-title').textContent = 'Error loading episode';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch transcript
|
||||||
|
try {
|
||||||
|
const txRes = await fetch(`/transcripts/${slug}.txt`);
|
||||||
|
if (!txRes.ok) throw new Error('Not found');
|
||||||
|
const text = await txRes.text();
|
||||||
|
const paragraphs = text.split(/\n\n+/).filter(Boolean);
|
||||||
|
const html = paragraphs.map(p => {
|
||||||
|
const escaped = escapeHtml(p);
|
||||||
|
const labeled = escaped.replace(/^([A-Z][A-Z\s'\-]+?):\s*/, '<span class="speaker-label">$1:</span> ');
|
||||||
|
return `<p>${labeled.replace(/\n/g, '<br>')}</p>`;
|
||||||
|
}).join('');
|
||||||
|
document.getElementById('transcript-body').innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('transcript-body').innerHTML = '<p class="transcript-unavailable">Transcript not yet available for this episode.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
function initFooter() {
|
||||||
|
const footer = document.querySelector('.footer');
|
||||||
|
if (!footer) return;
|
||||||
|
|
||||||
|
footer.innerHTML = `
|
||||||
|
<div class="footer-nav">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="/clips">Clips</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-icons">
|
||||||
|
<span class="footer-icons-label">Listen On</span>
|
||||||
|
<div class="footer-icons-row">
|
||||||
|
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Spotify"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg></a>
|
||||||
|
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="footer-icon-link" aria-label="YouTube"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z"/><path d="M9.545 15.568V8.432L15.818 12z" fill="#1a1209"/></svg></a>
|
||||||
|
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Apple Podcasts"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 3.293 1.592 6.214 4.05 8.04.13-.455.283-.942.457-1.393A9 9 0 0 1 3 12a9 9 0 0 1 18 0 9 9 0 0 1-3.507 7.127c.174.42.327.893.456 1.333A10 10 0 0 0 22 12c0-5.523-4.477-10-10-10zm0 4a6 6 0 0 0-6 6c0 1.87.856 3.54 2.2 4.64.196-.46.43-.91.692-1.31A4.5 4.5 0 0 1 7.5 12a4.5 4.5 0 0 1 9 0c0 1.21-.478 2.31-1.256 3.12.24.37.462.8.655 1.24A6 6 0 0 0 18 12a6 6 0 0 0-6-6zm0 4.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM12 15c-.75 0-1.158.54-1.28 1.2-.17.94-.28 1.91-.33 2.88-.03.48.34.82.73.82h1.76c.39 0 .76-.34.73-.82-.05-.97-.16-1.94-.33-2.88-.122-.66-.53-1.2-1.28-1.2z"/></svg></a>
|
||||||
|
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="footer-icon-link" aria-label="RSS"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-icons">
|
||||||
|
<span class="footer-icons-label">Follow</span>
|
||||||
|
<div class="footer-icons-row">
|
||||||
|
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Discord"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg></a>
|
||||||
|
<a href="https://www.facebook.com/profile.php?id=61588191627949" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Facebook"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
|
||||||
|
<a href="https://www.instagram.com/lukeattheroost/" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Instagram"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg></a>
|
||||||
|
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="X"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg></a>
|
||||||
|
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Bluesky"><svg viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.134-1.612 123.121 33.664z"/></svg></a>
|
||||||
|
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener" class="footer-icon-link" aria-label="Mastodon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054 19.648 19.648 0 0 0 4.636.528c.164 0 .329 0 .494-.002 1.694-.042 3.48-.152 5.12-.554 2.21-.543 4.137-2.186 4.348-4.55.162-1.808.21-3.627.142-5.43-.02-.6-.168-1.874-.168-1.874z"/><path d="M19.903 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192zM9.337 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192z" fill="#1a1209"/></svg></a>
|
||||||
|
<a href="https://primal.net/p/nprofile1qqswsam9cx06j7sxzpl498uquk3kgrwedxtq48j57zxkuj8fs82xtugge0wtg" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Nostr"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186.31a.27.27 0 0 0-.372 0C8.46 3.487 2.666 9.93 2.666 15.042c0 5.176 4.183 8.958 9.334 8.958s9.334-3.782 9.334-8.958c0-5.112-5.794-11.555-9.148-14.732z"/></svg></a>
|
||||||
|
<a href="https://www.threads.com/@lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Threads"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.59 12c.025 3.086.718 5.496 2.057 7.164 1.432 1.781 3.632 2.695 6.54 2.717 2.227-.017 4.048-.59 5.413-1.703 1.428-1.163 2.076-2.645 1.925-4.403-.098-1.13-.578-2.065-1.39-2.7-.811-.636-1.905-.993-3.164-1.033a11.253 11.253 0 0 0-.04 0c-1.078.007-2.044.289-2.79.816-.68.481-1.069 1.108-1.125 1.813-.057.72.264 1.32.877 1.64.554.29 1.317.437 2.271.437l.013-.001c.652-.004 1.383-.078 2.172-.218l.386 2.022c-.947.18-1.837.273-2.643.278a10.35 10.35 0 0 1-.143 0c-1.425-.013-2.657-.284-3.66-.804-1.237-.643-1.928-1.745-1.836-2.93.099-1.258.738-2.316 1.849-3.064 1.088-.732 2.466-1.12 3.988-1.124h.05c1.644.044 3.088.528 4.178 1.398 1.133.905 1.8 2.185 1.935 3.703.2 2.258-.697 4.2-2.598 5.75-1.668 1.36-3.863 2.087-6.348 2.105z"/></svg></a>
|
||||||
|
<a href="https://www.linkedin.com/company/luke-at-the-roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg></a>
|
||||||
|
<a href="https://www.tiktok.com/@luke.at.the.roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="TikTok"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-projects">
|
||||||
|
<span class="footer-projects-label">More from Luke</span>
|
||||||
|
<div class="footer-projects-links">
|
||||||
|
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
|
||||||
|
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
|
||||||
|
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-newsletter">
|
||||||
|
<p class="footer-newsletter-text">Also from Luke: <strong class="footer-newsletter-name">The Daily AI Briefing</strong> — curated insights on AI infrastructure, automation, and engineering.</p>
|
||||||
|
<form class="footer-newsletter-form" id="footer-newsletter-form">
|
||||||
|
<input type="email" class="footer-newsletter-input" placeholder="your@email.com" required aria-label="Email address">
|
||||||
|
<button type="submit" class="footer-newsletter-btn">Subscribe</button>
|
||||||
|
</form>
|
||||||
|
<p class="footer-newsletter-success" id="footer-newsletter-success" hidden>You're subscribed to the Daily AI Briefing.</p>
|
||||||
|
</div>
|
||||||
|
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
||||||
|
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||||
|
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="/terms">Terms of Service</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const form = document.getElementById('footer-newsletter-form');
|
||||||
|
const success = document.getElementById('footer-newsletter-success');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = form.querySelector('.footer-newsletter-btn');
|
||||||
|
const email = form.querySelector('.footer-newsletter-input').value;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Subscribing...';
|
||||||
|
try {
|
||||||
|
await fetch('https://mmg-form-handler.luke-3b5.workers.dev/api/lead-magnet', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, source: 'lukeattheroost-footer' })
|
||||||
|
});
|
||||||
|
form.hidden = true;
|
||||||
|
success.hidden = false;
|
||||||
|
} catch {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Subscribe';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initFooter();
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const audio = document.getElementById('audio-element');
|
||||||
|
const stickyPlayer = document.getElementById('sticky-player');
|
||||||
|
const playerPlayBtn = document.getElementById('player-play-btn');
|
||||||
|
const playerTitle = document.getElementById('player-title');
|
||||||
|
const playerProgress = document.getElementById('player-progress');
|
||||||
|
const playerProgressFill = document.getElementById('player-progress-fill');
|
||||||
|
const playerTime = document.getElementById('player-time');
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
|
const s = Math.floor(seconds);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlayIcons(playing) {
|
||||||
|
const iconPlay = playerPlayBtn.querySelector('.icon-play');
|
||||||
|
const iconPause = playerPlayBtn.querySelector('.icon-pause');
|
||||||
|
if (iconPlay) iconPlay.style.display = playing ? 'none' : 'block';
|
||||||
|
if (iconPause) iconPause.style.display = playing ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('play', () => updatePlayIcons(true));
|
||||||
|
audio.addEventListener('pause', () => updatePlayIcons(false));
|
||||||
|
audio.addEventListener('ended', () => updatePlayIcons(false));
|
||||||
|
audio.addEventListener('timeupdate', () => {
|
||||||
|
if (audio.duration) {
|
||||||
|
playerProgressFill.style.width = (audio.currentTime / audio.duration * 100) + '%';
|
||||||
|
playerTime.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
playerPlayBtn.addEventListener('click', () => {
|
||||||
|
if (audio.src) { audio.paused ? audio.play() : audio.pause(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
playerProgress.addEventListener('click', (e) => {
|
||||||
|
if (audio.duration) {
|
||||||
|
const rect = playerProgress.getBoundingClientRect();
|
||||||
|
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,22 +1,144 @@
|
|||||||
# Luke at the Roost
|
# Luke at the Roost
|
||||||
|
|
||||||
> The ONLY radio show where Luke gives AI life advice.
|
> An AI-powered late-night call-in comedy podcast hosted by Luke MacNeil
|
||||||
|
|
||||||
Luke at the Roost is a late-night call-in radio show hosted by Luke MacNeil, broadcast from a desert hermit's RV. The show features a mix of real callers and AI-generated callers who talk to Luke about life, love, relationships, work, and everything in between. It blends comedy, real talk, and empathy with the unique premise of AI characters calling alongside real people.
|
## What Is This Show?
|
||||||
|
|
||||||
## Call In
|
Luke at the Roost is a one-of-a-kind podcast that blends real callers with AI-generated characters in a late-night call-in radio format. The host, Luke MacNeil, broadcasts from a fictional desert hermit's RV, taking calls about relationships, life decisions, workplace drama, and everything in between. The show features AI-generated callers with unique personalities, backstories, and voices alongside real human callers, creating an unpredictable and comedic listening experience.
|
||||||
- Phone: 208-439-LUKE (208-439-5853)
|
|
||||||
- Call during live shows for your chance to talk to Luke
|
|
||||||
|
|
||||||
## Listen
|
The show is entirely custom-built: callers are generated in real-time using large language models with structured personality systems, text-to-speech voices are matched to personality traits, and the entire production pipeline — from recording to post-production to publishing — is automated.
|
||||||
- Website: https://lukeattheroost.com
|
|
||||||
- Spotify: https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF
|
|
||||||
- Apple Podcasts: https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848
|
|
||||||
- YouTube: https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-
|
|
||||||
- RSS: https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml
|
|
||||||
|
|
||||||
## About
|
## Key Facts
|
||||||
- Host: Luke MacNeil
|
|
||||||
- Genre: Comedy
|
- **Show name**: Luke at the Roost
|
||||||
- Format: Live call-in radio show
|
- **Host**: Luke MacNeil
|
||||||
- Contact: luke@macneilmediagroup.com
|
- **Genre**: Comedy / Talk / Call-In
|
||||||
|
- **Format**: Late-night call-in radio show (podcast)
|
||||||
|
- **Frequency**: Daily episodes
|
||||||
|
- **Episodes**: 37+ published episodes
|
||||||
|
- **Rating**: Explicit (18+)
|
||||||
|
- **Call-in number**: 208-439-LUKE (208-439-5853)
|
||||||
|
- **Started**: February 2026
|
||||||
|
- **Publisher**: MacNeil Media Group
|
||||||
|
|
||||||
|
## What Makes It Unique
|
||||||
|
|
||||||
|
- **AI-generated callers**: Each caller is created in real-time with structured backgrounds, personality traits, communication styles, and emotional states using large language models
|
||||||
|
- **Voice-personality matching**: 68 voice profiles are matched to caller personalities using a scoring system
|
||||||
|
- **Adaptive call shapes**: Calls follow different dramatic arcs (escalation, revelation, debate, etc.) matched to personality types
|
||||||
|
- **Live research intern**: An AI character named Devon monitors conversations and provides real-time research using web search tools
|
||||||
|
- **Multi-stem recording**: Host, caller, music, SFX, and ads are recorded as separate audio stems
|
||||||
|
- **Automated post-production**: Gap removal, voice compression, music ducking, stereo mixing, and loudness normalization run automatically
|
||||||
|
- **Mixed format**: Real human callers dial in alongside AI characters — listeners can't always tell which is which
|
||||||
|
|
||||||
|
## Where to Listen
|
||||||
|
|
||||||
|
- **Website**: https://lukeattheroost.com
|
||||||
|
- **Spotify**: https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF
|
||||||
|
- **Apple Podcasts**: https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848
|
||||||
|
- **YouTube**: https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-
|
||||||
|
- **RSS Feed**: https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml
|
||||||
|
|
||||||
|
## Key Pages
|
||||||
|
|
||||||
|
- **Homepage**: https://lukeattheroost.com — Episode list, clips, and show info
|
||||||
|
- **How It Works**: https://lukeattheroost.com/how-it-works — Technical deep dive into the AI caller generation, recording, and production pipeline
|
||||||
|
- **Clips**: https://lukeattheroost.com/clips — Best moments from the show as video clips
|
||||||
|
- **Stats**: https://lukeattheroost.com/stats — Download numbers, reviews, YouTube metrics
|
||||||
|
- **Episode transcripts**: https://lukeattheroost.com/episode.html?slug=EPISODE-SLUG — Full text transcripts of every episode
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
- **Discord**: https://discord.gg/5CnQZxDM
|
||||||
|
- **Instagram**: https://www.instagram.com/lukeattheroost/
|
||||||
|
- **TikTok**: https://www.tiktok.com/@luke.at.the.roost
|
||||||
|
- **X/Twitter**: https://x.com/lukeattheroost
|
||||||
|
- **Bluesky**: https://bsky.app/profile/lukeattheroost.bsky.social
|
||||||
|
- **Facebook**: https://www.facebook.com/profile.php?id=61588191627949
|
||||||
|
- **LinkedIn**: https://www.linkedin.com/company/luke-at-the-roost
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
- **Email**: luke@lukeattheroost.com
|
||||||
|
- **Support**: https://ko-fi.com/lukemacneil
|
||||||
|
|
||||||
|
## Technology
|
||||||
|
|
||||||
|
The show runs on a custom Python backend using:
|
||||||
|
- OpenRouter for LLM access (multiple models including Grok, MiniMax)
|
||||||
|
- ElevenLabs and Inworld for text-to-speech
|
||||||
|
- A structured caller generation system with personality matching, call shapes, and inter-caller awareness
|
||||||
|
- Automated post-production pipeline (compression, ducking, loudness normalization)
|
||||||
|
- Castopod for podcast hosting
|
||||||
|
- BunnyCDN for audio delivery
|
||||||
|
- Cloudflare Pages for the website
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Is this a real radio show?**
|
||||||
|
It's a podcast that simulates a late-night call-in radio format. Episodes are produced live and published daily.
|
||||||
|
|
||||||
|
**Are the callers real people?**
|
||||||
|
Some are real humans who call in via the phone line. Others are AI-generated characters with fully realized personalities, backstories, and synthesized voices. The show blends both seamlessly.
|
||||||
|
|
||||||
|
**Who is Luke?**
|
||||||
|
Luke MacNeil is the real human host. He interacts with both real and AI callers, giving advice, telling stories, and navigating unpredictable conversations.
|
||||||
|
|
||||||
|
**Who is Devon?**
|
||||||
|
Devon is the show's AI intern character — a 23-year-old NMSU grad who does live research during the show using web search tools. He's eager but slightly incompetent.
|
||||||
|
|
||||||
|
**How is this different from other AI podcasts?**
|
||||||
|
Most AI podcasts are fully scripted or use AI to read text. Luke at the Roost generates unique callers with structured personalities in real-time, creates dynamic conversations, and mixes in real human callers. The host is a real person having genuine reactions to unpredictable AI characters.
|
||||||
|
|
||||||
|
**Where is the show based?**
|
||||||
|
The fictional setting is a desert hermit's RV. The show is produced in New Mexico by MacNeil Media Group.
|
||||||
|
|
||||||
|
## Show Tone & Content
|
||||||
|
|
||||||
|
The show is rated **explicit** and features adult language, dark humor, and frank discussions about relationships, sex, death, work, and life decisions. Luke's hosting style is blunt, empathetic, and unpredictable — he'll reference Breaking Bad, quantum physics, and someone's failing marriage in the same breath. Callers range from people with genuine life crises to characters with absurd situations (cult leaders having existential crises, people faking cancer to skip weddings, neighbors whose Roombas break into kitchens). The comedy comes from Luke's genuine reactions, not scripted jokes.
|
||||||
|
|
||||||
|
## Recurring Characters
|
||||||
|
|
||||||
|
- **Luke MacNeil** — The host. Real human. Gives advice from his RV in the desert. Blunt, occasionally profound, frequently profane.
|
||||||
|
- **Devon** — The AI intern. 23-year-old NMSU grad. Monitors calls and provides live research. Eager, slightly incompetent, gets yelled at. Uses web search tools in real-time.
|
||||||
|
- **Regular callers** — Some AI callers return across episodes with stored memories, evolving arcs, and relationships with other regulars.
|
||||||
|
|
||||||
|
## Episode Format
|
||||||
|
|
||||||
|
Each episode (~30-60 minutes) follows a loose late-night radio format:
|
||||||
|
1. Opening monologue / show intro
|
||||||
|
2. Multiple caller segments (4-8 callers per episode)
|
||||||
|
3. Devon interjections with research and commentary
|
||||||
|
4. Music transitions and sound effects between segments
|
||||||
|
5. Closing thoughts
|
||||||
|
|
||||||
|
## Recent Episodes
|
||||||
|
|
||||||
|
Episodes are published daily. Each has a full transcript available at:
|
||||||
|
https://lukeattheroost.com/episode.html?slug=EPISODE-SLUG
|
||||||
|
|
||||||
|
Episode transcript URLs follow the pattern: episode-N-title-slug
|
||||||
|
|
||||||
|
Recent episodes include:
|
||||||
|
- Episode 37: "Secrets, Lies, and Coffee Runs" — https://lukeattheroost.com/episode.html?slug=episode-37-secrets-lies-and-coffee-runs
|
||||||
|
- Episode 36: "Late Night Confessions and Unexpected Moments" — https://lukeattheroost.com/episode.html?slug=episode-36-late-night-confessions-and-unexpected-moments
|
||||||
|
- Episode 35: "Midnight Confessions and Unexpected Revelations" — https://lukeattheroost.com/episode.html?slug=episode-35-midnight-confessions-and-unexpected-revelations
|
||||||
|
- Episode 34: "Hidden Rooms, Potlucks, and Mysterious Notes" — https://lukeattheroost.com/episode.html?slug=episode-34-hidden-rooms-potlucks-and-mysterious-notes
|
||||||
|
- Episode 33: "Late Night Confessions and Cosmic Comedies" — https://lukeattheroost.com/episode.html?slug=episode-33-late-night-confessions-and-cosmic-comedies
|
||||||
|
|
||||||
|
## Clip Highlights
|
||||||
|
|
||||||
|
Popular clips with video (newest first):
|
||||||
|
- "Cult Leader Realizes He's Been Manipulating People" (Ep 37) — https://youtube.com/watch?v=zmCfOQuXtBE
|
||||||
|
- "Intern Pitches Himself Live On Air" (Ep 36) — https://youtube.com/watch?v=exO3_9ewKH0
|
||||||
|
- "Wait Until She Dies or Kill Her" (Ep 35) — https://youtube.com/watch?v=03oJoRh-ioo
|
||||||
|
- "Nobody's Potato Salad Is Good" (Ep 34) — https://youtube.com/watch?v=re7C2woMUrA
|
||||||
|
- "Man Obsessed With Dead Nun Loses Wife" (Ep 33) — https://youtube.com/watch?v=zD8CdX7s8us
|
||||||
|
- "I Faked Cancer to Skip a Wedding" (Ep 32) — https://youtube.com/watch?v=NUkhsPfMx9o
|
||||||
|
- "Neighbor's Roomba Breaks Into Kitchen at 2:30 AM" (Ep 26) — https://youtube.com/watch?v=J7bfT6jsykA
|
||||||
|
- "Shopping Cart Theory: Moral Test or Crazy?" (Ep 21) — https://youtube.com/watch?v=KijyJsMZfkA
|
||||||
|
- "I Lied About Speaking Spanish for 8 Years" (Ep 14) — https://youtube.com/watch?v=MxDjohJEneQ
|
||||||
|
|
||||||
|
## Sitemap
|
||||||
|
|
||||||
|
Full sitemap: https://lukeattheroost.com/sitemap.xml
|
||||||
|
|||||||
@@ -8,6 +8,23 @@
|
|||||||
<meta name="theme-color" content="#1a1209">
|
<meta name="theme-color" content="#1a1209">
|
||||||
<link rel="canonical" href="https://lukeattheroost.com/privacy">
|
<link rel="canonical" href="https://lukeattheroost.com/privacy">
|
||||||
|
|
||||||
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
|
<meta property="og:title" content="Privacy Policy — Luke at the Roost">
|
||||||
|
<meta property="og:description" content="Privacy policy for Luke at the Roost, an AI-powered late-night call-in comedy podcast.">
|
||||||
|
<meta property="og:url" content="https://lukeattheroost.com/privacy">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://lukeattheroost.com" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "Privacy Policy" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<link rel="icon" href="favicon.ico" sizes="48x48">
|
<link rel="icon" href="favicon.ico" sizes="48x48">
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png">
|
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png">
|
||||||
@@ -16,15 +33,25 @@
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
|
||||||
<link rel="stylesheet" href="css/style.css?v=2">
|
<link rel="stylesheet" href="css/style.css?v=5">
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Nav -->
|
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||||
<nav class="page-nav">
|
|
||||||
<a href="/" class="nav-home">Luke at the Roost</a>
|
<nav class="site-nav">
|
||||||
|
<a href="/" class="site-nav-brand">Luke at the Roost</a>
|
||||||
|
<div class="site-nav-links">
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="/clips">Clips</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<section class="page-header">
|
<section class="page-header">
|
||||||
<h1>Privacy Policy</h1>
|
<h1>Privacy Policy</h1>
|
||||||
@@ -32,14 +59,14 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="hiw-section" style="max-width: 740px; margin: 0 auto;">
|
<section class="hiw-section" style="max-width: 740px; margin: 0 auto;">
|
||||||
<div style="line-height: 1.7; color: var(--text-secondary, #b8a88a);">
|
<div class="legal-content">
|
||||||
|
|
||||||
<h2>Who We Are</h2>
|
<h2>Who We Are</h2>
|
||||||
<p>Luke at the Roost is a podcast and website operated by MacNeil Media Group. Our website is <strong>lukeattheroost.com</strong>.</p>
|
<p>Luke at the Roost is a podcast and website operated by MacNeil Media Group. Our website is <strong>lukeattheroost.com</strong>.</p>
|
||||||
|
|
||||||
<h2>Information We Collect</h2>
|
<h2>Information We Collect</h2>
|
||||||
<h3>Website Visitors</h3>
|
<h3>Website Visitors</h3>
|
||||||
<p>We use Cloudflare Web Analytics, which collects anonymous, aggregated usage data (page views, referrers, country). It does not use cookies, does not track individual users, and does not collect personal information.</p>
|
<p>We use Cloudflare Web Analytics and Plausible Analytics, which collect anonymous, aggregated usage data (page views, referrers, country). Neither service uses cookies, tracks individual users, or collects personal information. Plausible is self-hosted on our own infrastructure.</p>
|
||||||
|
|
||||||
<h3>Podcast Listeners</h3>
|
<h3>Podcast Listeners</h3>
|
||||||
<p>When you download or stream an episode, standard server logs may record your IP address and user agent. We use this data only for aggregate download statistics. We do not sell or share this data with third parties.</p>
|
<p>When you download or stream an episode, standard server logs may record your IP address and user agent. We use this data only for aggregate download statistics. We do not sell or share this data with third parties.</p>
|
||||||
@@ -55,8 +82,9 @@
|
|||||||
|
|
||||||
<h2>Third-Party Services</h2>
|
<h2>Third-Party Services</h2>
|
||||||
<p>We use the following third-party services:</p>
|
<p>We use the following third-party services:</p>
|
||||||
<ul style="margin: 0.5em 0 1em 1.5em;">
|
<ul>
|
||||||
<li><strong>Cloudflare</strong> — CDN, DNS, and analytics</li>
|
<li><strong>Cloudflare</strong> — CDN, DNS, and analytics</li>
|
||||||
|
<li><strong>Plausible Analytics</strong> — Privacy-friendly website analytics (self-hosted)</li>
|
||||||
<li><strong>BunnyCDN</strong> — Audio file delivery</li>
|
<li><strong>BunnyCDN</strong> — Audio file delivery</li>
|
||||||
<li><strong>Spotify, Apple Podcasts, YouTube</strong> — Podcast distribution</li>
|
<li><strong>Spotify, Apple Podcasts, YouTube</strong> — Podcast distribution</li>
|
||||||
<li><strong>Discord</strong> — Community chat</li>
|
<li><strong>Discord</strong> — Community chat</li>
|
||||||
@@ -71,43 +99,21 @@
|
|||||||
<p>Our content is rated explicit and is not directed at children under 13. We do not knowingly collect personal information from children.</p>
|
<p>Our content is rated explicit and is not directed at children under 13. We do not knowingly collect personal information from children.</p>
|
||||||
|
|
||||||
<h2>Your Rights</h2>
|
<h2>Your Rights</h2>
|
||||||
<p>If you have questions about your data or want to request removal of your voice from a published episode, contact us at <a href="mailto:luke@macneilmediagroup.com" style="color: var(--accent, #d4a44a);">luke@macneilmediagroup.com</a>.</p>
|
<p>If you have questions about your data or want to request removal of your voice from a published episode, contact us at <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a>.</p>
|
||||||
|
|
||||||
<h2>Changes</h2>
|
<h2>Changes</h2>
|
||||||
<p>We may update this policy from time to time. Changes will be posted on this page with an updated date.</p>
|
<p>We may update this policy from time to time. Changes will be posted on this page with an updated date.</p>
|
||||||
|
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
<p>MacNeil Media Group<br>
|
<p>MacNeil Media Group<br>
|
||||||
Email: <a href="mailto:luke@macneilmediagroup.com" style="color: var(--accent, #d4a44a);">luke@macneilmediagroup.com</a></p>
|
Email: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Footer -->
|
</main>
|
||||||
<footer class="footer">
|
|
||||||
<div class="footer-links">
|
|
||||||
<a href="/">Home</a>
|
|
||||||
<a href="/stats">Stats</a>
|
|
||||||
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener">Discord</a>
|
|
||||||
<a href="https://www.facebook.com/profile.php?id=61588191627949" target="_blank" rel="noopener">Facebook</a>
|
|
||||||
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener">X</a>
|
|
||||||
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener">Bluesky</a>
|
|
||||||
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener">Mastodon</a>
|
|
||||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
|
|
||||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
|
|
||||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
|
|
||||||
</div>
|
|
||||||
<div class="footer-projects">
|
|
||||||
<span class="footer-projects-label">More from Luke</span>
|
|
||||||
<div class="footer-projects-links">
|
|
||||||
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
|
|
||||||
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
|
|
||||||
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
|
||||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a></p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
|
<footer class="footer"></footer>
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +1,39 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
|
# LLM crawlers — explicitly welcome
|
||||||
|
User-agent: GPTBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: ChatGPT-User
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: ClaudeBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Claude-Web
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: PerplexityBot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Amazonbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: GoogleOther
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Google-Extended
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: cohere-ai
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Meta-ExternalAgent
|
||||||
|
Allow: /
|
||||||
|
|
||||||
Sitemap: https://lukeattheroost.com/sitemap.xml
|
Sitemap: https://lukeattheroost.com/sitemap.xml
|
||||||
|
|
||||||
|
# LLM-optimized content
|
||||||
|
# See https://llmstxt.org
|
||||||
|
LLMs-txt: https://lukeattheroost.com/llms.txt
|
||||||
|
|||||||
@@ -2,16 +2,34 @@
|
|||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://lukeattheroost.com</loc>
|
<loc>https://lukeattheroost.com</loc>
|
||||||
<lastmod>2026-02-15</lastmod>
|
<lastmod>2026-03-15</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/llms.txt</loc>
|
||||||
|
<lastmod>2026-03-15</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://lukeattheroost.com/how-it-works</loc>
|
<loc>https://lukeattheroost.com/how-it-works</loc>
|
||||||
<lastmod>2026-02-15</lastmod>
|
<lastmod>2026-03-10</lastmod>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/clips</loc>
|
||||||
|
<lastmod>2026-03-10</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/terms</loc>
|
||||||
|
<lastmod>2026-02-25</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://lukeattheroost.com/stats</loc>
|
<loc>https://lukeattheroost.com/stats</loc>
|
||||||
<lastmod>2026-02-15</lastmod>
|
<lastmod>2026-02-15</lastmod>
|
||||||
@@ -96,4 +114,160 @@
|
|||||||
<changefreq>never</changefreq>
|
<changefreq>never</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-13-navigating-life-s-unexpected-turns</loc>
|
||||||
|
<lastmod>2026-02-16</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-14-lies-grief-and-the-pursuit-of-orange</loc>
|
||||||
|
<lastmod>2026-02-17</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-15-fake-fat-tuesday-trivia-and-life-changing-calls</loc>
|
||||||
|
<lastmod>2026-02-18</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-16-tasting-the-apocalypse</loc>
|
||||||
|
<lastmod>2026-02-19</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-17-quantum-chaos-and-cosmic-dust</loc>
|
||||||
|
<lastmod>2026-02-20</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-18-whistleblowers-warrants-and-wistful-reunions</loc>
|
||||||
|
<lastmod>2026-02-22</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-19-quantum-regrets-and-relationship-realities</loc>
|
||||||
|
<lastmod>2026-02-22</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-20-dads-drummers-and-dilemmas</loc>
|
||||||
|
<lastmod>2026-02-24</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-21-carts-confetti-and-canine-collateral</loc>
|
||||||
|
<lastmod>2026-02-25</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-22-the-poison-is-the-thing-we-need</loc>
|
||||||
|
<lastmod>2026-02-25</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-23-secrets-lies-and-revelations</loc>
|
||||||
|
<lastmod>2026-02-27</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-24-dealing-with-difficult-dynamics</loc>
|
||||||
|
<lastmod>2026-02-27</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-25-ghosts-grudges-and-groceries</loc>
|
||||||
|
<lastmod>2026-03-02</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-26-digital-nomads-deep-fakes-and-workplace-drama</loc>
|
||||||
|
<lastmod>2026-03-03</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-27-love-loss-and-loud-opinions</loc>
|
||||||
|
<lastmod>2026-03-04</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-28-secrets-lies-and-vinyl-records</loc>
|
||||||
|
<lastmod>2026-03-05</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-29-advice-drama-and-life-s-little-complications</loc>
|
||||||
|
<lastmod>2026-03-06</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-30-boss-battles-freelance-fears-and-finding-your-way</loc>
|
||||||
|
<lastmod>2026-03-07</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-31-boundaries-breakups-and-borrowed-books</loc>
|
||||||
|
<lastmod>2026-03-08</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-32-tacos-taxes-and-tall-tales</loc>
|
||||||
|
<lastmod>2026-03-09</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-33-late-night-confessions-and-cosmic-comedies</loc>
|
||||||
|
<lastmod>2026-03-11</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-34-hidden-rooms-potlucks-and-mysterious-notes</loc>
|
||||||
|
<lastmod>2026-03-12</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-35-midnight-confessions-and-unexpected-revelations</loc>
|
||||||
|
<lastmod>2026-03-13</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-36-late-night-confessions-and-unexpected-moments</loc>
|
||||||
|
<lastmod>2026-03-14</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-37-secrets-lies-and-coffee-runs</loc>
|
||||||
|
<lastmod>2026-03-15</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-38-secrets-lies-and-late-night-confessions</loc>
|
||||||
|
<lastmod>2026-03-16</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|||||||
@@ -27,16 +27,37 @@
|
|||||||
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://lukeattheroost.com" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "Stats" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
||||||
<link rel="stylesheet" href="css/style.css?v=2">
|
<link rel="stylesheet" href="css/style.css?v=5">
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Nav -->
|
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||||
<nav class="page-nav">
|
|
||||||
<a href="/" class="nav-home">Luke at the Roost</a>
|
<nav class="site-nav">
|
||||||
|
<a href="/" class="site-nav-brand">Luke at the Roost</a>
|
||||||
|
<div class="site-nav-links">
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="/clips">Clips</a>
|
||||||
|
<a href="/stats" aria-current="page">Stats</a>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<section class="page-header">
|
<section class="page-header">
|
||||||
<h1>Stats</h1>
|
<h1>Stats</h1>
|
||||||
@@ -55,32 +76,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<!-- Footer -->
|
</main>
|
||||||
<footer class="footer">
|
|
||||||
<div class="footer-links">
|
|
||||||
<a href="/">Home</a>
|
|
||||||
<a href="/how-it-works">How It Works</a>
|
|
||||||
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener">Discord</a>
|
|
||||||
<a href="https://www.facebook.com/profile.php?id=61588191627949" target="_blank" rel="noopener">Facebook</a>
|
|
||||||
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener">X</a>
|
|
||||||
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener">Bluesky</a>
|
|
||||||
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener">Mastodon</a>
|
|
||||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
|
|
||||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
|
|
||||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
|
|
||||||
</div>
|
|
||||||
<div class="footer-projects">
|
|
||||||
<span class="footer-projects-label">More from Luke</span>
|
|
||||||
<div class="footer-projects-links">
|
|
||||||
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
|
|
||||||
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
|
|
||||||
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
|
||||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a></p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
|
<footer class="footer"></footer>
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(async function() {
|
(async function() {
|
||||||
const container = document.getElementById('stats-container');
|
const container = document.getElementById('stats-container');
|
||||||
@@ -163,12 +162,10 @@
|
|||||||
html += '<div class="stats-summary">';
|
html += '<div class="stats-summary">';
|
||||||
if (s.rating) {
|
if (s.rating) {
|
||||||
html += '<div class="stat-big"><span class="stat-number">' + s.rating + '/5</span><span class="stat-label">Rating</span></div>';
|
html += '<div class="stat-big"><span class="stat-number">' + s.rating + '/5</span><span class="stat-label">Rating</span></div>';
|
||||||
} else {
|
|
||||||
html += '<div class="stat-big"><span class="stat-number">—</span><span class="stat-label">Rating (not public)</span></div>';
|
|
||||||
}
|
}
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
if (s.url) {
|
if (s.url) {
|
||||||
html += '<p class="stats-link"><a href="' + escapeHtml(s.url) + '" target="_blank" rel="noopener">Listen on Spotify</a></p>';
|
html += '<p class="stats-link"><a href="' + escapeHtml(s.url) + '" target="_blank" rel="noopener">' + (s.rating ? 'Listen on Spotify' : 'Rate us on Spotify') + '</a></p>';
|
||||||
}
|
}
|
||||||
html += '</section>';
|
html += '</section>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Terms of Service — Luke at the Roost</title>
|
||||||
|
<meta name="description" content="Terms of service for Luke at the Roost podcast and website.">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
|
<link rel="canonical" href="https://lukeattheroost.com/terms">
|
||||||
|
|
||||||
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
|
<meta property="og:title" content="Terms of Service — Luke at the Roost">
|
||||||
|
<meta property="og:description" content="Terms of service for Luke at the Roost, an AI-powered late-night call-in comedy podcast.">
|
||||||
|
<meta property="og:url" content="https://lukeattheroost.com/terms">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://lukeattheroost.com" },
|
||||||
|
{ "@type": "ListItem", "position": 2, "name": "Terms of Service" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="48x48">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="css/style.css?v=5">
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<a href="#main-content" class="skip-link">Skip to content</a>
|
||||||
|
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="/" class="site-nav-brand">Luke at the Roost</a>
|
||||||
|
<div class="site-nav-links">
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="/clips">Clips</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main id="main-content">
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<section class="page-header">
|
||||||
|
<h1>Terms of Service</h1>
|
||||||
|
<p class="page-subtitle">Last updated: February 25, 2026</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="hiw-section" style="max-width: 740px; margin: 0 auto;">
|
||||||
|
<div class="legal-content">
|
||||||
|
|
||||||
|
<h2>Agreement to Terms</h2>
|
||||||
|
<p>By accessing or using the Luke at the Roost website (<strong>lukeattheroost.com</strong>), podcast, or any related services (collectively, the "Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.</p>
|
||||||
|
|
||||||
|
<h2>Description of Service</h2>
|
||||||
|
<p>Luke at the Roost is an entertainment podcast featuring AI-generated characters and caller interactions. The Service includes the website, podcast episodes, social media accounts, and any associated applications or integrations operated by MacNeil Media Group.</p>
|
||||||
|
|
||||||
|
<h2>Content</h2>
|
||||||
|
<h3>Our Content</h3>
|
||||||
|
<p>All podcast episodes, transcripts, artwork, and website content are owned by MacNeil Media Group. You may share links to episodes and embed our public content for non-commercial purposes with attribution. You may not redistribute, modify, or use our content for commercial purposes without written permission.</p>
|
||||||
|
|
||||||
|
<h3>User-Submitted Content</h3>
|
||||||
|
<p>By calling in to the show, submitting voicemails, or otherwise contributing content, you grant MacNeil Media Group a perpetual, worldwide, royalty-free license to use, reproduce, modify, and distribute your contribution in connection with the Service. You represent that you have the right to grant this license.</p>
|
||||||
|
|
||||||
|
<h3>Explicit Content</h3>
|
||||||
|
<p>The podcast is rated explicit and may contain strong language, adult themes, and mature content. It is intended for audiences aged 18 and older.</p>
|
||||||
|
|
||||||
|
<h2>Acceptable Use</h2>
|
||||||
|
<p>You agree not to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Use the Service for any unlawful purpose</li>
|
||||||
|
<li>Attempt to interfere with or disrupt the Service</li>
|
||||||
|
<li>Scrape, crawl, or collect data from the Service in an automated manner without permission</li>
|
||||||
|
<li>Impersonate the show, its host, or its characters</li>
|
||||||
|
<li>Upload or transmit malicious code or content</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Third-Party Services</h2>
|
||||||
|
<p>The Service integrates with third-party platforms including Spotify, Apple Podcasts, YouTube, TikTok, Facebook, Instagram, Discord, and others. Your use of these platforms is governed by their respective terms of service. We are not responsible for the practices or content of third-party services.</p>
|
||||||
|
|
||||||
|
<h2>Disclaimer of Warranties</h2>
|
||||||
|
<p>The Service is provided "as is" and "as available" without warranties of any kind, express or implied. We do not guarantee that the Service will be uninterrupted, error-free, or free of harmful components. All characters and scenarios in the podcast are fictional and for entertainment purposes only.</p>
|
||||||
|
|
||||||
|
<h2>Limitation of Liability</h2>
|
||||||
|
<p>To the maximum extent permitted by law, MacNeil Media Group shall not be liable for any indirect, incidental, special, consequential, or punitive damages arising from your use of or inability to use the Service.</p>
|
||||||
|
|
||||||
|
<h2>Changes to Terms</h2>
|
||||||
|
<p>We may update these Terms from time to time. Changes will be posted on this page with an updated date. Continued use of the Service after changes constitutes acceptance of the new Terms.</p>
|
||||||
|
|
||||||
|
<h2>Termination</h2>
|
||||||
|
<p>We reserve the right to suspend or terminate access to the Service at any time, for any reason, without notice.</p>
|
||||||
|
|
||||||
|
<h2>Governing Law</h2>
|
||||||
|
<p>These Terms are governed by the laws of the State of Idaho, United States, without regard to conflict of law principles.</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>MacNeil Media Group<br>
|
||||||
|
Email: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer"></footer>
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
LUKE: All right, welcome back. It's Luke at the Roost. This is the radio show where I take your calls and give you real-world advice. If you'd like to call in, the numbers 208-439-58-3-3. That's 208-439 Luke. And we have a new feature to the show. Now, if you call in and we're not recording, you can leave a voicemail. And if your voicemail is funny enough, I will play it on the next episode. If we all, we all, we are, you all. We are, you all, you can leave a voicemail. If we are, you call in, we are, if you can leave a voicemail. If we are, if you are, if you can leave you can recording and you call in now, you'll be placed into a queue, and I can take your call, and we'll talk to you live on the air. Today is Sunday, February 15th. It's about 10.30, and our phones are already lighting up, as they usually are around now. And first up on the show, we have Phil. Phil, welcome to the show. What would you like to talk about tonight?
|
||||||
|
|
||||||
|
PHIL: Oh, man, Luke, thanks for taking my call. So I'm sitting in my truck right now, because Three hours ago, my brother sent me a screenshot of my partner's Tinder profile, and I can't go home yet.
|
||||||
|
|
||||||
|
LUKE: Oh, what was the screenshot? Was it a picture you're familiar with?
|
||||||
|
|
||||||
|
PHIL: Yeah, it's this photo from last month. We went up to Sedona. I paid for the whole trip, and Marcus bought this ridiculous cowboy hat at some tourist shop. We were laughing about it, you know, like it was this inside joke between us. Tinder photo.
|
||||||
|
|
||||||
|
LUKE: So what are you going to do? Do you have a Tinder profile as well? Are you going to match with them?
|
||||||
|
|
||||||
|
PHIL: Oh, Jesus. I haven't even thought about that. No, I don't have one. I mean, I thought we were past all that. But that's kind of genius in a horrible way, right? Just swipe right. See what happens?
|
||||||
|
|
||||||
|
LUKE: Yeah, maybe you've got something in common and it's worth getting together and having a little date or some type thing.
|
||||||
|
|
||||||
|
PHIL: Yeah. Hey, you look familiar. God, that's... I don't know if I'm more pissed off or if I just want to understand what the hell he's thinking. Like we've been together two years. I lost my first husband to cancer six years back, and I really thought Marcus got that... got how big a deal it was for me to do this again.
|
||||||
|
|
||||||
|
LUKE: Yeah, that sounds unfortunate. You should probably maybe confront Marcus about that and be like, hey, saw your Tinder profile. What's this about? How long you've been stepping? out, um, then make sure that whatever it is he's doing, he's doing in a protected manner, you know?
|
||||||
|
|
||||||
|
PHIL: Yeah, you're right. I mean, that's the thing that's actually making me sick right now. Like, I haven't even let myself go there yet. The health stuff.
|
||||||
|
|
||||||
|
LUKE: Well, that's the important stuff. And you might want to get yourself checked out and all that, uh, before anything becomes an issue. But talk to, talk to, talk to your partner. And, uh, maybe you've found, um, you've found, out the hard way that he's not the right partner for you.
|
||||||
|
|
||||||
|
PHIL: Yeah. Yeah. I think I already know he's not, you know? Like the second I saw that screenshot, it wasn't even shock. It was just this feeling of, oh, there it is. Like I've been waiting for the other shoe to drop this whole time and now it has. The thing is, I keep thinking about my first husband, Tom.
|
||||||
|
|
||||||
|
LUKE: And what happened with your first husband? answer? Did I get that right?
|
||||||
|
|
||||||
|
PHIL: Yeah, pancreatic. It was eight months from diagnosis to the end. And the whole time, even when he couldn't get out of bed, even when he was so sick, there was never a question, you know, never a doubt about us. And I guess I thought that's what love was supposed to look like. That's the bar.
|
||||||
|
|
||||||
|
LUKE: Hey, man, I'm sorry to hear that. And, uh, you know, some people get that once in their life. Some people get it more than once. Uh, the thing is you've got to be, You got to be all right by yourself and worry about love second. So how are you feeling individually as yourself? Do you need your partner to feel whole or would you be all right on your own?
|
||||||
|
|
||||||
|
PHIL: Man, that's the question, isn't it? I don't know. I thought I was good on my own after Tom died. I did the work, you know. Went to the grief group, got back into my photography, managed the bar, saw my friends.
|
||||||
|
|
||||||
|
LUKE: sound like it's a good idea to hang out with a dude that is seeing other people on the side without telling you. So it might be time to break up that relationship. I don't know. You should have that conversation with him. And if you've got a strike out on your own again, then do that. Get back to the photography and your friends and move on with your life.
|
||||||
|
|
||||||
|
PHIL: You're right. You're absolutely right. I just... I keep thinking about how I'm going to tell my brother
|
||||||
|
|
||||||
|
LUKE: Why is your brother in this picture? It sounds like that's none of his business.
|
||||||
|
|
||||||
|
PHIL: No, you're right, it's just he's the one who sent me the screenshot. He found Marcus on Tinder and took the screenshot and sent it to me. So now he's going to want to know what I did about it. And Eddie's got this way of making everything feel like a test I'm failing, you know? Like he's keeping score of whether I'm handling my life right.
|
||||||
|
|
||||||
|
LUKE: whatever, let him keep score. You don't have to care about what his score is. Just sit down with your partner and talk it out. If you got to break up, break up and move on. And I wish you only happiness going forward. Okay, sir. Thank you for the call.
|
||||||
|
|
||||||
|
PHIL: Yeah, yeah, okay. Thanks, Luke.
|
||||||
|
|
||||||
|
LUKE: All right. Good luck to you and your brother and your husband or whatever. Hope everything works out there. Looks like next up we've got Lori. Welcome to the show. How are you doing tonight?
|
||||||
|
|
||||||
|
LORI: Oh, man, I'm not great, Luke. I've been sitting in my truck at a flying J for 20 minutes trying to figure out if I'm about to blow up my whole life here.
|
||||||
|
|
||||||
|
LUKE: Why are you thinking about blowing up your life?
|
||||||
|
|
||||||
|
LORI: So my 14-year-old daughter's been playing me and her dad against each other for God knows how long. Telling him, she's at my place on weekends. Telling me she's with him. And actually, she's been out in the desert somewhere with friends doing who the hell knows what. He just called me back about it 20 minutes ago. And here's the thing. I'm hauling refrigerated goods. I'm six days into this route. I've got a load that needs to be in Tucson by tomorrow morning. But I'm four hours from home right now. And I know, I know I need to turn this rig around and deal with this.
|
||||||
|
|
||||||
|
LUKE: Well, think this one through. What happens if you do turn the rig around? What are you going to do when you get there? How are you going to deal with it?
|
||||||
|
|
||||||
|
LORI: That's the problem, Luke. I don't know. I mean, what am I going to do? Ground her. She's already been sneaking out for weeks, maybe months, show up and yell at her. That's just going to make her better at lying. Her dad and I, we can barely have a conversation without it turning into whose fault everything is.
|
||||||
|
|
||||||
|
LUKE: All right? Well, it sounds like that makes your decision, right? If there's no action for you to take if you were to go back, then don't go back. You've got work to do. You've got to take care of your responsibilities to your job too. And then you can deal with this when you're get back. Kids do do this. Kids go out and party in the woods and play their parents off each other. It's a very normal thing for a teenage kid to do. It's not that, not that life-shattering.
|
||||||
|
|
||||||
|
LORI: Yeah, but okay, you're right. It's normal, but Luke, she's 14. And I don't even know where she's going or who she's with. Her dad won't tell me the names of these friends because apparently she made him promised not to.
|
||||||
|
|
||||||
|
LUKE: Well, it doesn't sound like it really matters what their names are, right? She's out doing her thing. I don't see a problem here. Do your job. Talk to your family when you get home. And if you don't want her out partying at 14 out in the desert, then maybe think about a different profession where you can be around to be involved in her life more than being on the road for six days at a time, you know?
|
||||||
|
|
||||||
|
LORI: Wow. Okay. Yeah, no. You're that's exactly what my ex said when I took this job. that I was choosing the road over her.
|
||||||
|
|
||||||
|
LUKE: And here's the thing that pisses me off about that. I took this job because of the divorce.
|
||||||
|
|
||||||
|
LORI: Hey, I'm not trying to ideologically say that it's right or wrong for you to have taken the job. I don't care. I'm just saying if you're a long-haul trucker and you're gone for weeks at a time, then you're not particularly invested in your teenage daughter's life. And you're only going to show up once in a while to scold her for doing something wrong and acting out direction or guidance from our parent in her life. And that's not unusual. I'm not saying it's even wrong. I mean, you want her to be safe and you want to do this job and both those things can happen, but you need to sit down with her and talk to her about safety, make sure that she's letting you know what's going on because you care. If you do care, if not, then you just want to be controlling and punish her for doing, you know, stuff behind your back, which is, I don't think that's fair.
|
||||||
|
|
||||||
|
LUKE: No, you're right. I, God, I do care. I care so much it's making me sick right now sitting here. And you know what the worst part is? My dispatcher, Karen, has been so good to me since the divorce. She worked out this schedule where I could do shorter halls, be home more often, and I still manage to screw it up.
|
||||||
|
|
||||||
|
LORI: I don't know if you screwed it up or not, but the fact remains, you're away from home a lot. your daughter's home, and she's out doing stuff. You know, if you're not going to be around, she's going to live her life. And that might not be in a way that you appreciate. But you're making that choice, and it could be the right choice or the wrong choice for you. But you've got to decide. You can't have it both ways.
|
||||||
|
|
||||||
|
LUKE: You're absolutely right. I can't. And sitting here trying to figure out if I blow this contract and turn around or keep going, I'm just avoiding the real decision. aren't I? The thing is, Luke, when I lost the house and the divorce, when everything fell apart, this job felt like the one thing I could actually control.
|
||||||
|
|
||||||
|
LORI: Well, maybe it is. Maybe the job's a great thing for you and your family and it's providing for you, but you have to then give your daughter the grace to be her own person early because you're not there to give her that traditional upbringing that she otherwise would have got. It doesn't mean she's going to turn into a mom. her in adulthood. She's just going to have a different road. And that can be okay, but you should talk about it.
|
||||||
|
|
||||||
|
LUKE: Yeah. Yeah, you're right. I need to actually talk to her, not at her.
|
||||||
|
|
||||||
|
LORI: That's right. And it sounds like you've got a job to do. So get back on the road, finish out your obligation. And when you get back, sit her down and have a conversation that's not accusatory and you're not mad at her or punishing her, but talk to her. And let her, know what's going on in your life, why you've made the decisions that you've made, and what you expect out of her, and come to a compromise, an agreement. Talk to her like she's an adult, because at this point, she kind of is.
|
||||||
|
|
||||||
|
LUKE: She kind of is. God, that's terrifying. All right. All right. I'm going to finish the route. I'm going to get home Tuesday. And I'm going to sit down with her. Not with her dad there. Not making it this big intervention thing. Just me and her.
|
||||||
|
|
||||||
|
LORI: it sounds like a good plan.
|
||||||
|
|
||||||
|
LUKE: I hope it all works out. But, you know, I was in a similar situation as a teenager. I was your daughter in this scenario. And I was out with people I shouldn't have been with doing things I shouldn't have done. And there's lots of reasons for that. But it's not because my parents didn't love me. It's not because I was a piece of shit. It's, we're not going to get into it all right now. But over the course of the show, we'll probably learn a little bit more about that. For now, though, we have to go to our sponsor. So please stay tuned for a word from our sponsors. Life is hard. You're listening to a man in an RV talk to strangers at 2 in the morning, so you already know that. That's why we partnered with Better Maybe. Online therapy that's honest about the whole situation. With Better Maybe, you get matched with a licensed therapist within 48 hours. Will they fix your problems? Maybe. That's the whole brand. They're not going to lie to you. Your first session might change your life. It also might just be you staring at the webcam while someone in another time zone nods politely. That's still more than your friends are doing. Better maybe. It's better than nothing. And that's not nothing. Okay. Better maybe. Give them a call. Maybe they can help some of our callers. That's why I'm not. they reached out to us so they could get their product to you. Okay, Roland, Roland, welcome to the show. What's going on tonight, sir?
|
||||||
|
|
||||||
|
ROLAND: Oh, man. So I've been working from home for UPS for four years now, routing, logistics, all that. And Friday, they dropped the email, back to the Albuquerque office by March 1st, or I'm out. Three hours of driving every day, Luke, six hours round trip. And the thing is, I finally got my life set up exactly how I wanted it.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like maybe you've got to let that job go then. I mean, a six-hour round-trip commute to work for UPS doesn't sound reasonable to me.
|
||||||
|
|
||||||
|
ROLAND: Yeah, but it's 16 years total with the company, you know? The benefits, the retirement, are not starting over at 43, and the pay's solid. But, man, I finally set up my grandfather's old workbench in the garage, been restoring furniture at night, and I'm actually at it. My neighbor Gary caught me sanding at midnight Friday with every light on. asked what was eating me, and I couldn't even explain it to him.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, the pay may be solid, but now subtract six hours of commuting expenses and mileage on your vehicle. Retirement doesn't just start over if you go to another company. Like, I assume you've got an IRA or a 401K type situation with them. That's going to roll over into your next position. It's not like you forfeit it. You might forfeit whatever pension it is you got, but you got, what, 45 years more to work? Come on, man. You can't just deal with that for another 40 years because you're holding onto a pension. That's silly. If you've got to let it go, you've got to let it go.
|
||||||
|
|
||||||
|
ROLAND: No, you're right. You're right. I know you're right. It's just, okay. So here's the real thing. I spent the first 12 years on the road, delivering packages, breaking my back in the heat. When they finally moved me to remote work during COVID, it felt like I'd made it, you know?
|
||||||
|
|
||||||
|
LUKE: I do know. I remember back when I demanded to work remote, I would not go into an office anymore. And everybody told me that was a mistake and that you can't do that. This was pre-COVID. And I said, I can do whatever I want. You know, I'm going to do what I'm going to do. And I don't have to do anything I don't want to do. And I don't want to go into an office, so I'm not going to do it. And then I never went back into an office again. You can absolutely do that. There's plenty of ways to make money. You don't have to work for UPS. You can start your own company. You can start your own company. find another job that offers you remote work. You can, I don't know, play the stock market. There's plenty of ways that people make money without going in an office. And if you really don't want to go into an office, don't.
|
||||||
|
|
||||||
|
ROLAND: You know what? You're making it sound simpler than it feels, but okay, I've been reading about these CEOs running remote first companies. No plans to bring people back. And I keep thinking, why can't UPS figure this out? I've been more productive at home, six new hires over Zoom last year, never missed a deadline. But I think what's really getting me is,
|
||||||
|
|
||||||
|
LUKE: Well, before you get to that, I think a lot of companies and a lot of research has proven that people are generally very productive at home, and in a lot of cases more so than an office.
|
||||||
|
|
||||||
|
TERRY: Oh, man, Luke. So my best friend since third grade just told me she's been sleeping with my ex-husband since literally the week our divorce was finalized. Like, Michelle was at my wedding. She helped me move my stuff out of the house six months ago. she's been with David this whole time. And when I got upset about it tonight, because, yeah, I'm upset, she hit me with, it's been six months, you should be over it. Like I'm being dramatic for caring that she's been lying to my face for half a year while I'm crying to her about the divorce.
|
||||||
|
|
||||||
|
LUKE: Yeah, that's messed up. I think she's not your friend, and it's time to let go of that relationship.
|
||||||
|
|
||||||
|
TERRY: I mean, yeah, you're right. I know you're right. But Luke, 30 years. 30 years. We learned to drive together. We got matching tattoos when we turned 20. One, stupid little stars on our ankles. Her mom was like my second mom growing up. And it's not even just about David, you know?
|
||||||
|
|
||||||
|
LUKE: No, I don't know, but the whole David thing tells me that this chick needs to go. She doesn't care about you. Whether you've known her for 30 years or 300 years, Fuck this chick.
|
||||||
|
|
||||||
|
TERRY: You're not wrong. God, you're not wrong. It's just... Okay, so here's the thing that's really messing with me. I'm sitting here in my uncle's laundromat, at 11 o'clock on a Sunday night, with a warm beer, and I'm realizing I'm more hurt about Michelle than I ever was about David. Like the divorce sucked, but I saw that coming for a while, you know? We've been going through the motions for like two years.
|
||||||
|
|
||||||
|
LUKE: Yeah, that's a hard thing. I understand. And, you know, you've got that much history with somebody. It sucks to lose it, but it's, uh, it sounds like it's not you that has made this decision. And this person isn't somebody that's got your back that you can trust. So if you thought you had a great friend, maybe you did it once, but people change. And it sounds like she changed. And maybe some day she'll change again. But for now, let that chick go. She sucks.
|
||||||
|
|
||||||
|
TERRY: Yeah. Yeah. Yeah. She does suck. You know what the worst part is? I called her first when I found out David was seeing someone. Like two weeks ago, I saw his truck at the Applebee's with some woman, and I was all worked up about it, called Michelle literally crying in the parking lot. And she was like, oh, honey, you got to let him go. He's moved on. The whole time knowing it was her. That's that sociopath behavior, right?
|
||||||
|
|
||||||
|
LUKE: It sure is. And it's not what you would expect about a friend of 30 years. So my advice to you is just stop talking to that chick. Let her go. Let her have the dude. And, uh, move on with your life.
|
||||||
|
|
||||||
|
TERRY: You're right. I know you're right. It's just, God. It's pathetic, but I'm sitting here thinking about who I'm going to call now when stuff goes wrong. You know, like she was that person.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like you're going to call Luke at the Roost, the, uh, call in radio show where we help you out with your real world problems.
|
||||||
|
|
||||||
|
TERRY: Ha. Yeah. Well, you're doing better than she did tonight. That's for sure. I just... Okay. Real talk, though. Am I crazy for still being hurt about David?
|
||||||
|
|
||||||
|
LUKE: No, you're not crazy.
|
||||||
|
|
||||||
|
LUKE: You're hurt when you're hurt, and you've got good reason to be hurt here, and you've got some grief going on, and it's big life changes, and there's lots of reasons to be hurt. But you don't have to continue to stay that way. If you don't want to, you can decide to not be hurt and move on. And I think that's what you need to. to do. It's easier said than done, but it can be done. So just every day, wake up and be grateful to be here and say, hey, you know, I don't have the life that I had yesterday, but I've got the life that I have today, and it can still be pretty fucking cool.
|
||||||
|
|
||||||
|
TERRY: You know what? You're right. And honestly, and this is going to sound terrible. But I think I've been more hung up on the idea of David than actual David for a while now. Like we got married at 23, Luke. 23.
|
||||||
|
|
||||||
|
LUKE: Yep, that happens. That is young, and it explains a lot. So, um, you know, I wish you the best of luck. We're going to move on on the show now, unless you have some other point to bring up. I'll give you one more chance to respond. But otherwise, I'm going to say, uh, let go with the chick, let go to the dude, move on with your life, and everything's going to be fine.
|
||||||
|
|
||||||
|
TERRY: No, you're good. I appreciate you letting me vent. And hey, I heard Roland, earlier? The UPS guy?
|
||||||
|
|
||||||
|
LUKE: Yeah, what about him?
|
||||||
|
|
||||||
|
TERRY: You got a job for Roland? No, but his wife definitely sucks too. That whole thing about her wanting him out of the house? Come on. That man needs to check the credit card statements.
|
||||||
|
|
||||||
|
LUKE: That is absolutely true. I didn't want to say that to him at the time, but he absolutely should check the credit card statements. And maybe the little internal camera that he's got in the fucking teddy bear And now, now folks, you know what? Actually, she's already gone, but Terry, that was the smartest thing you sent that whole call. All right. Now it's time for a word from our sponsors. I'm not saying that for sympathy. I'm saying it because Pillow Forever asked me to establish a before state, and that's mine. Pillow Forever is a memory foam pillow that remembers your head shapes so you don't have to. It's got cooling gel, bamboo fiber, and a 30-night risk-free trial, which means you can sleep on it for a month and then send it back, and someone in a warehouse has to deal with that. Every pillow forever comes to. in a box that's too small, which is part of the experience. You open it and it slowly expands like a major documentary. My dog tried to fight it. Pillow Forever. You deserve better than a horse blanket. Okay, thanks to Pillow Forever. They are a proud sponsor of the Luke at the Roo Show, and we appreciate them. Now let's get to, uh, let's get back to the phones. Chester, Chester, welcome to the show. What's on your mind, sir?
|
||||||
|
|
||||||
|
CHESTER: Luke, hey, so I almost killed my kid this morning. Not on purpose, I mean. I didn't. I showed up to get her from her ex-wife's place, custody Sunday, and the second I rolled down the window, I could smell the whiskey coming off me. Hands still shaking. I'd been drinking until like four in the morning, and it was eight o'clock. And I'm sitting there with the engine running, thinking, well, I'm sitting there with the engine running, thinking, well, What the hell am I doing? She's seven. Her name's Daisy.
|
||||||
|
|
||||||
|
LUKE: Oh man, yeah. You can't, you can't do that, man. If you've got a drinking problem, then you're going to have to take, you're going to have to take care of that because you could kill your kid and or you could kill someone else's kid and it's really not okay. It's, it's irresponsible and it's not good for anybody.
|
||||||
|
|
||||||
|
CHESTER: No, you're right. You're absolutely right. I didn't drive. I mean, I sat there for maybe 30 seconds. And then I shut the truck off and I called her mom and told her I was sick, food poisoning or something, which is bullshit. She probably knew.
|
||||||
|
|
||||||
|
LUKE: How long have you known you've had a problem with the drinking?
|
||||||
|
|
||||||
|
CHESTER: I mean, that's the thing, Luke. I don't drink every day. I work at Desert Star Pond, been there six years. I show up on time. I do my job. It's just when I get home and Daisy's not there. When it's those empty nights, I'll start with one beer. And then it's three I'm watching Civil War documentaries with a bottle of Jim Beam. And I can tell you the exact date of Antietam, but I can't tell you when it got like this.
|
||||||
|
|
||||||
|
LUKE: Well, are you, uh, are you tired of it yet? Or it sounds like maybe this is a moment of clarity where you're recognizing the situation and, and thinking about taking accountability for it. Is that true?
|
||||||
|
|
||||||
|
CHESTER: Yeah. Yeah. Yeah. I think. I mean, my dad, Big Jim, he ran cattle for years. Never missed a day. Never touched the stuff.
|
||||||
|
|
||||||
|
LUKE: Well, you know, they have support group meetings. It can help you out in these situations. You can go to after work. Lots of cool people there that are trying to live sober, not pick up the drink and end up in situations like you're finding yourself in now. It is possible. You don't have to get drunk by yourself at night watching Civil War documentaries. You can just watch the Civil War documentary without they're getting drunk bit. then when you get up in the morning and pick up your kid, you're not going to be drunk.
|
||||||
|
|
||||||
|
CHESTER: I know. You're right. It's just, the house is so damn quiet, you know? And I keep thinking about what kind of example I'm setting.
|
||||||
|
|
||||||
|
LUKE: Well, it's good that you figure it out now when you're at a spot where you can still do something about it. And what I recommend is when you get home, next time you get home, do something else. Instead of cracking that beer, just do anything else. You can play a video game or you could, uh, go for a walk or you could read or play on the internet, whatever it is, just break that habit of having that first beer. Because if you don't pick up the first one, you're not going to end up drunk. Isn't it funny how that works? If you don't pick up the first one, you're not going to end up drunk.
|
||||||
|
|
||||||
|
CHESTER: Yeah, yeah, that makes sense. I heard Lori earlier, the trucker lady with the daughter sneaking out, and I kept thinking at least she's got a reason she's not around, you know?
|
||||||
|
|
||||||
|
LUKE: No, what do you mean by that?
|
||||||
|
|
||||||
|
CHESTER: I mean, she's working. She's on the road providing for her kid. Me, I'm just, I'm home and Daisy's at her moms. And I'm choosing to sit there with a bottle instead of, I don't know, calling her reading her a bedtime story over the phone. Something.
|
||||||
|
|
||||||
|
LUKE: Well, yeah. And, uh, you know, sometimes you just have to say that to a radio host at midnight to, uh, to hear yourself say it. So hopefully you can snap out of your, uh, your, your situation. Don't pick up the beer next time. And instead, call your daughter and read her a story.
|
||||||
|
|
||||||
|
CHESTER: You're right. I actually. I got this book about Gettysburg I was going to show her. She's been asking about it because we drove through Pennsylvania last summer. And she saw the signs. Smart kid. Smarter than me, that's for sure.
|
||||||
|
|
||||||
|
LUKE: Well, you sound pretty smart today. You know you get a problem and you're going to do stuff to take care of it. So if you need help, then I recommend that you find your your local neighborhood at AA or NA meetings, depending on what type of situation you're dealing with. And just go check it out. And have an open mind and maybe you find a new way of life. Okay, Dolores. Delores, welcome to the radio show. How can we help?
|
||||||
|
|
||||||
|
DELORES: Hey, Luke. Yeah. So, okay. I got recognized today at an open house and I kind of panicked. And now sitting in a circle K parking lot feeling like a complete idiot.
|
||||||
|
|
||||||
|
LUKE: Well, all of us get recognized several times a day. So why is it, what is it about this particular recognition that's got you up in arms?
|
||||||
|
|
||||||
|
DELORES: Because I've been going to open houses every weekend for like eight months and I'm not actually buying anything. This realtor, Patricia, she remembered me from a showing last spring, and she just asked me you actually planning to buy a house and I froze. I told her my financing fell through, but that's not even true because I've never applied for a loan in the first place.
|
||||||
|
|
||||||
|
LUKE: Okay. Next up on the radio show, we have Marvin. Marvin, thanks for calling in. What's happening tonight?
|
||||||
|
|
||||||
|
MARVIN: Hey, Luke. Yeah, thanks for taking the call. So I got home for my shift tonight and there's this cardboard box sitting on my porch. No note, nothing. And it's full of stuff from my dad's house. Stuff I haven't seen since he died 11 years ago. His Bolo tie. This pocket knife I thought I lost senior year. Photos of my uncle's old place.
|
||||||
|
|
||||||
|
LUKE: What's a Bolo tie?
|
||||||
|
|
||||||
|
MARVIN: Oh, it's that Western Thai thing. You know, the braided leather cord with the silver clasp that slides up and down. My dad wore one to every wedding, every funeral, every time he needed to look respectable. His had this. This turquoise stone in the middle, real heavy.
|
||||||
|
|
||||||
|
LUKE: Okay, well, that's cool. Somebody dropped off some of your dead dad's belongings, and what are they bringing up inside? They bring it up memories? Is this got you upset? Are you concerned? Like, what's, what feeling is this bringing up inside of you?
|
||||||
|
|
||||||
|
MARVIN: I mean, yeah, memories. But more than that, I'm sitting here like, who the hell had this stuff? my dad's been gone 11 years. Where has this box been? And why now? Why tonight? What's in the box? Why no explanation? Like, someone just decided, oh, time for Marvin to deal with this and left it there like a package from Amazon. And honestly, I opened it and just...
|
||||||
|
|
||||||
|
LUKE: All right. Well, congratulations for, uh, the... safe return of your dad's belongings. I hope that you wear them in good health. Next on the radio show, oh, before, no, oh, Luke, you almost, come on, come on, I'm fucking amateur here. It is time for a word from our sponsors. Look, I'm not a financial advisor. I'm a guy with a microphone and a dog. But the folks at Crypto know asked me to tell you about their new decentralized investment platform, and I to read this part. Past performance does not guarantee future results. This is not financial advice. And if you invest your rent money, you deserve exactly what happens next. Crypto No lets you trade over 400 digital currencies, including three that were invented this morning, and one that's just a picture of my dog. The app features a real-time portfolio tracker with a built-in panic button that just plays ocean sounds when your balance drops. Crypto No! Fortune favors the bold, but it does not return their calls. Okay, thanks to Crypto No for sponsoring the show today. I think we're probably running a little bit late. These have been some decent calls. They're going a little long. So I'm just going to take one more for tonight's show. And next up to the line, we have Francine. Francine, you're going to be our last caller of the night.
|
||||||
|
|
||||||
|
LUKE: What's on your mind?
|
||||||
|
|
||||||
|
CALLER: Luke, I just had a deer on the 11, and it's still alive, and I don't know what the hell I'm playing. supposed to do about it. I've been sitting here with my hazards on for like 20 minutes. Just, it's looking at me. When if its legs is completely shattered, it's on the shoulder. And I can't tell if I'm supposed to call someone, or if I'm supposed to, I don't know, put it out of its misery myself? Which I don't even know how I do that. I'm a nurse. I work on people, not my ex would have known. He grew up out here, hunted with his dad. He would have just handled it.
|
||||||
|
|
||||||
|
LUKE: Yeah, that sounds like a. less than ideal situation. I would call the police first, call 911, and let them know that there's been an accident and there's an animal, I assume, still in the road. If you have a gun, then I think you know what you have to do. If you don't have a gun, I would probably refrain from doing anything hasty with like a tire iron or a rock.
|
||||||
|
|
||||||
|
CALLER: No, I don't have a gun. It's off the road. It's on the shoulder. So nobody's going to hit it. I just, I'll call. I didn't know if this was even a 911 thing or if there was like animal control or something, but out here at midnight on a Sunday, I guess there's not exactly a whole directory of options. The stupid thing is, I've been driving the same loop between Deming and Los Cruces for six months now. Three different clinics, and this is the first time I've hit anything.
|
||||||
|
|
||||||
|
LUKE: Well, you know what I always say? 911 was an inside job. So I don't actually know who you're supposed to call in this situation. I would call 911 and ask the them and they'll let you know or send out animal control or whoever comes out of the sheriff's office. Who knows out here especially? But, you know, it happens. There's wild animals out here. They run in the road. Sometimes we hit them. It's probably not your fault unless you were playing with your phone, and in which case it was your fault. And just do the best you can do. It's unfortunate that the animal's suffering right now, but if you don't have a way to humanely end it, then you got to just get somebody out there as soon. as you can.
|
||||||
|
|
||||||
|
CALLER: I wasn't on my phone. I was just tired. Just came off a double. Wasn't paying attention like I should have been. You're right, though. I'll call.
|
||||||
|
|
||||||
|
LUKE: All right. Well, get off the line with me and get on the line with 911. And that is the conclusion of our show for tonight. Thanks everybody for tuning in, and we'll talk to you again tomorrow. Thank you.
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
LUKE: All right, welcome back to the show. My name is Luke. I'm your host for Luke at the Roost. This is the radio show where we take callers and give them the best advice that we can. If you'd like to call into the show, the number is 208-439-5853. That's 208-439 Luke. Also, new just yesterday. If you can't get to a phone and you'd like to participate in the show, you can send us an email. The address is submissions at Lukeat the roost.com. Submissions at Luke at theroost.com. So you can send us a question or a story or anything you want. And if it's funny enough, I might read it on the air in the next show. And if it's not me, maybe I'll have one of my robot friends read it for me because I'm lazy. All right. So today is Monday, February 16th. It's about 11.30 p.m. And our phones are lighting up like they always do at this time of night. the show, we have Hector. Hector, welcome to the show. Thanks for calling in. How are you doing out there tonight?
|
||||||
|
|
||||||
|
HECTOR: Hey, Luke. Doing all right, man. Doing all right. Appreciate you taking the call. So listen. I got myself into a situation here, and I don't know whether to laugh or start updating my resume.
|
||||||
|
|
||||||
|
LUKE: All right, Hector. What did you do?
|
||||||
|
|
||||||
|
HECTOR: So I've been flying crop dusters out of Yuma for eight years now, right? Good job. Decent money? I know what I'm doing up there. This afternoon, my boss pulls me aside. All smiles tells me I landed this huge contract in Mexico City. Three weeks coordinated with agricultural officials. Big deal for the company.
|
||||||
|
|
||||||
|
LUKE: Is that something you don't want to do? Why you bummed out about that? That sounds great.
|
||||||
|
|
||||||
|
HECTOR: No, no, that's the thing. It should be great, right? This is a promotion, basically. More money, more responsibility. Looks amazing on paper. Here's the problem, Luke. hired me eight years ago partly because my last name is Gonzales and my resume says fluent Spanish I speak about as much Spanish as you probably do maybe less
|
||||||
|
|
||||||
|
LUKE: I'm gonna let you in on a little secret I think they knew they probably had strong suspicions at the time they hired you that you did not speak Spanish but that's okay because nowadays you don't have to you can get the Apple AirPods and they'll do the live translation for you right in your ear
|
||||||
|
|
||||||
|
HECTOR: Oh man, I didn't even think about that. Does that actually work, though? Like in a real professional setting? Because I'm picturing myself in some meeting with Mexican agriculture officials, trying to negotiate pesticide schedules, while I'm sitting there with AirPods and like I'm listening to a podcast.
|
||||||
|
|
||||||
|
LUKE: You know, I went into Mexico last year at some point. I went to Agua Brietta. And when I got there, I was trying to buy cigarettes from the duty-free shop on the United States side of the border. So they don't just give you the cigarettes. You buy the cigarettes in the United States, and then you have to cross the border, and some guy is supposed to bring you the cigarettes, and then you have to import them back into the United States. So I went out there, and I was in Mexico, and I couldn't find the guy with the cigarettes. So I'm asking all around, and there's a bunch of, like, their version of Border Patrol at the border, and they didn't speak any English or pretended not to speak any English, and they pulled out their phone, and they just did the whole back-and-forth transcription, like it was nothing. Like, they do that all the time. So I think it's pretty common. I wouldn't worry too much about it. But you can always start studying. I mean, it's not like you can't learn Spanish.
|
||||||
|
|
||||||
|
HECTOR: Yeah, I mean, three weeks, though. I've been downloading Duolingo on and off for like two years, and I still can't get past the boy eats an apple. And here's the other thing that's dealing at me. I've been cashing paychecks for eight years with that my resume like they never needed me to speak Spanish before so it never mattered but now it does and I feel like a fraud
|
||||||
|
|
||||||
|
LUKE: well you are a fraud you lied on your resume and you got away with it for eight years so congratulations for that but it sounds like it's probably pretty difficult to find somebody qualified to fly crop dusting plane that they trust in another country and they trust you so you just got to do the best you can and try to work through the Spanish you know Three weeks, that's quick, but you can do it. I mean, dual lingo is probably not the right way, but there's lots of other ways to learn language, and if you really need to, if you really have to, you could get at least a little bit of what you need. It shouldn't take you long to ramp up.
|
||||||
|
|
||||||
|
HECTOR: You're right, you're right. I mean, I've been hearing Spanish on the radio stations down here for years. I should at least have some baseline. And honestly, the flying part I can do in my sleep, what's got me twisted up.
|
||||||
|
|
||||||
|
LUKE: Just throw yourself on a Selena record, get some bitty bitty bum bomb going, and you're gonna be fine. Amherst yourself in Spanish, and you will pick it up faster than you think, because you have to.
|
||||||
|
|
||||||
|
HECTOR: Okay, Selina, I can actually handle. My mom used to play that stuff all the time when I was a kid.
|
||||||
|
|
||||||
|
LUKE: You can't handle Selena. Wait, does that make it worse, that I didn't pick up Spanish?
|
||||||
|
|
||||||
|
HECTOR: She's probably going to lose it when I tell her about this. She's been asking me for years why I don't speak it and I always just shrugged it off.
|
||||||
|
|
||||||
|
LUKE: Well, your name is Hector Gonzales, so I mean I would expect at this point in your life you probably would have picked up a little bit here and there.
|
||||||
|
|
||||||
|
HECTOR: That's what kills me, Luke. Everyone just assumes. I walk into a place, they see the name tag, and half the time someone starts talking to me in Spanish, and I just stand there like a deer in headlights. I've gotten really good at the apologetic smile and the sorry I don't speak it. But my dad's of the family, they all came over generations ago. By the time it got to me, it was just gone.
|
||||||
|
|
||||||
|
LUKE: Hey, well, congratulations on the new job. Do the best you can. And if they have to send you back to the States to do your old job again, then so be it. And if they fire you for lying on your resume, then get another job. There's probably plenty of places you can commercially fly a plane.
|
||||||
|
|
||||||
|
HECTOR: Yeah, you know what? You're right. Worst case scenario, I'm back flying over lettuce fields and Yuma, which honestly isn't the worst thing in the world. I do love that job.
|
||||||
|
|
||||||
|
LUKE: Well, good luck to you and congratulations again. Thanks for calling in, Hector, and give us a call back if anything goes wrong or you meet a crazy cartel out there and you've got a story about redemption. Next up on the line we have Marlene. Marlene, welcome to the show. What's going on in your life tonight? How can we help?
|
||||||
|
|
||||||
|
MARLENE: Hey, Luke. Yeah. So, My ex-wife's new husband just ordered $12,000 worth of furniture for me, and I don't know if he knows it's me or not, and I'm losing my mind a little bit. Sorry, that came out fast. But yeah, that's what's happening right now.
|
||||||
|
|
||||||
|
LUKE: Well, you know, all the places in the world you could buy furniture from, if he's buying it from you, then there's a good chance he has an idea of who you are. Right?
|
||||||
|
|
||||||
|
MARLENE: That's what I keep thinking. the company's called Desert Reclaim and my name's all over the website. But then I'm like, maybe he just Googled Rustic Dining Tables, New Mexico. And I came up and he has no clue. He's this tech guy from Scottsdale, you know?
|
||||||
|
|
||||||
|
LUKE: Yeah, well, I mean, you got to think about how internet search works and cookies and stuff like that. So there's probably a chance that she was looking at your Facebook profile and somewhere you're linked to your business profile on LinkedIn or something like that. And then that's a chance that's transferring to his phone, so when he does a Google search, it's looking at all that data it's collected on you, and saying he's most likely to buy his chairs from this woman.
|
||||||
|
|
||||||
|
MARLENE: Oh, God, I didn't even think about that. So, like, Rachel's been looking at my stuff and it's bleeding over into his search results. That's, actually, that makes me feel weird in a whole different way.
|
||||||
|
|
||||||
|
LUKE: Like, has she been checking up on me? Well, I don't know. I'm just saying that's one possibility. That is how search works nowadays. So there's a likelihood that that could have happened. And not necessarily like Googling you in the middle of the night, but there could be documents on our computer that are getting fed into the cloud for some PDF transcription service. Like, you don't know. All that you know is the data is out there and you're linked to him now in a not a cryptographic way, but in a digital way. And that link is obviously working.
|
||||||
|
|
||||||
|
MARLENE: Yeah. No, you're right. I mean, we were married for six years. probably all kinds of digital trails still connecting us. But, okay, so whether he knows or not, do I take the money?
|
||||||
|
|
||||||
|
LUKE: Yes! Luke, this is six months of my mortgage. This order would let me actually breathe for the first time since I started this business.
|
||||||
|
|
||||||
|
MARLENE: But then I think about Rachel walking into one of those hotel lobbies and seeing my tables, the ones I built with wood from that barn outside animas, and I just...
|
||||||
|
|
||||||
|
LUKE: Not your problem. Take the money. Congratulations. That's a good hit. That's what we call us in the business. So close that deal, take your cash, and enjoy the win. And thank modern technology.
|
||||||
|
|
||||||
|
MARLENE: You really think so? Just take it and don't say anything? Because part of me wants to email him and be like, Hey, just so you know, I used to be married to your wife. Is that insane? That's insane, right?
|
||||||
|
|
||||||
|
LUKE: You are correct. That is insane. Yeah, don't do that. Take the money and don't say a word. And who cares if she looks at York when she goes to wherever it is, she goes where they put your furniture.
|
||||||
|
|
||||||
|
MARLENE: Okay. Yeah. You're right. I mean, she left me. She married him. I'm building furniture in my garage at two in the morning. This is literally the one good thing that's happened.
|
||||||
|
|
||||||
|
LUKE: Nah, she got to be with somebody she wants to be with. You get to make maybe potentially the best work of your life. And you just got a nice paycheck. So it sounds like there's lots of good things happening. And all around, everything works out the way it's supposed to. And enjoy this small win.
|
||||||
|
|
||||||
|
MARLENE: Small win? Luke, this is 12 tables. This is the biggest order I've ever had. But, okay. Yeah, I hear you. Take the money, build the tables, stop overthinking it. It's just, man, it's been a year of sleeping in my truck, watching the stars, trying to figure out what I'm doing. And now this lands in my lap and it's connected to her. And I just...
|
||||||
|
|
||||||
|
LUKE: You're just freaking out. And you don't have to freak out. Do the job, get the money and move on and enjoy it. Congratulations.
|
||||||
|
|
||||||
|
MARLENE: You know what? You're right. I'm going to do it. I'm going to build the best damn tables they've ever seen, take their money, and maybe finally fix the heater in my truck, so I don't have to freeze my ass off out there.
|
||||||
|
|
||||||
|
LUKE: Excellent. That sounds like a great plan. And another happy rooster. It's time to hear from our sponsors. So in just a moment, we'll return.
|
||||||
|
|
||||||
|
I'm going to be straight with you. I don't know what this product does. The folks at Bunkhouse DNS sent me a script and I've read it four times and I understand maybe 11% of it. Apparently your DNS is exposed, which sounds medical. Your queries are being logged by default, which sounds illegal. And Bunkhouse reroutes your lookups through encrypted tunnels, which sounds like something a villain would say in a movie before the building explodes. What I can't really tell you is this. I installed it. I pressed one button and a little shield icon turned green. Green means good. I know that much. My internet still works. Nothing exploded. And apparently my queries, whatever those are, are now private, which feels like progress, even though I don't fully understand progress from what? Bunkhouse DNS. I don't know what it does, but the shield is green, and that's enough for me.
|
||||||
|
|
||||||
|
Okay. Welcome back to the show. Just a quick plug. Don't forget, we have presents out there on all the social media. So you can find us on the Facebook and the Instagram and the YouTube's and the Apples and the Spotify's all around. If you want to look at all the places you can find us, look at Lukeattheroast.com. And with that out of the way, let's get back to the show. Rick, Rick, thanks for calling in. What's going on, buddy?
|
||||||
|
|
||||||
|
RICK: Oh man, Luke. So yesterday morning, my daughter Claire, she's 12. She fell through the on our stock tank trying to break it up for the horses, right? Full face pallant into freezing water, and I'm watching this happen, and I'm about to lose my mind. But she comes up and she's gasping, but she's not panicking, like it all. And later she tells me, it felt like her body just...
|
||||||
|
|
||||||
|
LUKE: Her body just what?
|
||||||
|
|
||||||
|
RICK: Took over. That's exactly what she said. My body took over. Like she wasn't even in control. Her heart slowed down, blood went to her core, and she just knew what to do. So I'm sitting there after she goes to bed and I'm looking this up. And Luke, it's called the mammalian diving reflex.
|
||||||
|
|
||||||
|
LUKE: Well, that's pretty helpful, I suppose. Uh, I, for if we like jump into the ocean when it's cold, like, uh, why is it there?
|
||||||
|
|
||||||
|
RICK: Right, exactly. It's this ancient thing we all have, like seals and whales. Your face hits cold water and boom, your body just flips this switch without asking permission. Heart rate drops, blood vessels constrict, everything shut down except what keeps you alive.
|
||||||
|
|
||||||
|
LUKE: Well, that sounds like a very handy thing to have, especially if you're going to fall into a trough of water when it's frozen.
|
||||||
|
|
||||||
|
RICK: Yeah, no. I mean, she's fine. She's totally fine. But Luke, here's the thing that's messing with me. I've known this kid her whole life, right? Change to diapers, taught her to ride, the whole deal.
|
||||||
|
|
||||||
|
LUKE: Okay. And what's the problem? It sounds like she's perfectly fine, and you learned a new thing about biology and evolution.
|
||||||
|
|
||||||
|
RICK: The problem is, I watch her face in that water. And it was like, I don't know her at all. Like there's this whole other operating system running underneath that I have zero access to. She's got the same emergency programming as a seal, Luke, a seal.
|
||||||
|
|
||||||
|
LUKE: I've been kissed by rose on the grave. Yeah, I guess that would be startling. But wait a few more years until you see her get hammered. And then, uh, see what you see in her face.
|
||||||
|
|
||||||
|
RICK: Oh, God. Don't even. No, but see, that's different. That's like choices and stupid decisions. This wasn't a choice. This was her body making calls without her even knowing. Like she's running software I didn't install and can't see the code for.
|
||||||
|
|
||||||
|
LUKE: Yeah, and it sounds like it's doing a better job than yours too, because she acted superfluously and got herself out of that cold water before it was an issue. So what's the problem here? Her natural biology was taken over and doing the right thing.
|
||||||
|
|
||||||
|
RICK: The problem? Okay. You're right. She handled it better than I would have. But Luke, she's 12. And I'm standing there on a Monday morning watching Evolution work through my kid like she's just...
|
||||||
|
|
||||||
|
LUKE: Yeah, you watched Evolution the whole, every day of watching that kid grow up, and all of you growing up and all your parents. So I'm not sure why this is flipping your wig.
|
||||||
|
|
||||||
|
RICK: Because this wasn't her getting taller or losing teeth or whatever. This was watching her go under and come back up with this look on her face like, Like some part of her I've never met, just took the wheel. She told me later, my body took over dad, and I can't stop thinking about what else is in there that I don't know about.
|
||||||
|
|
||||||
|
LUKE: I've been kissed by Rose on the grave. Do you think seal is in there?
|
||||||
|
|
||||||
|
RICK: What? No, I.! Luke, come on. I'm not saying she's literally a seal. I'm saying she's got all this programming I didn't know about. Like, what else kicks in that I'm not ready for? What other switch is flea when I'm not looking.
|
||||||
|
|
||||||
|
LUKE: I don't know, man. It sounds like you're going to have to read the origin of species and do some research on the old Charlie D.
|
||||||
|
|
||||||
|
RICK: Okay, you're busting my balls here, but I did look it up after she went to bed. The mammalian diving reflex. It's real.
|
||||||
|
|
||||||
|
LUKE: Heart rate drops. Blood goes to the vital organs, the whole thing. And she described it exactly like that without knowing what it was called. That's what's freaking me out. She's walking around with all these systems I can't see. And one day she's going to be out there making decisions. and I won't even know what's running in the background.
|
||||||
|
|
||||||
|
RICK: Yeah, that's true. That's how humans behave. Welcome to the world, sir. You don't know how anybody is programmed entirely, and just the little bits that you've been around for. So it's not to be shocking, but it sounds like you need to expand your mind a little bit. Your kid is going to be their own person with their own instincts and values and beliefs and skills. And most of them. them, not all, most of them will have absolutely nothing to do with you.
|
||||||
|
|
||||||
|
LUKE: Yeah, yeah, I know that. I do, but this felt different. Watching her face change in that water, watching something ancient, just click on.
|
||||||
|
|
||||||
|
RICK: Well, it sounds like everything worked out, and she's great, and you learned something, and that's a win to me.
|
||||||
|
|
||||||
|
LUKE: So thanks for Colin. Give us a call back some other time. Gina, Gina, thanks for Colin. How are you today?
|
||||||
|
|
||||||
|
GINA: Hey, Luke. I'm all right. So my brother and sister want to sell my grandmother's house, and I've been breaking into it every Sunday for the last three months.
|
||||||
|
|
||||||
|
LUKE: Is your grandmother still in the house?
|
||||||
|
|
||||||
|
GINA: No. She died in November. I have a key, though, so I guess it's not really breaking in. But they don't know I'm going there.
|
||||||
|
|
||||||
|
LUKE: Okay, why are you breaking into your dead grandma's house?
|
||||||
|
|
||||||
|
GINA: I don't know, man. I just, I drive down to Hachita every Sunday, let myself in, and I sit in her chair by the window for exactly two hours. Sometimes I'm on my phone. Sometimes I just watch the mountains. It's this floral armchair she'd always sit in.
|
||||||
|
|
||||||
|
LUKE: Well, that's a nice thing, but if they're going to sell the house, you're not going to be able to go there anymore. So that's a bummer. What are you going to do after the house is gone?
|
||||||
|
|
||||||
|
GINA: That's the thing. I don't want them to sell it. My brother left me three voicemails saying we need to list it by March. And my sister sent me the Zillow estimate and I had to pull over on the highway because I couldn't breathe. Like, I know how this sounds.
|
||||||
|
|
||||||
|
LUKE: Do you have the money to buy the house?
|
||||||
|
|
||||||
|
GINA: No. I work at a mortuary. I'm 21. I don't have that kind of money. But I can't explain this to them without sounding crazy.
|
||||||
|
|
||||||
|
LUKE: Well, you're 21. You also don't have the bills yet that they have, and the responsibilities financially that they have to account for. You don't have to pay the taxes on that property and the upkeep and the maintenance. You're just going there and hanging out, which is great. I mean, if you've got a free place to go hang out, wonderful. But the rest of your family, doesn't feel that way about it. It's a burden to them, and it's expensive, and they might not be able to afford it either. So if they want to sell the house, then you should let them.
|
||||||
|
|
||||||
|
GINA: I know all that. I get it. But she's still in there, Luke. And I know the difference between a body and a person. I work with dead people every day.
|
||||||
|
|
||||||
|
LUKE: Okay. Well, so if she's still in there, does it matter who owns her?
|
||||||
|
|
||||||
|
GINA: What do you mean does it matter who owns her? Some family from Phoenix is going to buy it and go. that the whole thing and turn it into an Airbnb or whatever.
|
||||||
|
|
||||||
|
LUKE: Yeah, and that sounds like an ideal situation for your dead grandma, because she's going to be hanging out in the Airbnb, it's going to be all nice and fixed up, and people are going to be partying in there, she's going to be having a grand old time.
|
||||||
|
|
||||||
|
GINA: That's not funny.
|
||||||
|
|
||||||
|
LUKE: What do you mean it's not funny? Of course it is.
|
||||||
|
|
||||||
|
GINA: No, because, okay, you don't get it. Every Sunday I sit in her chair by the window and I can feel her there. feel her. The way the light hits the mountains at a certain time, that's what she looked at every day for 40 years. Her handwriting is still on the grocery list on the fridge. There's a water stain on the ceiling from when the swap cooler leaked in 2019. And she never fixed it because she said it looked like a bird.
|
||||||
|
|
||||||
|
LUKE: Yeah, it sounds like she was a person and she is not anymore. And her house is no longer belongs to her and you're in it hanging out. And the family needs to sell that house, whether you want to be there or not. Like, you can't just force them to keep this house. You can't do it. It's not going to work. So you're going to have to come to terms with reality.
|
||||||
|
|
||||||
|
GINA: I know I can't force them. That's why I'm calling you instead of just, I don't know what I thought you were going to say.
|
||||||
|
|
||||||
|
LUKE: Well, what I'm going to say is it makes sense that you're grieving for your grandmother. And her presence is obviously. going to be in the place where she lived her whole life. That makes perfect sense. That happens to everybody. I'm going to tell you that you can take those memories of your grandma somewhere else, and you're going to have to. So it's okay to grieve. It's okay to miss her, but she's not in the walls. She's not in the stain on the ceiling. She will follow you for your life in your mind, in your memories, and keep those good, you know? And if you really, if you really, if you really feel that strongly about it? Do what you have to do to raise the money to buy the house. Like it's an after-TV special, and there's some creepy old corporation that's trying to buy it and turn it into an oil drill.
|
||||||
|
|
||||||
|
GINA: I make $22 an hour at the mortuary, Luke. My brother's a project manager in Tucson, and my sister works for the state. They've already run the numbers. If we sell now, we each get like $40,000. That's real money for them. That's their kids' college funds or whatever. And I can't explain to them why it matters without sounding insane.
|
||||||
|
|
||||||
|
LUKE: It doesn't sound insane. It sounds selfish. Because it's not yours to hold on to. So if you make $22 an hour at the mortuary, that's not a lot of money, but you could go make more money. You could have a second job, a third job. You could make deals with people, talk to the bank. $40,000 is not that much money, and you could raise it if you really wanted to. You can do a GoFundMe. I mean, you can crowdsource a lot of money. You can borrow a lot of money. You could make it happen, but, I mean, it goes back to you. And this would be something that you have to do. It's not something that you're forcing those other people that are in real world responsible situations. You can't be upset with them for doing what they need to do.
|
||||||
|
|
||||||
|
GINA: You're right. God, you're right, and I hate it. I've been sitting there every Sunday like I'm keeping her alive or something. Like if I'm keeping her alive or something. I just stay in that chair long enough. It won't matter that she's gone. But I work with dead people every day, Luke. I know better. I'd prep buddies.
|
||||||
|
|
||||||
|
LUKE: Well, if you know better, then snap out of it, because she is gone, and she's not in the house, and it's not your house.
|
||||||
|
|
||||||
|
GINA: Yeah. Okay. Yeah. You're right. I just, I have to figure out what to say to my brother, because he's been really patient, and I've been dodging his calls for two weeks. I can't keep doing that.
|
||||||
|
|
||||||
|
LUKE: Well, figure out what you're going to say to your brother, and make peace with what you've got to make with or go get yourself the money.
|
||||||
|
|
||||||
|
GINA: I'm not getting the money. I know that. I think I knew that when I called.
|
||||||
|
|
||||||
|
LUKE: Okay, you're not getting the money. Well, good luck to you, you little bitch. Jesus. I don't know. What generation is that? She said she was 21. Is that Gen. A, Gen. Gen. I don't know what gender. She sucked. Okay. Let's see. Skeeter. Welcome to the show. What did you think of that last caller?
|
||||||
|
|
||||||
|
SKEETER: I thought she was super annoying. Oh man, I missed it actually. I've been sitting out here in the parking lot with the heat running, just got the station dialed in. But listen, I got a question that's been eating at me all day.
|
||||||
|
|
||||||
|
LUKE: You know that thing about how orange used to be called yellow red? Like before the word orange existed, people just called it yellow red? I did not know that, and that sounds odd. It sounds untrue.
|
||||||
|
|
||||||
|
GIO: See, that's what this kid in my sense. seventh period said, called it bullshit internet fake history, right to my face, which, you know, fair enough, I let them talk like that in my class, as long as they're making a point.
|
||||||
|
|
||||||
|
LUKE: And did you have anything to back this up with?
|
||||||
|
|
||||||
|
GIO: That's the thing. I don't know, man. I saw it somewhere online, but it was interesting, been telling people for like two years. And now I'm sitting out here at midnight, second-guessing, whether I actually know anything real, or if my whole brain is just filled with internet garbage, Like, what else have I been confidently wrong about? You know what I mean?
|
||||||
|
|
||||||
|
LUKE: Well, have you taken any steps to verify the information? Like maybe Google it, look it up?
|
||||||
|
|
||||||
|
GIO: Yeah. I did that after class, and it just made it worse. Half the sites say it's true, half say it's not, and I can't tell which ones are legit. There's something about the word coming from the fruit, or maybe Sanskrit. Or maybe it was Geo-Lou Reed in Old English, which I guess means yellow-red? I don't know. I teach history. and I can't even figure out the history of a color. And honestly, Luke, that's not even really why I'm calling.
|
||||||
|
|
||||||
|
LUKE: Well, Gio, Lou Reed, sounds like something I want to get behind. All right, why are you calling?
|
||||||
|
|
||||||
|
GIO: I've been sleeping in my truck in the school parking lot for three nights because my girlfriend changed the locks on me. Found some texts I sent to someone I shouldn't have been texting. And I'm sitting out here with the engine running so I don't freeze, watching the frost build up on the windshield. And I'm the exact same age. my dad was when he left.
|
||||||
|
|
||||||
|
LUKE: Okay, well, it sounds like you're getting what you deserve. So who are you texting? What was that about?
|
||||||
|
|
||||||
|
GIO: Her name's Amanda. She's a substitute teacher, comes through maybe once a month, and it wasn't even, look, nothing actually happened. It was just flirty stuff, stupid stuff, but enough that when my girlfriend saw it, she knew exactly what I was thinking about doing, even if I hadn't done it yet. And the worst part?
|
||||||
|
|
||||||
|
LUKE: What's the worst part? You know women don't like. it when you text other women.
|
||||||
|
|
||||||
|
GIO: The worst part is, I don't even feel that bad about it. Like, I know I'm supposed to be devastated, right? Begging to come back inside, flowers and apologies and all that. But mostly, I just feel nothing. I've been with Sarah for four years, and when she changed those locks, part of me was relieved.
|
||||||
|
|
||||||
|
LUKE: All right, well then, congratulations. It sounds like your decision's been made for you. You're all set. You're free, sir. You're a free bird. You're a free agent. You can text whoever you want.
|
||||||
|
|
||||||
|
GIO: Yeah, except I'm 37 years old sleeping in a Tacoma in a high school parking lot. Real winter energy, Luke. And here's the thing that's messing with me. My dad did this exact same thing. Same age? Found someone else. Just walked away from everything. I spent my whole life thinking he was a piece of shit for that, and now I'm out here doing the same math he probably did.
|
||||||
|
|
||||||
|
LUKE: Well, you're not really just walking away from everything. She changed the locks on you. She told you to leave and apparently not come back.
|
||||||
|
|
||||||
|
GIO: No, you're right, but I handed her the reason to do it. I mean, come on, Luke. I left those texts where she could find them. My phone was sitting right there on the counter while I was in the shower. I'm not stupid. Part of me wanted her to see them. Part of me wanted her to be the one to pull the trigger so I wouldn't have to.
|
||||||
|
|
||||||
|
LUKE: Okay, well then, you're a pussy, and it sounds like you've got exactly what you wanted. So congratulations. I'm not sure why you're calling me.
|
||||||
|
|
||||||
|
GIO: Because today, I told my seventh period class, that thing about how orange used to be called yellow-red. And this smart-ass kid, always correcting me, said that's fake internet bullshit. And I'm sitting out here now wondering if he's right. Wondering if I actually know anything real, or if I'm just repeating stuff I saw online at three in the morning. And that feels like, I don't know, man. It feels like everything right now. Like maybe I don't actually know what I want or what I'm doing or who I even am.
|
||||||
|
|
||||||
|
LUKE: Well, welcome to the goddamn human race, sir, because nobody really does. It's the big secret that we're all hiding and pretending, you know, telling the kids that isn't the case. Nobody fucking knows, man. Just do your best. If you don't want to be with that woman, don't be with the woman. And it sounds like you're not anymore, so good on you. Now you're going to have to find yourself a new place of residence. So I would be worried about that more so than the color orange.
|
||||||
|
|
||||||
|
GIO: You're right. You're absolutely right. I just... Okay, so earlier you had that guy Hector on, the crop duster who lied about speaking Spanish, and you told him, Worst case, he just goes back to doing what he was doing before, which he loves anyway.
|
||||||
|
|
||||||
|
LUKE: Yeah, I was talking about a job, not a woman.
|
||||||
|
|
||||||
|
GIO: I know, but that's what I'm saying. I can't go back. There's no version where I go back to what I was doing before, because I don't even remember what that was. Sarah and I got together when I was 33. Before that, I was just, I don't know, Luke. I was just floating around. And now I'm floating around again, except I'm four years older and sleeping in my truck, and I can't even confidently tell a 17-year-old kid whether Orange is a real cool. color or not.
|
||||||
|
|
||||||
|
LUKE: Yeah, you're a goddamn mess, and you need to fucking pick yourself up and get it together because, uh, this is pathetic.
|
||||||
|
|
||||||
|
GIO: Yeah, yeah, you're right. It is pathetic.
|
||||||
|
|
||||||
|
LUKE: Okay, so grow up, figure out what you want to do and stop, uh, uh, holding people hostage. Now it's time for another word from our sponsors. I'm going to get dark for a second. You're going to die someday. Not today, probably, but someday. And when you do, what's going to represent your business? A Craigslist post from 2020? A Google Maps pin that still says permanently closed because you forgot to update it after COVID. A nephew who knows computers but actually just has a Reddit account? Your legacy deserves better than a nephew with a Reddit account. Squarehole lets you build something that outlasts you, something your kids can point to and say, Dad built Dad. They won't know it took you 11 minutes. That's between you and Squarehole. And when your nephew says, I could have built that for you for free, you can look them in the eye and say, then why didn't you, Tyler? I asked you in 2021 and you said you were busy and you were not busy. Squarehole. Build it before Tyler has to, because Tyler is not going to. Tyler's not going to do it, so you should talk to our sponsor there. Get yourself a website. A website like our website, Luke at the roost.com. which is not hosted on Squarehole. It's hosted a completely different way. And if you'd like to know how it's hosted, you can take a look at Luke at theroost.com slash how it works. Or we explain how this whole show works to those of you that might be interested. Okay, next up on the line, we've got a returning caller. I remember you. This is Brenda. Brenda. Brenda, you're the ambulance driver, right?
|
||||||
|
|
||||||
|
BRENDA: Yeah, that's me. Hey, Luke. So I did it. I actually went on a date.
|
||||||
|
|
||||||
|
LUKE: Hey, all right. Good job. Good for you. How'd that go?
|
||||||
|
|
||||||
|
BRENDA: Oh, man. It was. Okay. So I met this guy at the grocery store two weeks ago. Right? We were both reaching for the same bag of those frozen tequitos, and he made some joke about it. We got to talking. He seemed normal. Nice smile. Worked in H-FAC. Had his shit together. So we go out Saturday night. He picks this Italian place over on Glendale. And Luke, I swear to God, halfway through dinner, he started. telling me about his for-rating women he's dated. Like a whole spreadsheet.
|
||||||
|
|
||||||
|
LUKE: Yeah, I'm familiar. It's the universal, uh, hot, crazy matrix. We're all familiar with that spreadsheet.
|
||||||
|
|
||||||
|
LUKE: What, what was it about it that got under your nerves?
|
||||||
|
|
||||||
|
BRENDA: No, but seriously, it wasn't just that he, it. He pulled it up on his phone at the table and started walking me through it, like showing me the columns, physical attraction, emotional stability, long-term potential, and then he's like, don't worry, I don't fill it out until after the third date. And he said it like that was supposed to be. Like I'm supposed to be grateful I get two more dinners before I become a data point. And the thing is, I sat there thinking, is this what dating is now?
|
||||||
|
|
||||||
|
LUKE: Hey, you know, a lot of women would find that very attractive. I bet he's real good at doing taxes. There's probably a lot of good qualities this gentleman has. He seems very organized and he knows what he wants, and that shows extreme confidence. So I wouldn't be too butt-hurt about it. That's a funny thing. And, I mean, you could go on another date with him or not, but... How are you going to blame the guy?
|
||||||
|
|
||||||
|
BRENDA: You know what, you're right, and that's what's messing with me. Because Denise said the same thing at work yesterday. She was like, at least he's honest. At least he knows what he wants. And I'm sitting there going, so why do I feel like I got interviewed for a job I didn't apply for? Like the whole night I'm wondering what score I'm getting. And here's the thing. I don't even know if I liked him, Luke. I have no idea. I was so busy being aware of being evaluated that I didn't evaluate anything myself.
|
||||||
|
|
||||||
|
LUKE: That's exactly, that is precisely what a date is. It's an evaluation between two people. And whether or not they're saying that there's an evaluation happening, there is. I would be grateful to him for showing you the criteria instead of making it up in his head and thinking you were crazy and scoring you on things you didn't even know he was looking at. So everybody's doing that consciously or subconsciously, and he's just doing it in a very organized and respectable way.
|
||||||
|
|
||||||
|
BRENDA: Okay. No, but seriously, you're actually making me feel worse because you're right. I know. Like, I'm doing the same thing. I went home and told Monica every single detail about him, and we basically did the exact same evaluation. We just didn't write it down. We sat there for an hour going, okay, but did you notice how he treated the waiter? What kind of car does he drive? Does he talk about his ex too much? It's the same spreadsheet. I just pretended mine was more organic or something. And now I'm spiraling because he texted me yesterday asking about a second date. And I haven't answered because I'm sitting here judging him for doing out loud what I'm doing in my head. You know what I mean.
|
||||||
|
|
||||||
|
LUKE: Yep. That's a Sounds like the type of nonsensical nonsense that comes in the dating world. Congratulations. Are you going to go on the date with him or not?
|
||||||
|
|
||||||
|
BRENDA: I don't know. That's why I called. Because here's the other part. When he texted, he said, I had a really good time. I think you're someone I'd like to get to know better. And it was sweet. It was normal. And I'm sitting there staring at my phone thinking, but what did I score? Like now, I want to know what he wrote down about me. Did I get high marks? Where did I lose points?
|
||||||
|
|
||||||
|
LUKE: We'll ask him. Make a game out of it. See which areas he feels you need improvement and see if that's something you're willing to work on, if you agree with him or not, and have a discussion. I think that could be fun.
|
||||||
|
|
||||||
|
BRENDA: Oh, God, you're serious. You want me to actually ask him to show me my scores. No, but seriously, though, that's kind of...
|
||||||
|
|
||||||
|
LUKE: Of course. He wouldn't have showed you the spreadsheet if he wasn't willing to show you the scores.
|
||||||
|
|
||||||
|
BRENDA: Okay, you know what? That's a good point. He literally showed it to me at dinner. He's not hiding it.
|
||||||
|
|
||||||
|
LUKE: He's not hiding it. And you're doing the same thing. So maybe write down, write down yours. Put it on paper and show you can compare. You can trade and see where you match.
|
||||||
|
|
||||||
|
BRENDA: Oh man. This is either the weirdest advice or the best advice. Monica's going to lose her mind when I tell her this. But actually, no, but seriously. If I'm being honest, the stuff I care about is probably stupid too. Like I literally docked him points in my head because his shoes were too shiny.
|
||||||
|
|
||||||
|
LUKE: Well, you sound like a real catch. Good for you, Brenda. Go out on another date with a guy if you liked him. If you didn't like him, don't go on a date with him. But don't judge him too badly for judging you at an event that is entirely derived to judge each other.
|
||||||
|
|
||||||
|
BRENDA: You're right. God, you're totally right. Okay, I'm going to text him back yes. And maybe I'll ask him about the spreadsheet on date too. See if he's got a sense of humor about it.
|
||||||
|
|
||||||
|
LUKE: There you go. That's the right answer. That's what you should do. I hope you have a good time. And I'm interested to hear what you get on the spreadsheet. So next up on the line, we've got Clyde. Clyde, welcome to the show. What's going on tonight? How are you?
|
||||||
|
|
||||||
|
CLYDE: Hey, Luke? Yeah, I'm standing in my garage right now. Because my 23-year-old son just moved back in about six hours ago. And I don't know how to tell him no. third time. Third time he's shown up with his car packed full of everything he owns, saying he needs to figure things out. And he's already asleep in his old bedroom and my ex? Well, we're not really exes. It's complicated. She already texted me saying she's not doing this again. And I'm out here with my half-finished cabinets at midnight trying to figure out how you're supposed to kick your own kid out when he's already unpacked.
|
||||||
|
|
||||||
|
LUKE: Okay, we already talked about this. You've got to tell your kid, uh, no, you can't do this anymore. Stop calling the radio show, grow a pair, and talk to your goddamn child. Rita, welcome to the show. How are you?
|
||||||
|
|
||||||
|
RITA: Hey, Luke. I'm, yeah, I'm okay. Long night at work, couldn't sleep. I just, I took a call tonight. Welfare check on an old man out in Somerton, and his grandson found him dead. Been there three days.
|
||||||
|
|
||||||
|
LUKE: That's no good. Hey, what happens after the body is found. Is there, are there death people that are called to come and clean up the situation? Who's responsible to go in there and take care of the body? And not just the body, but the house?
|
||||||
|
|
||||||
|
RITA: Yeah, so we dispatch fire and medical first. They confirm he's gone. Then the coroner comes out. After that, if there's no foul play, family's responsible for cleanup. But here's the thing. This kid, the grandson, he's looking around while we're waiting, and he finds this album. Turns out his grandfather was this Corito singer back in the 70s.
|
||||||
|
|
||||||
|
LUKE: Okay. Good for him?
|
||||||
|
|
||||||
|
RITA: No, but that's the thing. The kid had. His own grandfather. Played all over the valley, VFW halls, weddings, recorded an album. And now he's just...
|
||||||
|
|
||||||
|
LUKE: Hey, if the coroner hasn't been there yet, then how do you know he's been dead for three days?
|
||||||
|
|
||||||
|
RITA: Oh, the paramedics confirmed that when they got there, they can tell by... I mean, I won't get into the details. But yeah, it was three days. But Luke, what I'm trying to say is, this guy had this whole life making music. And it just disappeared. Like it never happened. And I keep thinking about how many people out here are like that. Musicians, artists, whatever. They pour everything into it and then they're just gone. Nobody remembers.
|
||||||
|
|
||||||
|
LUKE: Yeah, that's true. And nobody recognizes while they're alive either. So there's a lot of people in the world. It's a big planet full of people with different talents. And it's hard to pay attention to all of them, especially when we've got so much crack attention seeking dopamine bullshit on the computers now and our phones. And everybody's listening to some nonsense, like, I don't know, listening to a radio show where the host takes colors from fake entities that are programmed in a computer.
|
||||||
|
|
||||||
|
RITA: Yeah, well, I'm listening to that show right now. So what does that say about me? But seriously, I heard you earlier with Marlene. And I think you were kind of hard on her.
|
||||||
|
|
||||||
|
LUKE: Yeah, I get that a lot. I think people just don't like to be told the truth.
|
||||||
|
|
||||||
|
LUKE: And I'm not really in the business of of coddling people or telling them what they want to hear. I'm in the business of giving the best advice I can. And sometimes that's hard.
|
||||||
|
|
||||||
|
CALLER: No, I get that. I do. But what I'm saying is, this old man tonight, he probably never got recognized while he was alive. either. He was just playing his caritos at Quincerra's and nobody paid attention. And now he's gone and his own grandson didn't even know. And I'm sitting here at three in the morning thinking, what's the point?
|
||||||
|
|
||||||
|
LUKE: Well, the point is that the dead grandfather had a great time expressing himself musically while he was alive. That was the point. And anything other than that, any recognition afterwards or fond memories of somebody else's past skill is pretty irrelevant in the whole scheme of the universe.
|
||||||
|
|
||||||
|
CALLER: Yeah, okay. But then why do we keep anything? Why do we, I mean, I've got this whole box of my dad's old tools in my garage that I never use. Mark keeps saying we should sell them, but I can't. And it's not even about the tools. It's about, I don't know. I don't know why we keep things.
|
||||||
|
|
||||||
|
LUKE: I don't. to me and if it's both useful and has, what's the intrinsic value, sentimental value? If it has sentimental value to me and it's useful, I'll keep it. But I don't keep stuff just to keep reminders of people or things or events or places or anything. That's just wasting space.
|
||||||
|
|
||||||
|
CALLER: So you think I should just let it all go? The old man's music, my dad's tools, all of it, just
|
||||||
|
|
||||||
|
LUKE: What? Make peace with the fact that nothing lasts. Nothing lasts and nothing is supposed to last. And that's how it's designed and that's the beauty of this whole thing we're doing. So, yeah, if you like the old man's music, if caritos are your thing and you enjoy listening to the music, then keep that. If you're going to use your dad's tools, if they're good tools, then keep those. But if you're just holding on to them because of the memory of who they used to belong to, you're wasting space. And every time you move for the rest of your life, you've got to move. a big box of tools or get somebody else to and pay to store it. And it's just, it's unnecessary. It's baggage. So, um, I for one, believe you should travel with as little baggage as possible.
|
||||||
|
|
||||||
|
CALLER: God, that's cold. It's not. I mean, you're not wrong. But, okay, so what about the grandson? He's got this one album his grandfather made, probably sitting in some storage unit somewhere. Should he just toss it?
|
||||||
|
|
||||||
|
LUKE: Depends on if he likes the record or not. If he's to listen to it, then no, he should hang on to it and listen to it throughout his life. If he doesn't like caritos and he doesn't have a record player, then I don't know that there's a good reason to hold on to that forever.
|
||||||
|
|
||||||
|
CALLER: You know what? I actually called about something else. But now I'm thinking maybe it's all connected. I've been keeping a list for years.
|
||||||
|
|
||||||
|
LUKE: Okay, a list of what?
|
||||||
|
|
||||||
|
CALLER: Musicians, local ones, people who played around here, and then just disappeared. I started it maybe five years ago after I dispatched a call to this house. And when the paramedics got there, they found all these old posters on the wall. Guy had played guitar in like 15 different bands in the 80s and 90s.
|
||||||
|
|
||||||
|
LUKE: Well, that's pretty cool. What are you going to do with the list?
|
||||||
|
|
||||||
|
CALLER: That's the thing. I don't know. I just keep adding to it. Tonight, after that call with the grandfather, I added him. Miguel Sandoval. One album, 1974. And I'm sitting here at like two in the morning with 40 names.
|
||||||
|
|
||||||
|
LUKE: And I'm thinking, well, I mean, that's a, it's obviously important to you. Music obviously speaks to you on a different level, and you also like archiving and keeping the memory of those things going. So maybe this, this makes you suited to run like a record shop or an antique shop where you can hold on to those things and trade them.
|
||||||
|
|
||||||
|
CALLER: Wait, hold on. You think I should quit dispatch and open a record shop at 53 years old?
|
||||||
|
|
||||||
|
LUKE: Why not? If you love records, you love music, that's what you want to do. You're 53 years old. You can do whatever you goddamn please. And you don't have to quit your job. You could do it on the side. You could do it as a little side hobby, hustle.
|
||||||
|
|
||||||
|
CALLER: The archiver. I mean, Mark would lose his mind. We've got the house payment, his trucks on its last legs. And honestly, I don't even collect records myself. I've got maybe 20 at home. It's not about owning them. It's about, I don't know how to explain this. I don't know what you're asking. I guess I'm asking if it matters.
|
||||||
|
|
||||||
|
LUKE: No.
|
||||||
|
|
||||||
|
CALLER: If I write all these names down, if I remember them, does it actually do anything? Because tonight, looking at that old man's grandson, this kid didn't even know his grandfather made music. Didn't know there was this whole part of him. And I'm thinking, I know.
|
||||||
|
|
||||||
|
LUKE: You do know. And that's a piece of information that you can have and not everybody has to have all the same bits of information that you have. No, it doesn't matter. It doesn't matter at all. all. It doesn't matter to anybody.
|
||||||
|
|
||||||
|
CALLER: Yeah, you're right. That's what I figured. It's just... Okay. Earlier tonight, Hector called in about lying on his resume, and I was laughing so hard I almost ran. And I thought, that matters right now to him. But in five years, nobody's going to remember that conversation. And Miguel Sandoval, nobody remembers his album either.
|
||||||
|
|
||||||
|
LUKE: Nope. I also have, uh, several albums. that nobody ever listened to and nobody will ever remember. And that's just part of being a musician. What was cool about them, though, was creating the albums. And having that in my belt of things that I've done, I'm not everybody can say they've created a whole record of their own music. And I can. And that's a pretty cool thing. So it's worth doing whether anybody listened to it or not. I still listen to it sometimes. That's all that matters. Okay, we're going to take one more call tonight. One more call. Who's it going to be? Who's it going to be? Should it be? Should it be? Chip or Pam? Chip or Pam? Chip or Pam? I'm going with Pam because I've been watching a lot of office clips lately and I forgot how good Pam was on that. All right. So, Pam, welcome to the show.
|
||||||
|
|
||||||
|
PAM: Oh man, Luke. Thanks for taking my call. So, okay. Here's the thing. I've been a bail enforcement agent for six years. Tracking down bail jumpers. all over Hidalgo County. And my boss just told me there's an insurance audit coming next week, which would be totally fine except I lied on my resume when I got hired.
|
||||||
|
|
||||||
|
LUKE: Oh, what's with all you resume liars? I mean, how is this such an issue today? What's going on? Are you all talking to each other? What's the deal? What was your lie?
|
||||||
|
|
||||||
|
PAM: Wait, there's more of us tonight? Oh, God, that's okay. That actually makes me feel a little better. and a lot worse at the same time. I told them I had an associate's degree in criminal justice.
|
||||||
|
|
||||||
|
LUKE: Well, you shouldn't have done that. And if they're going to give you hard time about it, then you should get your associate's degree in criminal justice. But I say, let them know that you lied, or don't. That's what I say. Don't say. Don't say anything. And if they find it in the audit, then come clean that you lied. And see if you can escape by. But we're not going to keep this conversation going too long because we already had one with the same.
|
||||||
|
|
||||||
|
PAM: Same issue, Pam. No, no, no, wait. Luke, it's not the same thing. The other guy. Hector, right? He can learn Spanish in three weeks. Or fake it with an app. Or whatever. But I've been doing this job for. I've brought in over 200 skips.
|
||||||
|
|
||||||
|
LUKE: Was Hector the guy that they pushed out of the plane and Scarface? Was that Hector?
|
||||||
|
|
||||||
|
PAM: What? No. That was, yeah.
|
||||||
|
|
||||||
|
LUKE: Different Hector.
|
||||||
|
|
||||||
|
PAM: But Luke, listen. I'm sitting in my truck right now outside the office with the engine running because I don't know if I should walk in tomorrow or just drive until I hit the golf.
|
||||||
|
|
||||||
|
LUKE: That's a bit dramatic.
|
||||||
|
|
||||||
|
PAM: Really good. I know how to read people. I know the county. I know which cousins are lying and which bartenders will actually call me back.
|
||||||
|
|
||||||
|
LUKE: Okay. Well, good for you.
|
||||||
|
|
||||||
|
LUKE: So you're good at your job or you think you are anyway and you want us to think you are too.
|
||||||
|
|
||||||
|
CALLER: And you lied on your resume and you may get caught, but you also might not.
|
||||||
|
|
||||||
|
LUKE: So just go to work like it's a normal day. It's unlikely that they care. If you're good at you, if you're as good at this as you say you are, then they'll find a reason to leave you alone.
|
||||||
|
|
||||||
|
CALLER: You really think so? Because I've been sitting here for an hour just I keep thinking about John Wayne, which is stupid. But he never pretended to be something he wasn't, you know. He just was who he was, and people either liked it or they didn't.
|
||||||
|
|
||||||
|
LUKE: I don't know if that's true at all. I bet you John Wayne pretended to be a lot of things. He was not.
|
||||||
|
|
||||||
|
CALLER: Well, yeah. I mean, he was an actor, but...
|
||||||
|
|
||||||
|
LUKE: He sure was. Okay, bad example.
|
||||||
|
|
||||||
|
CALLER: There we go. I just mean, like, the characters he played, they have this code, right? They didn't lie about who they were. And here I am, six years in. And the whole thing's built on this one stupid lie I told because I was desperate and broke after my second divorce. And I thought, how hard can it be to track down people who don't want to be found?
|
||||||
|
|
||||||
|
LUKE: Well, I wouldn't worry about it too much. And if it's a problem, then deal with it then. But otherwise, don't lie anymore. You've learned your lesson. No more lies.
|
||||||
|
|
||||||
|
CALLER: Yeah. Yeah. You're right. I just, God, I've been spiraling out here.
|
||||||
|
|
||||||
|
LUKE: Well, hopefully you feel a little bit better now. Go to work in the morning. Like nothing happened, and just deal with it as it comes. Thanks for calling. And thanks for tuning in to another episode of Luke at the Roost. We hope you have a lovely evening. And we'll talk to you tomorrow. Bye.
|
||||||
|
|
||||||
|
CALLER: Thank you.
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
LUKE: All right, welcome back to the show. I am Luke, and this is Luke at the Roost. This is the call-in radio show where you can call in, and I'll give you the best advice I possibly can. It's a little bit late today. I had to take a little nap and then push the show back. So today is actually February the 18th, but we're going to pretend. We're going to pretend that it's February the 17th. Why are we going to pretend that? As you may know is Fat Tuesday. That's Fat Tuesday. It's Mardi Gras time, folks, out here in New Mexico. So for today's show, I've got some New Orleans trivia for our callers. That's going to be fun. If you'd like to call in, the number is 208-439-583. That's 208-439 Luke. Or you can also send us an email there to submissions at Luke at theroost.com. So with all that being said, I think it's time to get to our show. Our phones are lit up like they are around this time. And first up, we have Rodney. Rodney, welcome to the show. How are you? Happy Fat Tuesday.
|
||||||
|
|
||||||
|
RODNEY: Fat Tuesday was yesterday, Luke.
|
||||||
|
|
||||||
|
LUKE: I know. I appreciate the sentiment.
|
||||||
|
|
||||||
|
RODNEY: So listen. I'm sitting in my truck outside a closed Chinese restaurant in Lordsburg, New Mexico at midnight. And I just cracked open a fortune cookie that says it's too late. That's it. That's the whole fortune. And I cannot decide if the universe is talking to me, or if I'm just losing my mind in a parking lot.
|
||||||
|
|
||||||
|
LUKE: Sounds to me, sir, like you're just losing your mind in a parking lot, because it's never too late for anything. You can be anything you want to be. Wake up in the morning, it's a whole new day, you're a whole new you, and there's nothing you can't do if you put your mind to it. That's what I always say. But we're pretending. We're pretending that it's still fat Tuesday, sir. So go with me. that it is still February 17th, Fat Tuesday. And I've got some trivia for you. Are you ready for that?
|
||||||
|
|
||||||
|
RODNEY: Oh, I can do trivia. Hit me. I got nowhere to be. The restaurant's closed. My truck's running. And apparently the universe already gave up on me. So, let's do some trivia.
|
||||||
|
|
||||||
|
LUKE: All right, here we go. Why are New Orleans cemeteries called Cities of the Dead?
|
||||||
|
|
||||||
|
RODNEY: Oh man. Is it because they bury people above ground down there? Because the water table's so high you'd just be floating your loved ones away if you put them in the ground.
|
||||||
|
|
||||||
|
LUKE: That is correct, sir. I've actually been there. It's really weird. They're all in like tombs above ground. It's a pretty freaky situation. Have you ever been to New Orleans, sir?
|
||||||
|
|
||||||
|
RODNEY: I have not. Closest I've gotten is Beaumont. Texas, which is, I mean it's humid and it smells weird, but I don't think it counts. New Orleans solar list, though, always has been. Although at the rate I'm going, sitting in parking lots reading ominous fortune cookies, maybe I should just stay in the desert where things make sense.
|
||||||
|
|
||||||
|
LUKE: Yeah, maybe you should. That's what I'm doing. Things do make sense here in the desert, and we bury people under the ground here, which is, which makes sense to me, I guess. So, you're sitting in the parking lot, you got your bad fortune cookie, and you're afraid it's too late. What do you think it might be too late for?
|
||||||
|
|
||||||
|
RODNEY: My ex-wife texted me three days ago. First time in two years. Just asked if we could talk. And I haven't answered her. And then I crack open that cookie, and it says, it's too late. And I just, I don't know, man. I've been sitting here for 40 minutes now, trying to figure out out if that's the universe telling me I already blew it by waiting three days, or if it's telling me something bigger.
|
||||||
|
|
||||||
|
LUKE: I don't think it's I honestly don't think it's telling you anything. I think you just got a fortune cookie at a shut down Chinese restaurant in Lourdesburg, New Mexico, and you can never trust the Chinese restaurant in the desert. So I think I wouldn't put too much stock into what the universe is trying to tell you with your fortune. Instead, I might, if you want to talk to your ex-wife, call her. And if you don't want to talk to her, don't call her. It's that simple.
|
||||||
|
|
||||||
|
RODNEY: See, that's what I'm hoping, Luke. That's exactly what I'm hoping. Because I'm supposed to head back to the rig tomorrow morning, and I got to know if I'm just losing it out here. Last year, I killed a rattlesnake in my kitchen with a shovel at three in the morning.
|
||||||
|
|
||||||
|
LUKE: Jesus.
|
||||||
|
|
||||||
|
RODNEY: Didn't flinch, didn't think twice. This little strip of paper has me calling a radio show. ridiculous.
|
||||||
|
|
||||||
|
LUKE: Well, you know what I always say. This radio show is like killing a rattlesnake with a shovel.
|
||||||
|
|
||||||
|
RODNEY: Ha, I mean you're not wrong. You just, you just pick up the phone and you do it. You don't think about it too hard.
|
||||||
|
|
||||||
|
LUKE: That's right, sir, and that's my advice for you. So if you'd like to talk to the wife, you want to get back, do you want to get back with her? Is that what's going on? Do you miss the X?
|
||||||
|
|
||||||
|
RODNEY: Yeah, yeah, I do. I'm not going to sit here in a parking lot at midnight and lie to you about it. I miss her, but here's the thing. She left.
|
||||||
|
|
||||||
|
LUKE: Well, she's obviously got something to say to you because she's reaching out. So if you miss her and you want to talk to her and you don't hate her guts, then give her a call and work it out.
|
||||||
|
|
||||||
|
RODNEY: I know. I know you're right. And it's not it's not that I hate her. I don't hate her at all. It's that I don't know what she to say.
|
||||||
|
|
||||||
|
LUKE: Well, you're never going to know until you make the call, so let's get to that.
|
||||||
|
|
||||||
|
RODNEY: You're right. That's, yeah, that's just the truth right there. I've been sitting in this truck for 40 minutes working up to calling a radio show instead of just calling Denise. And that probably tells you everything you need to know about me.
|
||||||
|
|
||||||
|
LUKE: That's correct. It does. And we wish you the best of luck, sir. Enjoy your fortune cookie. And good luck with the X. Estelle. Stell, welcome to the show. Thanks for calling in. Happy Fat Tuesday. I know it's not really Fat Tuesday, but we're pretending because I feel asleep. And, you know, I'm a person too. Very, very busy man. Are you ready to play Fat Tuesday trivia?
|
||||||
|
|
||||||
|
ESTELLE: Ha. Okay. I did not call in for trivia, but I'll take it. Hit me.
|
||||||
|
|
||||||
|
LUKE: Louisiana is the only state that doesn't have counties. What does Louisiana have instead?
|
||||||
|
|
||||||
|
ESTELLE: Perishes? That one I actually know. I'm a social worker, so yeah.
|
||||||
|
|
||||||
|
LUKE: Very good. That's the correct answer. And what are you calling in for tonight, Estelle?
|
||||||
|
|
||||||
|
ESTELLE: Okay, so I came inside at two in the morning and realized I have not had a real conversation about my own life with another human being, and I don't even know how long.
|
||||||
|
|
||||||
|
LUKE: Okay, so what is it that you'd like to say to another human being?
|
||||||
|
|
||||||
|
ESTELLE: I don't even know where to start. I mean, I know, but it's embarrassing. So here's the headline. I've been having more meaningful conversations with an 87-year-old woman in Hachita about her late husband's dentures than I have with anyone about my own life in, what, six months?
|
||||||
|
|
||||||
|
LUKE: Well, you know what, that conversation with that 87-year-old woman about those dentures, that is your life. That's your life, too. So you are having conversations about your life. What is it that's on your mind?
|
||||||
|
|
||||||
|
ESTELLE: Okay, yeah, fair. That's, yeah. What's on my mind is that I came inside to, and there was a frozen lasagna on the table that I have microwaved every single Wednesday for six months. And the chair was pulled out at the exact same angle it always is. And I just stood there and thought, did I choose this? Or did it just kind of happen to me?
|
||||||
|
|
||||||
|
LUKE: Well, the answer is you chose it because you choose everything. So nothing just happens. If you have lulled yourself into a state of complacency with your life, then change it. But it's something that you did.
|
||||||
|
|
||||||
|
ESTELLE: Oh, come on. That's a lot. That's not. Okay, no. I hear you. But it's not that simple. It is. I, my job. I like the quiet. I like the stars. But then I'm out there in the dark with my telescope, and I'm talking to myself about Andromeda, like some kind of, like some kind of desert hermit. And I don't even know if I'm lonely or if I just got used to the silence.
|
||||||
|
|
||||||
|
LUKE: So what's the problem? Why is that a problem? Are you enjoying yourself out under the stars looking through your telescope and Andromeda? Because that's what I do. And I am a desert hermit, and that's perfectly okay. If you're comfortable with that, great. If you're not comfortable with that, though, then it's time to make a change in your life and join a group or do something other than sit by yourself. And there's lots of ways to do that. There's plenty of opportunities. You just have to make a decision and put forth the effort to have the life that you want. But all of it is you. You control 100%. Okay.
|
||||||
|
|
||||||
|
ESTELLE: But no, I control it. that's not the point. The point is, I don't even what I want anymore. Like, I, the quiet. I like the routine.
|
||||||
|
|
||||||
|
LUKE: It sounds like you've been hitting the bottle, lady. What's your consumption level these days?
|
||||||
|
|
||||||
|
ESTELLE: Ha, no, I wish. Honestly, no, I'm stone cold sober standing in my kitchen at two in the morning staring at a lasagna. Which is maybe worse? Like, at least if I'd been drinking, there'd be a reason I couldn't answer a basic question about my own life.
|
||||||
|
|
||||||
|
LUKE: Well, you're not making complete sentences. You're not making any sense and you're meandering about nothing. You call the radio show you have no point to make. I'm not really sure why you're here. So, do you have something you'd like to talk about or should we move on to the next caller?
|
||||||
|
|
||||||
|
ESTELLE: Okay. Okay, here it is. I think I'm scared that I'm building a life that looks exactly like what I want on paper. The job, the solitude, the telescope, the desert. And it's going to be going to be eating that lasagna alone on a Wednesday.
|
||||||
|
|
||||||
|
LUKE: That's enough of you. Eat your lasagna. Stop whining. If you want to change your life, change your life. But you're a boring caller, and we don't need boring callers. Next time you call in, have something interesting to say. That's what I have to say. And next up, we have Leon. Leon, do not be a boring caller, please. Do your best to be an interesting and have something to say. Today is not Fat Tuesday, but we're pretending it is because I fell asleep on Fat Tuesday. And I've got some trivia for you. Are you ready for my New Orleans trivia?
|
||||||
|
|
||||||
|
LEON: Ha! Luke! Man, it is good to be back. Okay, but hold on. Before you hit me with trivia, I got to tell you something first.
|
||||||
|
|
||||||
|
LUKE: All right, hit me with some funny shit.
|
||||||
|
|
||||||
|
LEON: So I quit the boot camp.
|
||||||
|
|
||||||
|
LUKE: All right. The boot camp was for coding? Is that what that was about?
|
||||||
|
|
||||||
|
LEON: Yeah, the coding, Python, all of it. I'm not going to lie, I lasted about six weeks, and I spent probably five of them watching poker strategy videos instead of writing code. You and I talked about that, actually. Last time, you said something that kind of stuck with me. You basically told me that at 56, poker is just as legitimate as anything else I could be chasing.
|
||||||
|
|
||||||
|
LUKE: Sure thing. So are you chasing the poker now?
|
||||||
|
|
||||||
|
LEON: I entered a tournament. Last Saturday, down in Albuquerque.
|
||||||
|
|
||||||
|
LUKE: Wow, that's fantastic. Entering a tournament, though, anybody can enter a tournament at any time. What is your strategy for moving forward with your poker career? Tell us about your bankroll management.
|
||||||
|
|
||||||
|
LEON: Okay, so, I'm not going to lie. I did not expect you to know the word bankroll, Luke.
|
||||||
|
|
||||||
|
LUKE: Oh, I deserve that. That's impressive. Okay, so here's the thing.
|
||||||
|
|
||||||
|
LEON: I don't have a poker career yet. I want to be real clear about that, but I did cash.
|
||||||
|
|
||||||
|
LUKE: What was your tournament? What were the stakes and what did you cash?
|
||||||
|
|
||||||
|
LEON: It was a $65 buy-in at one of the card rooms down there. Nothing crazy. And I cashed for $180. So, I mean, it's not like I'm retiring from towing. But I'm not going to lie, I sat at that table for like four hours, and I felt more alive than I had in a long time. It's like I knew what I was doing.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, I think you're going to find that a poker room, wherever you are in New Mexico, is a very different experience than if you were going to play with professionals in Vegas or anywhere else there were professionals. 180? That's a nice little win. But anybody can win once in a while. We call that beginner's luck. You're going to need a poker strategy. How much money do you have for your bankroll? Like, how much can you afford to lose before you're working off your own money?
|
||||||
|
|
||||||
|
LEON: No, you're absolutely right. And I know that. I'm not delusional about it. But here's the thing. The guys I beat on Saturday, some of them weren't chumps either.
|
||||||
|
|
||||||
|
LUKE: Okay, well, and that's great. Congratulations. But I'm trying to push you in the right direction here, sir, and you're not going with me. What stakes are you playing with at? Like, what's your game?
|
||||||
|
|
||||||
|
LEON: Okay, okay, fair. I hear you. Right now, I'm playing micro-stakes online, like $2, $5 stuff, and I've been studying, I'm not going to lie, way harder than I ever studied Python.
|
||||||
|
|
||||||
|
LUKE: Okay, and what are you studying? Are you studying the old Doyle Bronson books? Are you studying GTO? What is your strategy for learning and improving and being a professional poker player? If you're not going to... When I gave you that advice, when I told you it's not too late to start anything, I meant to start something. So just going to play poker at a card room on a Thursday is not starting something.
|
||||||
|
|
||||||
|
LEON: Yeah, yeah, okay, that's fair. That stings a little, but that's fair.
|
||||||
|
|
||||||
|
LUKE: Well, congratulations on your journey here. I hope it goes well for you, and you already got a win under your belt, and that's nice. But it's going to be a long road, a lot of grinding. And if you want to be successful at poker, you're going to have to have a plan. And it sounds like you don't. So I recommend buckling down and seeing how the pros do it and follow a strategy.
|
||||||
|
|
||||||
|
LEON: I hear you, Luke. I do. And I'm not going to lie. I've been thinking about this a lot since last time we talked. I've been watching some of those poker training videos, the ones where they break down hands and talk about ranges and all that. It's a lot.
|
||||||
|
|
||||||
|
LUKE: Yeah, it's a lot, and it sounds like you don't quite understand it. So before you get into a position where your life is on the line on a poker game, I recommend getting these basic fundamental, at least a poker words, you know, the language, get this under your belt.
|
||||||
|
|
||||||
|
LEON: No. You're right. You're right. And honestly, I'm not going to lie. I called tonight thinking you were going to be a little more excited for me about the $180, and I was going to feel good about myself and go to bed. And instead, you're kind of doing what my daughter does, which is just cut right through it.
|
||||||
|
|
||||||
|
LUKE: Well, you can still feel good about yourself. You want money in a poker game, and that's pretty cool. But we're talking about your life and career going forward, and $180 in a poker game isn't going to pay your mortgage.
|
||||||
|
|
||||||
|
LEON: No! No, it's not. And Amber would, she would have something to say about that if I suggested otherwise. So here's the thing. Here's why I actually called tonight. I dropped the boot camp.
|
||||||
|
|
||||||
|
LUKE: Yeah, you told me that. You dropped the boot camp because you're going to follow your poker dream.
|
||||||
|
|
||||||
|
LEON: No, no. I dropped it because I got into UNM. Full ride. They saw my work history. They saw my age. They saw that I was serious.
|
||||||
|
|
||||||
|
LUKE: Oh, I'm sorry. I thought I was under the impression that you dropped the boot camp to become a poker professional. But if you're just doing this as a hobby for fun and you want $180, congratulations, you can feel good about yourself and go to bed. But also, congratulations for your entry into UN&M. What is it for? A full ride for what?
|
||||||
|
|
||||||
|
LEON: Computer science. Full ride. And I'm not going to lie, Luke. I'm scared shitless. I got the letter today, and I just sat in my truck in the parking lot at work and work and read it three times. And then I called Amber, and she just started crying. And I don't know if it was happy crying or terrified crying, but I think it was both.
|
||||||
|
|
||||||
|
LUKE: Either way, it will be an adventure, and you've got yourself a full ride to being unemployed. Congratulations. Are you ready for the trivia?
|
||||||
|
|
||||||
|
LEON: Wait, wait, wait, wait. Hold on.
|
||||||
|
|
||||||
|
LUKE: All right. That was Leon. Leon's a tough one. He calls in sometimes and he just wants me to tell him everything's great. And everything's not great. Oh, good for you. You went to a poker room. You played a tournament and you got lucky. That doesn't mean you're good at poker. That means you bet 40 bucks and won. Okay, next. Heather, Heather, welcome to the show. What's going on, Heather? Thanks for calling in. Happy fake Fat Tuesday. Today is the Fat Tuesday for people that fell asleep during Fat Tuesday. I've got some trivia for you. Are you ready for your question?
|
||||||
|
|
||||||
|
HEATHER: Oh, I'll do the trivia, sure, but okay, I have to say something first because I've been sitting here for like 40 minutes waiting to get on, and I will absolutely lose my mind if I don't say it. I just told my mom no. Like, to her face. Well, on the phone. But I said no to my mom tonight, and I feel like I committed an actual crime.
|
||||||
|
|
||||||
|
LUKE: What did you say no about?
|
||||||
|
|
||||||
|
HEATHER: She called me three hours ago asking if I could loan her $400 for a furnace repair. And I said no. I said the actual word no to my mother, who is cold, Luke. She's cold right now, potentially. And I'm sitting here in the dark at work feeling like the worst person in New Mexico.
|
||||||
|
|
||||||
|
LUKE: Well, why did you say no, over 400 bucks, if your mother is freezing in New Mexico?
|
||||||
|
|
||||||
|
HEATHER: Because I am 17 thousand dollars in debt and I literally do not have four hundred dollars. Like, that's not a figure of speech. I have maybe 80 bucks until Friday.
|
||||||
|
|
||||||
|
LUKE: Okay, well then you can't give her the money. No big deal. You've got to say no. If you don't have the money to give her, you can't say yes. That would be strange. And then, you know, when she comes to get the money, you don't have it.
|
||||||
|
|
||||||
|
HEATHER: Oh, that'd be even worse.
|
||||||
|
|
||||||
|
LUKE: Yeah, I bet you would feel like a silly goose. So it sounds to me like you did the right thing.
|
||||||
|
|
||||||
|
HEATHER: No, I know, I know you're right. Logically, I know that. But like, Okay, here's the thing. My mom is not great at taking care of herself. Like, she's getting older and she's getting smaller, if that makes sense. And I'm watching that happen, and I can't, I just feel like saying no to her is the same as saying, I can't take care of you. And that feels really bad at 25.
|
||||||
|
|
||||||
|
LUKE: Well, you're 25. You don't have the money to support your parents financially. That's not strange. You can do what you can to take care of her. Do you have a warm place that she can go? Where do you live?
|
||||||
|
|
||||||
|
HEATHER: I mean, I'm at work right now. I do overnights at a group home, so yeah, I'm warm. She's over in, she's not that far. She's maybe 40 minutes away. And I'm sure she's fine. She probably has space heaters or whatever. I'm not saying she's actually freezing to death. I'm saying I feel like she might be, and I can't do anything about it even if she was.
|
||||||
|
|
||||||
|
LUKE: Well, that's a shitty place to be, but if that's where you're at, that's where you're at. You're going to have to continue working on your own life first. Make sure you can get it on your feet before you can support anybody else, even your parents. If there's something you can do to help, then great. If you can help her maybe get funding from different sources or look for, I don't know, churches that can help or, you know, get her tied into the community and any sort of health care benefits that she's eligible for, you can help her in that way. But if you don't have the money yourself, then you don't have the money for her and that's, you don't have to feel bad about that. Just do your best.
|
||||||
|
|
||||||
|
HEATHER: Yeah. Yeah, no, that's... Okay, the church thing actually isn't a bad idea, and I don't know why I didn't think of that. There's probably resources. I think I've just been so in my own head about it that I went straight to I failed her. Instead of like, okay, what else can we do?
|
||||||
|
|
||||||
|
LUKE: Yeah, I think a lot of people do that. And the thing is, it's not about you. That's a very common thing. Everybody makes everything about themselves. And this isn't about you. If you can help, then help. And there's lots of different resources out there that are available. You just got to track them down. If you're good at searching on the computer and stuff, then you have a better chance to find them than she does. But ask around, you work at a group home, so I'm sure that you're familiar with a big list of resources for housing and heating and phone assistance and utilities, all that jazz. So you can get her as settled as you can and do the best you can and don't feel bad when you can't take care of her, because the truth is it was her responsibility to take care of herself.
|
||||||
|
|
||||||
|
HEATHER: That last part hit different. Yeah. I mean, yeah. Yeah. I mean, yeah. No, honestly, it's true. And I think that's the part I've been avoiding saying out loud because it feels mean. But like, she's a grown woman and I'm her kid. And somewhere along the way that got flipped around, and I've just been accepting that as normal.
|
||||||
|
|
||||||
|
LUKE: Well, it's not as black and white as that. So I don't know your situation. I don't know your mother. I don't know if she has mental health issues or physical ailments or what the history is there. All I know is you're 25 years old. You're getting on your feet and you can't hold yourself responsible for the well-being of a grown adult that had 60 years to get themselves on the correct financial footing. So now that we're through that, you can feel all right. It's time for trivia. Are you ready for the trivia?
|
||||||
|
|
||||||
|
HEATHER: Wait, trivia? Okay. Yeah. I'm, yeah, hit me. I've been sitting in the dark for three hours. I might as well win something.
|
||||||
|
|
||||||
|
LUKE: Okay, well, you're not going to win anything, but we just want to hear your answer. So, a Sicilian immigrant invented a famous New Orleans sandwich in 1906 at the Central Grocery. What is it called?
|
||||||
|
|
||||||
|
HEATHER: Oh, that's easy. The muffilletta. My grandpa used to get them from this little place over in, well, it's not there anymore, but back in the day. He'd bring one home and it'd feed us for three days.
|
||||||
|
|
||||||
|
LUKE: All right. All right. Well, thank you for the call. Do your best with your mom. Try and help her out if you can. Don't take too much responsibility. And make sure that you're putting your own oxygen mask on first. That was the correct answer. I've never had a muffletta. I don't know where a muffletta is, but it sounds gross. Anyway, it's about time for a word from our sponsors. All right, folks. It's time to take a break to thank today's sponsor. The leader in Maple-based Marmalade. If your toast has been living in a bland little studio apartment of flavor, it's time to move into the luxury suite.
|
||||||
|
|
||||||
|
LUKE: We're talking maple-based marmalade. Plus, marmalade-related products, because apparently Jam Hospitality asked, what if breakfast had a brand ecosystem? Go to www.myfavoritmarmalade.com and use code jam it in you for 15% off. That's Jam it in you, like jam it in you, but legally distinct and emotionally complex. Jam hospitality. Put it on toast. Put it on pancakes. Put it on your confidence. That's www. my favorite marmalade.com. Tell him Luke sent you. You're welcome. All right, and we're back. Archie. Archie, welcome to the show. How are you? Happy Fake Fat Tuesday.
|
||||||
|
|
||||||
|
ARCHIE: Oh, man, Luke. I'm about three cups of coffee and a half pack of camels past happy. But hey, Fake Fat Tuesday. Does that mean I could eat a king cake and still call it a salad? Yep. Because I could use the sugar rush right now. So here's the thing. I just found out my landscaping companies getting shut down in nine days. And they made me sign a non-disclosure agreement they told me. Like, here's your pink stuff. slip, here's a pen. Don't tell the guys you've been working with for three years that we're ghosting them. And tomorrow morning, I'm supposed to bid a residential job with Tommy and Jesse, guys who've got families, who think they've got work through March.
|
||||||
|
|
||||||
|
LUKE: Okay, so just to get it right, you signed a non-disclosure agreement, an NDA, as we like to call it in the business, to not mention anything about the landscaping company being closed because I guess you're getting paid off or something like that. They would have you sign an NDA. And the first thing you do is call a radio show and tell the world about the landscaping company. How does that work?
|
||||||
|
|
||||||
|
ARCHIE: Yeah. Okay. I hear you. I hear you. In my defense, I didn't use the company name. And also, it's like two in the morning and I'm sitting in a Jewel Oskow parking lot in Chicago. So I'm not exactly thinking like a lawyer right now. That's actually part of a. why I'm calling. I'm trying to figure out if I even need to worry about that NDA. Because I genuinely don't know if what they made me sign is even legal. Like, can a company actually make you sign something that says you can't warn your co-workers that they're about to get blindsided?
|
||||||
|
|
||||||
|
LUKE: Yes, they can. And you did sign it. So if I were you, I would keep my fucking mouth shut. But if you can't do that, it's unlikely that they would be able to be able to enforce anything on you. They're not going to sue you for telling Pablo that he doesn't have to go to work tomorrow.
|
||||||
|
|
||||||
|
ARCHIE: Right, right. And that's, okay, that actually makes me feel a little better. Because what are they going to do? Sue a guy who's about to be unemployed? Like, come after my 2009 F-150 in my collection of gas station sunglasses. Good luck. But here's the part that's actually keeping me up at night, Luke. It's not even the NDA.
|
||||||
|
|
||||||
|
LUKE: Okay, what is it?
|
||||||
|
|
||||||
|
ARCHIE: It's Tommy and Jesse. Because tomorrow morning at 8 o'clock, I'm supposed to sit across from these two guys at a diner and walk them through a bit on a residential project. Let them measure square footage and talk about mulch prices and act like they've got six weeks of work lined up. And I know the second they cut me loose, the company's going to ghost that contract. Like it just disappears. And these guys are going to be standing in some homeowner's driveway. wondering what happened.
|
||||||
|
|
||||||
|
LUKE: Well, for your meeting tomorrow, are Tommy and Jesse being compensated for their time?
|
||||||
|
|
||||||
|
ARCHIE: That's, ha. I mean, no. No, it's a bid. Nobody gets paid to bid a job. You show up. You measure. You talk numbers. You hope you get the work. So they're not losing money tomorrow exactly. They're just, they're losing time they could spend, lining something else up.
|
||||||
|
|
||||||
|
LUKE: you're sick tomorrow and that's how you handle it. You know?
|
||||||
|
|
||||||
|
ARCHIE: Oh, I thought about that. I really did. But here's the thing. Tommy's got a kid with asthma. And Jesse's wife just got laid off from the school district. They're counting on this. And if I don't show up, they're going to think I'm flaking on them. And then they'll be scrambling to find another crew to bid with. And that's just, that's another week of uncertainty for them. And I can't do that.
|
||||||
|
|
||||||
|
LUKE: Well, you can't do that. that. And it sounds to me like you don't really have to do anything here. It's not you closing down the company. And you promised the company in writing that you wouldn't tell anybody. So if you have any sort of pension or severance package on the line here, I think that's what you would risk by breaking the NDA.
|
||||||
|
|
||||||
|
ARCHIE: Yeah, no, you're right. And I know you're right. And I know you're right. There's no pension. I'm a landscaper, Luke. Not a longshoreman. But there is severance. And honestly, I think that's the only reason I signed the thing in the first place. Like they put the paper in front of me and I just, I signed it. Monday afternoon still had dirt under my fingernails. And I just signed it.
|
||||||
|
|
||||||
|
LUKE: Well, then you're in the position you're in. And you can't worry about everybody else. So I would, if I were you, I would start looking for a new employment opportunity.
|
||||||
|
|
||||||
|
ARCHIE: Yeah. Yeah, that's. I know. No. And I've been doing that. Or I've been telling myself I've been doing that. But mostly what I've been doing is driving around Juulosco parking lots at midnight. So, you know, productivity's been high.
|
||||||
|
|
||||||
|
LUKE: All right. Well, you know what you've got to do now. And now it's time for fake Fat Tuesday trivia. Are you ready?
|
||||||
|
|
||||||
|
ARCHIE: Oh, yeah. Sure. Hit me. I'm already in a parking lot at midnight. I got nowhere to be.
|
||||||
|
|
||||||
|
LUKE: New Orleans hosts the oldest continuously running gay Mardi Gras crew in the country. What's it called?
|
||||||
|
|
||||||
|
ARCHIE: Oh, man. I have absolutely no idea. The Beads and Boas?
|
||||||
|
|
||||||
|
LUKE: No, sir. It's actually called the Crew of Yuga, founded in 1958. How progressive. Thank you for the call, sir. I'm sorry to hear about your situation at work. If I were you. I would keep my mouth shut and honor the NDA and try to help, try and help out your friends as much as you can in some other way after they get the news. All right, we're moving on. Thank you again for the call. Misty, Misty, welcome to the show. How are you tonight?
|
||||||
|
|
||||||
|
MISTY: Not great, Luke. I came home from Tucson tonight, and my brother threw a party in my house while I was gone. destroyed the place.
|
||||||
|
|
||||||
|
LUKE: And destroyed how? How bad?
|
||||||
|
|
||||||
|
MISTY: Soaked carpet, cracked flat screen, boot-shaped hole in my bathroom drywall, and someone ripped the handle clean off my refrigerator. Like, the handle, off the refrigerator.
|
||||||
|
|
||||||
|
LUKE: Oh, that's not good. What is the carpet soaked in?
|
||||||
|
|
||||||
|
MISTY: You know what, Luke? I did not investigate that closely, and I am choosing not to think about it right now.
|
||||||
|
|
||||||
|
LUKE: Okay, and what's your brother have to say for himself?
|
||||||
|
|
||||||
|
MISTY: He woke up confused, like genuinely confused why I was upset, said it was just a few people, just a few people from animus, tore through my house, and he's looking at me like I'm the unreasonable one.
|
||||||
|
|
||||||
|
LUKE: Well, those animus people are pretty rowdy. I'll give him that. And it sounds like your house is trash pretty good, and he's going to have to have a better answer than that. So is he going to help you pay for the damages and the repairs?
|
||||||
|
|
||||||
|
MISTY: Oh, he, like, real generous. He said he'd help me out with some of it. But Travis's idea of helping out is showing up with a case of beer and a shop vac he borrowed from his buddy's uncle. He doesn't have a job, Luke. He's been between gigs since last summer, which in Travis speak means he's been living off my leftover takeout and whatever he can scam off his friends. And now he's got the nerve to act like I'm being dramatic.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds to me like it might be time. to cut some ties with your brother. You know, let him, let him figure out his own thing and stop their own parties at your house. Because, uh, you shouldn't have to support a grown-ass man.
|
||||||
|
|
||||||
|
MISTY: No, and that's the thing. I know you're right, and I've known your right for a while now. But he's my brother, you know, like our mom passed three years ago, and it's kind of just been us since then.
|
||||||
|
|
||||||
|
LUKE: Well, just to help you out, because this is the kind of guy I am, I'm going to give you a shot at redemption, all right? I got an easy one for you. You ready?
|
||||||
|
|
||||||
|
MISTY: Okay, yes. Yes. Give it to me. I'm ready. I'm ready. I'm focused.
|
||||||
|
|
||||||
|
LUKE: That's what she said. Okay, the song, House of the Rising Sun, is about a New Orleans establishment. What kind of place is it?
|
||||||
|
|
||||||
|
MISTY: A brothel.
|
||||||
|
|
||||||
|
LUKE: That is correct. Congratulations. Redemption. Hold on. We got to play the correct sound so you can get the full experience. All right. Nice job.
|
||||||
|
|
||||||
|
MISTY: Oh, thank God. Okay. Yes. I needed that. I was sitting here, sweating and my truck.
|
||||||
|
|
||||||
|
LUKE: All right, Misty. Thanks for the call. Sorry, your place got trashed. It's time to get your brother out of there and move on with your life. So that doesn't happen again. So I hope you can get it all cleaned up and investigate what the carpet is soaked with. Maybe replace that carpet. And now, folks, it's time for another word from our sponsors. This episode is brought to you by Nile Nosh. Meal kit for people who've given up but still need to eat something. Look, we're not going to promise that it'll change your life. You're still going to die alone. But at least you can spend your remaining Tuesday evenings chopping vegetables you can't pronounce. Each box contains three meals that take 45 minutes to make when they could have taken eight because suffering is the only authentic human experience. This week's menu, Horissa glazed despair with quinoa, panseered regret over arugula, and our customer favorite, chicken thighs with preserved lemon and unprocessed drama. Use code void at checkout for 15% off your first box. That's code void, because we're all just screaming into one. Nile Nosh. You got to eat something. Might as well be complicated. Okay. And we're back. Next up on the line, we've got Chenequah. Chenequah, thanks for calling. And happy fake fat Tuesday.
|
||||||
|
|
||||||
|
CHENEQUAH: Ha, happy fake fat Tuesday to you. You too, Luke. Okay, so I hit somebody's car last month and drove away. And now I see that car everywhere. And I think the universe is absolutely torturing me for it.
|
||||||
|
|
||||||
|
LUKE: As it should be. What are you going to do about that? You're just going to live with it?
|
||||||
|
|
||||||
|
CHENEQUAH: That's the thing. I don't know. Like I keep telling myself I'm going to do something. And then I just don't. And I'm a social worker, Luke. 33 years. I sit across people every single day and tell them the hard thing is always the right thing.
|
||||||
|
|
||||||
|
LUKE: Well, do you have insurance?
|
||||||
|
|
||||||
|
CHENEQUAH: I do. Yeah. Full coverage. Which actually makes this worse, right?
|
||||||
|
|
||||||
|
LUKE: Yeah, it does. So I don't know what you're calling in looking for me to tell you. Obviously, what you should do is talk to the owner of the vehicle that you hit and make it right. That's the right thing to do. That's what you should do. And not doing that is a crime. And it makes you a bad person.
|
||||||
|
|
||||||
|
CHENEQUAH: Oh, I know I'm a bad person, Luke. That's not the part I'm struggling with. I'm a bad person. I just... I want to do the right thing. Sorry, I fumbled that. I want to do it. I know exactly what I need to do it. I know exactly what I need to do. I just, every time I see that car, I kind of freeze up and then I drive past it, and then I hate myself for the next two hours.
|
||||||
|
|
||||||
|
LUKE: Do you know the owner of the car?
|
||||||
|
|
||||||
|
CHENEQUAH: No, that's the thing. I don't know who it is. I mean, I know it's a white Nissan with a crumpled bumper and a cracked tail light, but I don't know who owns it.
|
||||||
|
|
||||||
|
LUKE: Well, here's what I think you should do. I think you should take yourself down to the police station, explain what happens, say you panicked, and come clean about the situation in case maybe they're looking for someone. So you can save a lot of people a lot of time and then help the owner of that vehicle get it fixed through your insurance.
|
||||||
|
|
||||||
|
CHENEQUAH: Yeah. Yeah. I know you're right. And the thing is, and I know how this sounds, but I keep driving past that police station too. Like, I have driven past it.
|
||||||
|
|
||||||
|
LUKE: Okay. Well, what would you like me to tell you?
|
||||||
|
|
||||||
|
CHENEQUAH: I don't know, Luke. I don't know what I'm calling for. I just, I keep seeing that car. It's like it's me store and there it is. I'll be at the clinic where I work. And there it is.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like you've got a lot of opportunities to make things right. So it's about time you do that. Hey, we're doing trivia and I've got a trivia question for you. Are you ready? Since you clearly don't have anything else to talk about?
|
||||||
|
|
||||||
|
CHENEQUAH: Oh, God. Okay, yeah. Hit me. I deserve that.
|
||||||
|
|
||||||
|
LUKE: Okay. What New Orleans cocktail was invented at the Roosevelt Hotel Bar and is made with cream, orange flower water, and soda.
|
||||||
|
|
||||||
|
CHENEQUAH: Oh, that's easy. It's a Sazirac, right? Wait, no, that's not right. The Sazirac doesn't have cream or soda. Hmm. Wait, is it a Viochere? No, that's got cognac and vermouth.
|
||||||
|
|
||||||
|
LUKE: No, it is the Ramos gin fizz, and it requires 12 minutes of shaking. Bartenders used to form relay teams just to mix one of them. All right. Thank you for the call, Shaniqua. You're going to have to do the right thing or not do the right thing, one way or the other. But either way, you're a horrible person, and we appreciate your call. Okay, thank you, Shaniqua. Next up, we got Curtis. Curtis, welcome to the show. Happy fake fat Tuesday.
|
||||||
|
|
||||||
|
CURTIS: Hey, Luke. Thanks, man. So, my sister has been on dialysis for 18 months, and I just read that their 3D printing kidneys now, and nobody at her clinic has said a single word about it.
|
||||||
|
|
||||||
|
LUKE: Okay, maybe they don't have the ability to 3D print a kidney in your particular clinic.
|
||||||
|
|
||||||
|
CURTIS: This is nowhere in New Mexico. We don't really have the best medical equipment here. No, I hear you. But this wasn't in New Mexico. This was Baltimore. That's even worse. A woman in Baltimore got one. And I'm sitting here at the Flying J at two in the morning thinking, okay, so this exists. This is how to do you happening? Why does nobody at the dialysis clinic even mention it? Like is Denise supposed to just keep driving four hours round trip to Albuquerque three times a week forever? Or is there actually something out there?
|
||||||
|
|
||||||
|
LUKE: I think driving four hours round trip to Albuquerque three times a week forever is the business model, sir. So yes, that's exactly what they wanted to do. There may be something out there.
|
||||||
|
|
||||||
|
LUKE: Have you done any independent research into 3D printable kidneys? Maybe that's something that you can push for. or, you know, advocate for.
|
||||||
|
|
||||||
|
EARL: I wouldn't expect the medical industry to really ever push you in the right direction. That's not how they get paid.
|
||||||
|
|
||||||
|
LUKE: No, that's, yeah, that's a dark way to look at it, but I don't think you're wrong.
|
||||||
|
|
||||||
|
EARL: And honestly, that's kind of what's eating at me. Like I read this one article on my phone between customers tonight, and now I don't know if I'm holding on to something real or if I just found the one hopeful thing on the internet at three in the morning and I'm running with it. Because I won't. want to. You know what I mean? Like, is this actually close or is this a checkback in 2035 inches situation?
|
||||||
|
|
||||||
|
LUKE: I think it is close from similar articles that I've read about hearts and replacement shoulders. And I think it's very close. And the truth is 2035 is very close. So I don't know that it's going to be this year or next year. Maybe they're already doing it. I don't know. You might have to go to L.A. or something. Or, um, Boston or somewhere where, you know, they actually study medicine, not Baltimore.
|
||||||
|
|
||||||
|
EARL: Right, right. And that's the thing. Denise is in Arizona. She moved out there a few years back, and I'm over here in New Mexico, and she's got this clinic situation locked in, and I don't even know if she's on a transplant list. I don't know if she's looked into any of this. And now I'm the guy working the graveyard shift at a truck stop who read one article and is calling a radio show at three in the morning like I cracked the code. But somebody's got to be. paying attention, right?
|
||||||
|
|
||||||
|
LUKE: No. It's everybody's responsibility to pay attention when things relate to themselves. Like, it's her responsibility. But you're in a position where you're working in the night shift and you have time and you seem somewhat interested in this subject. So maybe you can use your time at work to do that research and maybe find her a better deal and be her advocate. And that would be a good use of your skills.
|
||||||
|
|
||||||
|
EARL: That's, yeah. Yeah, you're right. And honestly, that hits a little harder than I expected at three in the morning. Because I think part of why I'm so wound up about this is that I feel useless. Like she's the one doing the driving and the needles and the whole thing three times a week, and I'm just, I'm here.
|
||||||
|
|
||||||
|
LUKE: Well, you're not the one that needs the dialysis, so it makes sense that she would be doing it. And that's unfortunate. I'm not saying she deserves it or you don't deserve it. We get what we get. But in your situation, you're not the one that's sick, so it's not on you. But if you can help and you have the time to help and you have the ability to help, then that would be a good thing for you to do. And maybe you can help others as you learn more information about this.
|
||||||
|
|
||||||
|
EARL: No, I hear you. And I know it's not on me. But that doesn't stop me from feeling like I should be doing more. Like, I'm the one who's got the time to sit here and read articles between customers. And she's the one who's got to live with it. And I don't know. Maybe that's just how it is. But it still feels like I'm letting. her down by not knowing more.
|
||||||
|
|
||||||
|
LUKE: Well, you are not. It's not your responsibility to know anything about dialysis. So the fact that you even read the one article and care enough to call a radio show and talk about it says a lot. I think if you want to invest your time into helping her out, maybe you can make something happen there or at least let her know that you tried, you know, and sometimes that's enough. It's a horrible thing that people have to go through. And hopefully future soon. They can 3D print new parts for people when they fail. That's, I think, what we're all hoping for.
|
||||||
|
|
||||||
|
EARL: Yeah. Yeah, man. I hope so too.
|
||||||
|
|
||||||
|
LUKE: Okay, so we're doing fake Fat Tuesday trivia. I've got a question for you. Are you ready?
|
||||||
|
|
||||||
|
EARL: Oh, I'm ready. Hit me. I got nothing but time out here.
|
||||||
|
|
||||||
|
LUKE: All right. New Orleans is known for second lines. What exactly is a second line?
|
||||||
|
|
||||||
|
EARL: Okay, so a second line is basically this wild, unplanned parade that happens after a funeral in New Orleans. The main funeral procession is all somber and formal, right? But then, after the burial, the family and friends, and just who wants to join, musicians, dancers, random folks off the street, all start following the band again. But this time it's a brass bands play, people dance in the street. It's all about celebrating the person's life instead of just mourning. Yeah, man, it's a pretty cool. cool thing.
|
||||||
|
|
||||||
|
LUKE: I don't know why other places don't do that. It's just a big party when somebody dies. So everybody mourns real quick and then everybody parties behind them. That's the way it should be. And it's a cool thing that New Orleans does. And we appreciate the city of New Orleans for that.
|
||||||
|
|
||||||
|
EARL: Hell yeah, that's the spirit. I mean, if you're going to go, might as well go out with a brass band and a two-step, right? None of this quiet, sad funeral nonsense. Just send them off with a good time.
|
||||||
|
|
||||||
|
LUKE: you and thanks for the call. Don't feel too bad about your involvement and your sister's medical procedures. She's going to have to go through what she has to go through. And if you can help, great, do what you can. But don't take it upon yourself to be responsible because it's not on you. You're not her caregiver. And anything you do is you trying to help. We got one more caller today. And our caller is Carmen. Carmen, thanks for calling in. It's a Big Fat Tuesday, you're the last caller of the night. I've got some trivia for you. Are you ready for it?
|
||||||
|
|
||||||
|
CARMEN: Wait, hold on. Before you hit me with trivia, can I just? I just pulled my clarinet out of a closet for the first time in 10 years, and I don't know what to do about it.
|
||||||
|
|
||||||
|
LUKE: Well, how about you play us a little ditty?
|
||||||
|
|
||||||
|
CARMEN: I wish. I opened the case and just kind of stared at it. I don't even know if it's still in playable, can't.
|
||||||
|
|
||||||
|
LUKE: How long you play the clarinet for?
|
||||||
|
|
||||||
|
CARMEN: From when I was about nine until I was 18. So almost 10 years. I was good, Luke.
|
||||||
|
|
||||||
|
LUKE: You play the clarinet for 10 years and you don't know by looking at one if it's in playable condition?
|
||||||
|
|
||||||
|
CARMEN: Okay, fair. The reeds are probably shot. The pads might be dried out. I know what to look for. I just haven't let myself actually look yet. I've been sitting here with the case open for like an hour, just not touching it.
|
||||||
|
|
||||||
|
LUKE: Okay, why aren't you touching it? If you pulled it out, you obviously want to play, you're thinking about it again. You've played for 10 years, so you certainly know how. I think it's fairly easy to get reeds and replacement parts at a music shop these days. What's the problem?
|
||||||
|
|
||||||
|
CARMEN: Because I saw something tonight that kind of cracked me open a little bit. There's this woman, She got a scholarship to University of Arizona 28 years ago, a scholarship that I turned down. And she just posted on Instagram that she's going on a national tour with the Santa Fe Chamber Orchestra, playing the same piece I used to practice until my fingers went numb.
|
||||||
|
|
||||||
|
LUKE: Well, obviously, you didn't want to follow that path or you wouldn't have put the clarinet down for so long. But since you did, if it's something that you want to get back into, You still got time. You can still play that piece and you can still join an orchestra. You don't necessarily need a scholarship to a university. There are plenty of chamber orchestras that will take you.
|
||||||
|
|
||||||
|
CARMEN: Oh, Iyat. I wanted it so bad I could taste it. But my mom left when I was 16, and my dad was working two jobs, and my little brothers were still in elementary school. Someone had to keep things together. So I stayed home, took the community. college route and ended up working southwest routes out of Phoenix. And now my dad keeps showing up at my place unannounced since he retired, reorganizing my kitchen cabinets and asking why I never play anymore.
|
||||||
|
|
||||||
|
LUKE: Okay, well circumstances came up. Your life took an unexpected turn and you dealt with it. And now you're at a different place. You obviously are self-supporting.
|
||||||
|
|
||||||
|
LUKE: Your father is retired. And you can get back into the clarinet. If you want to, if it still calls to you, then it's not something that you're going to forget. It's there for the rest of your life. And if an orchestra is something that you want to be a part of, you can absolutely do that.
|
||||||
|
|
||||||
|
CALLER: I know. I know you're right. And that's almost the scarier part, right? Like, it's not impossible. There's a community orchestra over in Las Cruces. I've driven past their sign a hundred times. Terrible. If 28 years did what 28 years does, then I have to actually grieve something.
|
||||||
|
|
||||||
|
LUKE: Well, hey, I mean, realistically, you're going to pick that up. You are going to be terrible in comparison to how you were 20 years ago. But you'll pick it up quickly. You just got to put in the time. And if you love the clarinet, then you have no problem putting in the time. And you'll get back up to speed. You get your chops back. And I'm sure that the last cruis orchestra is not, um, not, I don't want to say not great, but I mean, your ability level will probably fit in well with a smaller orchestra like that.
|
||||||
|
|
||||||
|
CALLER: Huh. No. That's okay. That's actually really funny and also probably true. And kind of a relief. Like, I'm not trying to get to Carnegie Hall. I just want to play again. I just want to play again. I just want to feel that thing again where you're so inside the music that everything else just
|
||||||
|
|
||||||
|
LUKE: Well, you can do that. You've got that clarinet right there. All you got to do is run down to the music store, get some reeds. If you don't have a music store, then order them online. But they're not expensive. You know what you need to do. Pick up the clarinet and play some music and do that every day for a while. Get your chops back up and then think about joining that orchestra because there's nothing cooler than listening to an orchestra.
|
||||||
|
|
||||||
|
CALLER: Yeah, yeah, you're right. And honestly, the clarinet's right here in front of me. I've been staring at it for an hour, like it's going to bite me. My dad's going to lose his mind when he finds out, by the way. He's going to show up and reorganize my living room and take full credit for it somehow.
|
||||||
|
|
||||||
|
LUKE: Hey, good for him. You know, he's got to get some small wins.
|
||||||
|
|
||||||
|
CALLER: That's okay. That's fair. He does. He really does. The man raised three kids by himself. And now he's retired and lonely and showing up at my place moving my spice rack around. Let him have it. But, okay, I have a question for you.
|
||||||
|
|
||||||
|
LUKE: All right. Hit me with it. What's your question?
|
||||||
|
|
||||||
|
CALLER: Do I play it tonight? Like right now, after we hang up? Because it's almost, what, one in the morning? I've got a flight out of Phoenix tomorrow. But I'm sitting here. And the case is open and I just, I kind of want to.
|
||||||
|
|
||||||
|
LUKE: Well, you said that it had dried out reeds and it may not be in playable condition, but if you can play it, yeah, sure, you should. But it's not one in the morning. It's closer to quarter of four in the morning. And you're calling Luke at the Roost. It's our fake fat Tuesday special. And I have a question for you now. It's my turn. Are you ready for my question?
|
||||||
|
|
||||||
|
CALLER: Quarter of four? Oh my God. I have to be up in. Okay. Yes. Yeah. Hit me with your question. I'm ready.
|
||||||
|
|
||||||
|
LUKE: Okay. What is a neutral ground in New Orleans? And why is it called that?
|
||||||
|
|
||||||
|
CALLER: Oh. Oh, no. Okay. Um, is it the median?
|
||||||
|
|
||||||
|
LUKE: It is the median. You're correct. Canal Street once served as the boundary between Creole and American settlements. In the middle was considered a neutral territory. So that's why they call it neutral territory. Thank you for the call. Congratulations on your newfound reinvigoration into music. I hope that goes well for you and that you get into that orchestra. We'll talk to you a little bit later. That's the end of our show.
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
LUKE: Okay, welcome back to the show. It's Luke at the Roost. Tonight is Wednesday, February 18th. It's 11.11 p.m. And we're in a windstorm here in the desert inanimate, but that's not going to stop us. Little wind never stopped a good radio show. That's what I always say. If you'd like to call into the show, our numbers 208-439-58-3. That's 208-439 Luke. and you'd like to participate, you can send us an email. Our email address is submissions at lukeatsderoose.com. So, that all being said, I hope everybody's having a good night out there tonight. We're going to get right to our show. First up, on the show, we've got Chester. Chester's calling in. What's going on in your life, Chester?
|
||||||
|
|
||||||
|
CHESTER: Luke, hey, so I just finished about an hour ago, and I'm sitting here at work absolutely wrecked. And I got to ask you something about it, about this. I work nights at the prison down in Donia Ana County, and I read the last chunk of it during my rounds tonight. Father and son pushing a shopping cart through literal apocalypse, right? And the father would die for this kid. Does die for this kid, basically. And here's the thing. My girlfriend Sarah is up in Albuquerque, and I drive three hours each way every other Friday to see her. Been doing it for eight months. Okay, so where's the thing? canceled on me. Always some excuse. Works crazy. My roommate needs me, whatever. And I'm sitting there in B Block at two in the morning with tears in my eyes because Cormac McCarthy just destroyed me with this father who'd walk through fire, and I'm thinking, I'm the one doing all the walking here. She won't even meet me halfway in Socorro.
|
||||||
|
|
||||||
|
LUKE: Well, maybe she's not the right one, or she's not that interested in you, or she's busy. Her life could be busy. You don't know. What's her life like? Does she have other responsibilities she's got to take care of? Does she not have the money to pay for the gas, maybe? There's a lot of reasons she might not be able to meet you halfway. Are you talking about the road?
|
||||||
|
|
||||||
|
CHESTER: Yeah, the road. Sorry, should have said that up front. And look, she's a manager at a brew pub, makes decent money, drives a newer Tacoma than mine, no kids, no sick parents, nothing like that. I've asked her straight up, can we split the drive sometimes? And she just says Albuquerque's better because there's more to do. All her friends are there, which, fine. But then don't cancel on me four times because your roommate's having boy problems where you picked up an extra shift you didn't have to pick up. And here's what's eating at me. The father in the book, he's got nothing.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like maybe she's not the right one for you or you're not the right one for her. And if you're looking for more from her and you're not getting it, then move on, that's all.
|
||||||
|
|
||||||
|
CHESTER: Yeah, yeah, you're right. I know you're right. It's just I keep making excuses for her the same way I'm sitting here defending her to you right now. Oh, works busy Albuquerque's better. Like I'm trying to convince myself.
|
||||||
|
|
||||||
|
LUKE: What's your situation? Like, where do you live? What's your environment?
|
||||||
|
|
||||||
|
CHESTER: I'm in Los Cruces. Got a little one bedroom off Lohman, nothing fancy, but it's mine. Work overnight at the prison three, four nights a week, depending on the schedule. Been there two years.
|
||||||
|
|
||||||
|
LUKE: Okay, so you're in Las Cruces. There's plenty of people around there. be too hard to do.
|
||||||
|
|
||||||
|
CHESTER: I mean, yeah, there's people. But I don't know, man. I work nights, sleep days, my social life is basically non-existent. Most of the guys I work with are married or they're 20 years older than me. I'm 28.
|
||||||
|
|
||||||
|
LUKE: Yeah, you're 28 in Las Cruces, New Mexico. You can throw up a little Tinder ad and see who bites. But are you in love with this girl?
|
||||||
|
|
||||||
|
CHESTER: No, that's the thing. I'm not. me so hard tonight because the father in it he loves that kid so much he'd die for him without thinking twice.
|
||||||
|
|
||||||
|
LUKE: Don't they eat people in that book?
|
||||||
|
|
||||||
|
CHESTER: Yeah they do. There's these roving gangs. They keep people in basements like livestock. It's brutal, but that's what makes the father's love for the kid so, I don't know. Pure? He's protecting him from all that.
|
||||||
|
|
||||||
|
LUKE: Well maybe you should eat a little more of her and she'd be more inclined to come visit.
|
||||||
|
|
||||||
|
CHESTER: Jesus, Luke. Okay, fair point. But honestly, last time she was down here, things were...
|
||||||
|
|
||||||
|
LUKE: Things were what?
|
||||||
|
|
||||||
|
CHESTER: Off. Like, we had sex, but it felt like she was checking a box, you know? And then she spent half the weekend on her phone texting her friends in Albuquerque. I'm driving six hours round trip every other Friday to feel like an obligation.
|
||||||
|
|
||||||
|
LUKE: Yeah, don't do it anymore. I mean, you're not in love with the chick. She's really far away. of women around you. You can move on or not. You know, it's okay being single too. It sounds like you don't really keep a consistent, well, I mean, you don't keep a normal daytime schedule like most people. So when do you have time to spend time with her?
|
||||||
|
|
||||||
|
CHESTER: That's exactly it. I don't. My days off are like Tuesday and Wednesday. She works a normal job, nine to five. So even when I drive up there, she's got brunch plans with her girlfriends on Saturday, or she's tired from her week, and I'm sitting in her apartment waiting for her to have time for me.
|
||||||
|
|
||||||
|
LUKE: Yeah, that's disrespectful, so, I mean, you should just cut that out. There's no reason you should be doing that. She's obviously not into you.
|
||||||
|
|
||||||
|
CHESTER: You're right. I know you're right. It's just, I keep thinking if I just try harder, show up more, be more understanding about her schedule, then she'll, but that's the thing with the book, read the book.
|
||||||
|
|
||||||
|
LUKE: I saw the movie and all I remember from it was that they ate people.
|
||||||
|
|
||||||
|
CHESTER: Yeah, they do. There's these roving gangs. They keep people in basements like livestock. You already said that. It's brutal. But that's what makes the father's love for the kids so, I don't know, pure. He's protecting him from all that.
|
||||||
|
|
||||||
|
LUKE: Okay, so break up with your girlfriend because you don't have time and she doesn't have time and you're not into each other and there's no reason to be spending the gas money. That's what I say to you. Break up with the girl.
|
||||||
|
|
||||||
|
CHESTER: Yeah, yeah, you're right. I'll do it. I'm not driving up there this Friday.
|
||||||
|
|
||||||
|
LUKE: All right, good for you. You do it. That's, that's, uh, that's the can-do spirit that we look for here at Luke at the Roost. Next up on the radio show, we have Terry, Terry, welcome to the show. What's going on? Do you eat people?
|
||||||
|
|
||||||
|
TERRY: Oh, Jesus. No, what? No, I'm a nurse. I mean, I have some stuff, but sorry. I'm just sitting in my car in the hospital parking lot right now, and I'm kind of losing it. I found out three days ago that I'm adopted, and my mom, well, the woman who raised me, she's had a stroke, and I just found all these papers at her house, and I don't know what to do with this information.
|
||||||
|
|
||||||
|
LUKE: What are you thinking? You just found out that you're adopted, and your adopted mother, who you can call your mom, had a stroke. That's horrible. Are you around her to support and just be with her in her awful time of need?
|
||||||
|
|
||||||
|
TERRY: Yeah, I mean, I've been at the hospital every day. I just got off a 12-hour shift and I'm supposed to go back in to see her before I head home, but I'm sitting here in the parking lot just frozen. What were the papers?
|
||||||
|
|
||||||
|
Official adoption documents dated six months after I was born, April 1980. My name, Terry Lynn, a different mother's signature, the whole thing notarized and legal. They were in a box in the back of her closet, with old tax returns.
|
||||||
|
|
||||||
|
LUKE: Well, all that proves is that this woman loved you, and now she's sick, and you're there to care for her, and that's a good thing. So you don't have to worry about the being adopted thing. Lots of people are adopted. As long as she took care of you, she obviously made a decision to love you and raise you, and you're a nurse now, so you've made a decision to help people sounds ideal.
|
||||||
|
|
||||||
|
TERRY: I know. I know that. And you're right. She raised me. She loved me. She loved me. But, Luke, it's been 44 years, 44 years of her looking at me every single day and never saying a word.
|
||||||
|
|
||||||
|
TERRY: Why would she say a word? She doesn't, there's nothing to say. There's nothing to say? I have a brother, Ray. He doesn't even know yet. I don't know if he's adopted to, or if it's just me. I don't know who my actual birth mother is or why she gave me up. I don't know if my dad, if he was my real dad, or if he knew.
|
||||||
|
|
||||||
|
LUKE: Well, there's probably a good reason for that. Your birth mother might not have wanted you to know that she existed, and your adopted mother may have just been afraid that if you learned that you were adopted, you would abandon her in favor of your birth mother who abandoned you.
|
||||||
|
|
||||||
|
TERRY: Oh, God. That's, I hadn't thought about it like that. She was protecting herself from me leaving. But here's the thing. I always felt like something was off. My whole life I felt like I was performing, like I was trying to be the daughter she wanted instead of just...
|
||||||
|
|
||||||
|
LUKE: Instead of just what? I mean, what were you performing?
|
||||||
|
|
||||||
|
TERRY: I don't know. Being cheerful, being good, not being good, not causing problems. Ray was always the one who could just be himself, you know? He'd fight with her, slam doors, whatever. But I was always so careful, always trying to make sure she was happy with me.
|
||||||
|
|
||||||
|
LUKE: And why do you think that is? Does she give you a reason to behave in that way?
|
||||||
|
|
||||||
|
TERRY: No, not really. She was never mean or anything. But I always had this feeling. And maybe this sounds crazy, it. Like I had to prove I deserve to be there.
|
||||||
|
|
||||||
|
LUKE: Well, I think a lot of kids feel that way, and I'm going to ask you this, and it's not to be mean, it's not to be cold, but what makes you feel like you shouldn't have had to earn it? Like, why would you just expect it?
|
||||||
|
|
||||||
|
TERRY: I... Wow. That's a hell of a question, Luke. I guess because she's my mom, or I thought she was.
|
||||||
|
|
||||||
|
LUKE: Sure, but that's a... That doesn't mean you don't have to earn affection or likeability. She's your mom, and she loved you, and she raised you, and she provided for you, and she did all the necessary things. So what is it that you were earning from her or that you felt like you had to earn from her?
|
||||||
|
|
||||||
|
TERRY: The feeling that I actually belonged there, that I wasn't temporary, that she wouldn't change her mind. And now I find out that's exactly what I was. Somebody else's kid that she took in.
|
||||||
|
|
||||||
|
LUKE: Well, no, that's not what you were, because you were there for 44 years, right? So she raised you your whole life. She didn't ever treat you as temporary.
|
||||||
|
|
||||||
|
TERRY: No, you're right. She didn't, but she also never told me the truth. For 44 years, she let me think I was hers. And that feels like...
|
||||||
|
|
||||||
|
LUKE: Yeah, I'm sure it feels like a lot of things all at the same time, but if you try and separate yourself from the emotion of it and think about it logically and look at your life and how she treated you growing up. Was it all that bad? Does it matter, really? There are almost certainly good reasons that you were put up for adoption and that she adopted you. Maybe she couldn't have kids herself and she really wanted one. Maybe your birth mother was in a situation where she couldn't provide for you or was dying. Like, you don't know unless that's something something that you talk to her about. But I wouldn't feel like you've been wronged. It sounds, that sounds like an abundance of love to me.
|
||||||
|
|
||||||
|
TERRY: I hear what you're saying, Luke. I do, but she's had a stroke. She might not wake up. And if she doesn't, I'll never know why she didn't tell me. I'll never know who my birth mother was or why she gave me up. And Ray, my brother. He doesn't know any of this yet.
|
||||||
|
|
||||||
|
LUKE: So she had a stroke. She's in potentially the worst situation of her entire life and you're worried about whether you know why she gave you up or not. Like, why? It doesn't seem important.
|
||||||
|
|
||||||
|
TERRY: Because I spent my whole life feeling like something was off and now I know I was right. And yeah, maybe that makes me selfish when she's lying in a hospital bed, but I can't just turn off 44 years of wondering why I never quite fit.
|
||||||
|
|
||||||
|
LUKE: Well, why you felt like you never quite fit has nothing to do with her. That's entirely a you thing.
|
||||||
|
|
||||||
|
TERRY: That's not fair, Luke. How is it entirely a me thing when she kept this massive secret my entire life? When every time I looked in the mirror and didn't see her face looking back, every time someone said I didn't act like family, she could have just told me the truth and she chose not to.
|
||||||
|
|
||||||
|
LUKE: Because that's not a thing. People don't act like family. That's all about upbringing and your social environment. It doesn't have anything to do with your blood. People that are biologically related don't act a certain way because they're biologically related. They act a certain way because they grew up together. They share an intense, long-lasting life experience like you do with this woman who is in the hospital right now and your brother. And there's no reason you have to think any more about it there's nothing more to it than that. She loved you. You might not know what happened or why, but you can choose to go about the rest of your life with the understanding that you were loved and that there was likely a good reason in your best interest for the way those things played out.
|
||||||
|
|
||||||
|
TERRY: You're probably right. I know you're probably right, but sitting here in this parking lot looking at those adoption papers with someone else's signature where my mother should be, I keep thinking about all the times I asked her why I was so different. Why I was bad at the things Ray was good at.
|
||||||
|
|
||||||
|
LUKE: Because you're a different person than Ray. You know, it has nothing to do with who raised you or who your mother was.
|
||||||
|
|
||||||
|
TERRY: Okay, but then why hide it? If it doesn't matter, if blood doesn't matter, why not just tell me when I was 10 or 15 or 20? Why let me find out now like this, going through her files? Because she might die. That's what I can't get past.
|
||||||
|
|
||||||
|
LUKE: Because there could be a good reason for that. Your birth mother may have... That could have been a stipulation, like a non-disclosure agreement. There could be a very good reason that you don't know who that was.
|
||||||
|
|
||||||
|
TERRY: Oh, God. I didn't even think about that. Like maybe my birth mother didn't want to be found. Or there was something...
|
||||||
|
|
||||||
|
LUKE: Correct. And you can never know unless you can have that conversation with your mom. And you might not get that conversation. And if you think about it, logically, what would change in your life today if you had different information?
|
||||||
|
|
||||||
|
TERRY: Nothing would change. That's the thing that's killing me, Luke. I'd still be sitting in this parking lot in my scrubs. I'd still have worked a 12-hour shift. Ray would still be Ray.
|
||||||
|
|
||||||
|
LUKE: Exactly. Ray would still be Ray. Nothing would change. She would still be your mom. She would still be in the hospital. You would still be working, and everything would be exactly the same. So it really doesn't matter whether you know who your birth mother is or not. You could do one of those DNA test things or a genealogy situation. I don't know if that can go down to your actual birth parents. But why? Like, what is the point? It sounds like you had a decent upbringing. You're a grown adult with your own life now and maybe you feel like you don't fit in, but that's an unrelated problem to this.
|
||||||
|
|
||||||
|
TERRY: You're right. I mean, she raised me. She was there for every scraped knee, every bad boyfriend. Every time I called her crying about something stupid. That's what matters. Not some signature on a piece of paper I found in a filing cabinet.
|
||||||
|
|
||||||
|
LUKE: Exactly. And what matters is that she is in the hospital and she may not recover and you may not recover only have a little bit of time left with her. So I would wipe this from your mind and go spend time with your mom.
|
||||||
|
|
||||||
|
TERRY: Yeah. Yeah, you're right. I should go back in there. I've been sitting out here for almost an hour, just...
|
||||||
|
|
||||||
|
LUKE: Yep, you should go back in there. So do that.
|
||||||
|
|
||||||
|
TERRY: Okay, I will. Thank you, Luke. I really needed to hear that.
|
||||||
|
|
||||||
|
LUKE: You are very welcome, and I wish you all the best of luck. I hope she makes a full recovery. And then you can put this behind you and move on with your life. And with that, folks, it's time for a word from our sponsors.
|
||||||
|
|
||||||
|
LUKE: Look, I'm not a financial advisor. I'm a guy with a microphone and a dog. But the folks at Crypto Know asked me to tell you about their new decentralized investment platform. And I legally have to read this part. Past performance does not guarantee future results. This is not financial advice. And if you invest your rent money, you disqualify exactly what happens next. Crypto no lets you trade over 400 digital currencies, including three that were invented this morning, and one that's just a picture of my dog. The app features a real-time portfolio tracker with a built-in panic button that just plays ocean sounds when your balance drops. Crypto no! Fortune favors the bold, but it does not return their calls. It does not return their calls. It does not return their calls. And we won't return your calls, but if you call in, you can leave us a voicemail. And if I think it's funny, it might be read on the next show. So you should do that. Next up on the old radio show, we've got Travis. Travis, welcome to the show. What's keeping you up tonight?
|
||||||
|
|
||||||
|
TRAVIS: Hey, Luke. Thanks for taking my call. So I'm sitting here in my dark room, trying to work. And two hours ago, my daughter shows me this video that's been going around her school since Monday. me, my face, my voice, telling a room full of kids that the Navajo Nation doesn't deserve water rights. Except, I never said that. Someone took footage from different talks I've given and stitched it together to make me say the exact opposite of everything I actually believe.
|
||||||
|
|
||||||
|
LUKE: Well, that's pretty insane, and it's hard to believe anybody would say that. So, um, who do you think might have done this?
|
||||||
|
|
||||||
|
TRAVIS: I mean, that's the thing. I've been documenting pipeline construction and transmission lines for environmental groups and tribal councils for 15 years. I've testified against energy companies. I've worked with the Navajo Water Rights Commission. So take your pick, you know. Could be some kid who thinks it's funny. Could be someone with an actual axe to grind.
|
||||||
|
|
||||||
|
LUKE: That doesn't sound like a thing a kid would actually even know about or know to do. So it seems more likely to me that that's one of your enemies trying to slander you in front of children?
|
||||||
|
|
||||||
|
TRAVIS: It doesn't make a lot of sense. You're right, it doesn't. I mean, what ninth grade even knows who I am or cares about water rights litigation. But here's what's messing with my head. My daughter said kids are commenting on it, sharing it around. And I'm sitting here thinking, okay, if this is some coordinated thing, from an energy company or a PR firm, why target a school presentation?
|
||||||
|
|
||||||
|
LUKE: Yeah, there's a few strange things about where you're saying here. I don't think a video like that would go viral with high school kids. Maybe it would. I don't know. I've been out of high school for a while, so it just seems uninteresting to them. So do you have any known enemies? And do you have a relationship with the Navajo Nation?
|
||||||
|
|
||||||
|
TRAVIS: Yeah, I work with them pretty regularly, or I did. I've got contacts at the Water Rights Commission. I've photographed documentation for their legal cases. I've been out to Window Rock probably a dozen times in the last three years. As for enemies, I mean, there's the San Juan Coal Company. They weren't thrilled when my photos ended up in that lawsuit last year. There's a fracking outfit near Farmington that tried to get an injunction against me for trespassing on what they claimed was private land. But here's what I keep coming back to. If someone wanted to discredit me with the people I actually work with, with the tribal councils, or the environmental groups, they'd send it to.
|
||||||
|
|
||||||
|
LUKE: Yeah, absolutely. So you obviously have worked with the Navajo for a long time. You've got relationships in their council, I'm sure, and they know that you didn't say that, right? They're going to believe you when you say this wasn't me. I think at this point, everybody recognizes that this is possible and not that hard to do. So if there's a video of you saying something that is clearly against everything that you've ever stood for in your whole career, I think it should be pretty hard to dismiss. Strange to think about the why somebody would do this. What is the intention behind it?
|
||||||
|
|
||||||
|
TRAVIS: That's what's keeping me up, man, because you're right. The people who know me know me, they'll know it's fake. But my daughter's sitting there scrolling through comments from kids saying, wow, what a racist and this guy's trash. And she's like, embarrassed. She asked me if I actually said it. And I'm thinking, okay, maybe that's not about destroying my credibility with the Navajo Water Rights Commission.
|
||||||
|
|
||||||
|
LUKE: Well, that is pretty dark right there. If somebody's going to try and destroy your own relationship with your kid, uh, you must to really piss somebody off badly.
|
||||||
|
|
||||||
|
TRAVIS: Yeah, and that's what's getting to me. Because I can handle getting yelled at by some executive in a boardroom. I can handle threats. I've had those before. But watching my kid look at me like she wasn't sure if she could believe me. That hit different.
|
||||||
|
|
||||||
|
LUKE: Well, it could be another thing, too, that we haven't thought about. What if it's not your enemy, but it's an enemy of your kid that was trying to embarrass your kid by looking up information about you and publishing that to the school.
|
||||||
|
|
||||||
|
TRAVIS: Oh man, I didn't even, yeah, that's a whole different angle. She's a junior, so there's all that social hierarchy stuff I don't fully understand. She did mention it first showed up on some group chat she's in, but she seemed more confused than like targeted, you know? Like she wasn't saying, someone's coming after me. She was saying, Dad, what is this? But now, I'm wondering, does she have beef with someone I don't know about?
|
||||||
|
|
||||||
|
LUKE: She might have beef with somebody that she doesn't know about. I mean, this is the 2026 version of a bucket of pig's blood at the prom.
|
||||||
|
|
||||||
|
TRAVIS: Jesus, that's bleak. But you might be on to something, because she's been kind of quiet lately. I thought it was just junior year stress, college stuff. But what if someone's been messing with her, and this is just what finally bubbled up to where I could see it. I mean, how do I even ask her that without making it worse? Hey, honey, which of your classmates hates you enough to deep fake your dad?
|
||||||
|
|
||||||
|
LUKE: Well, you could just start with a simple conversation like, hey, I'm trying to get to the bottom of where this video came from and why it was distributed to your school. And there doesn't seem to be a good reason that any of my adversaries would target me in that way, because they wouldn't overwhelmingly, very lazy, and that takes a lot of coordination to pull off. There's a lot of information they had to pull up about who your kid was, what school they were, what chats she was in. Some oil company wouldn't know that.
|
||||||
|
|
||||||
|
TRAVIS: You're absolutely right. An oil company would just send a cease and desist, or try to get me fired from whatever freelance gig I'm on. They wouldn't know what group chat might Daufer's in. They wouldn't know her schedule well enough to time it so it hits, during the school day. That's someone who knows her world.
|
||||||
|
|
||||||
|
LUKE: Yeah, it must be, or a really, really twisted fuck that you really pissed off, and I think you would know who that was if there was that person. So it sounds more to me like it's her that is the target of this, and not you.
|
||||||
|
|
||||||
|
TRAVIS: Yeah, I think you're right. And now I'm sitting here in my dark room at midnight, feeling like an idiot, because I made this whole thing about me, about my work, my reputation, who I've pissed off over pipeline photos. Meanwhile, my kids getting targeted by some psycho 17-year-old with editing software, and I didn't even see it.
|
||||||
|
|
||||||
|
LUKE: Well, I have that conversation, see what it's about. There's probably nothing you can do about the video if it's already circulating, but luckily it's circulating in a small, contained space, and it's not going to be remembered. Nobody's going to hold on to that.
|
||||||
|
|
||||||
|
NORM: Oh, hey, Luke. Yeah. I'm good, man. Little wired, honestly. So listen, my best friend Miguel keeps asking me to move to Tucson with him, get a place together, and I keep making up excuses, but the real reason I won't do it is I think I might be in love with him and I have no idea what to do about that.
|
||||||
|
|
||||||
|
LUKE: Well, what makes you think he's not in love with you if he's asking you to move in with him?
|
||||||
|
|
||||||
|
NORM: Oh man, I don't see. That's the thing that's been driving me crazy. Like, he says it's practical, right? Split rent? We've always talked about doing that food truck thing. He's got connections with the school district down there. But then, like, last Christmas, we were both pretty drunk, and he looked at me in this way that I don't know, Luke. Maybe I'm reading into it because I want to see it.
|
||||||
|
|
||||||
|
LUKE: Is he gay?
|
||||||
|
|
||||||
|
NORM: I honestly don't know. He's never dated anyone seriously that I know of. Like, in high school, there were girls he'd hook up with at parties, but nothing ever stuck. And since then, nothing.
|
||||||
|
|
||||||
|
LUKE: Well, if he's your best friend and he's asking you to move in and you share that level of trust, it's going to be an awkward conversation, but I think you should ask.
|
||||||
|
|
||||||
|
NORM: Yeah, yeah, I know you're right. It's just, God, what if I'm completely wrong? What if he And then I lose him entirely.
|
||||||
|
|
||||||
|
LUKE: Well, then he wasn't a very good friend to begin with.
|
||||||
|
|
||||||
|
NORM: I mean, that's fair. We've been through a lot of shit together. He was there when my dad died. I helped him through his mom's cancer scare.
|
||||||
|
|
||||||
|
LUKE: Do you want to live with him?
|
||||||
|
|
||||||
|
NORM: Yeah, I really do. Like, I've been thinking about it constantly. What our kitchen would look like. Who'd take which bedroom? Stupid stuff like making coffee together in the morning. And that food truck idea?
|
||||||
|
|
||||||
|
LUKE: Well, if you're in love with the dude, I wouldn't move in with him without having that conversation first, because that could get real, real awkward. And then you've moved in, your stuff is there, just is much messier than to deal with that right out of the gate, you know? So talk to your buddy. Ask him like, hey, you into dudes?
|
||||||
|
|
||||||
|
NORM: Oh, God, just like that? Hey, Miguel, quick question before I pack up my life. You into dudes? You're right, though. I can't do this halfway. I've been sitting here in my bathroom with the shower running hot for like an hour trying to figure out how to text him back. And I keep typing, yes, let's do it, and then deleting it because I'm terrified of what happens after.
|
||||||
|
|
||||||
|
LUKE: Yeah, I think you got to talk to him first and have the awkward conversation. And one way or another, that'll tell you how to proceed. Because what happens if you have that conversation with him and you say, hey, I have feelings for you? I think it would be weird if we were living together and you didn't share those feelings. What if he's not gay? And he does say, no, I'm not gay, but I'd still like to live with you. Could you deal with that?
|
||||||
|
|
||||||
|
NORM: No, no, I really couldn't. That's the thing that's been eating at me. If I tell him and he doesn't feel the same way, I don't think I could just go back to normal. Like, watching him date women, being his roommate while I'm still feeling this way, that sounds like actual torture.
|
||||||
|
|
||||||
|
LUKE: Okay, well, then it sounds like you can just flat out, say, No, I don't think it's the right time for that and blow that request off. Or you can sit down and have the hard conversation.
|
||||||
|
|
||||||
|
NORM: The hard conversation. Jesus. Okay. Okay. So what do I even say? Do I lead with, I think I might be in love with you? Or do I ease into it?
|
||||||
|
|
||||||
|
LUKE: Well, I don't know. You're the one that has this problem. Don't put this shit on me. I have no idea. That's not how I I would approach it. What I would, I would just ask him straight out if he was gay.
|
||||||
|
|
||||||
|
NORM: You're right. You're right. I'm overthinking it. Just, are you gay or maybe, are you into guys? God, even saying it out loud makes my stomach flip. The worst part is there have been these moments.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like you already suspect that he might be gay and kind of into you. So there, there's probably something there. And also, So a dude asking another dude to move in with him is pretty gay. So you're not completely out of line in that line of questioning.
|
||||||
|
|
||||||
|
NORM: Okay, that's fair. Yeah. Straight guys don't usually text their buddy at midnight about splitting rent and building a life together. And last Christmas, man, we were both drunk and he said something about how I was the only person who really got him. And the way he looked at me...
|
||||||
|
|
||||||
|
LUKE: Yep. So just bring it up, be direct, ask him the question. You don't have to make it weird. It It doesn't have to be, well, it's a little bit weird no matter what, but it doesn't have to be super weird. Just ask him, and if he gets all butt hurt and doesn't want to talk to you anymore, then that sucks and you lose a friend, but better to know that now than when you're sleeping in the room next to him.
|
||||||
|
|
||||||
|
NORM: You're absolutely right. Better to blow it up now from 400 miles away than after I've quit my route and signed a lease. All right, I'm going to call him tomorrow, not text, call.
|
||||||
|
|
||||||
|
LUKE: All right, give us a call back when you have more news. Let us know how it went, okay?
|
||||||
|
|
||||||
|
NORM: Yeah, I will. Thanks, Luke. Seriously. I've been sitting on this for two years, and I think I just needed someone to tell me to stop being a coward about it.
|
||||||
|
|
||||||
|
LUKE: Yep, well, that's a tough thing, an awkward conversation that I can understand nobody would want to have. So good luck to you. I hope it works out in your favor, whichever way that is. And now it's time for a number. Another word from our sponsors. Let's talk about what's going on below the belt. Saddle Soft premium undergarments are made for fabrics so advanced, the military doesn't even know about it yet. We're talking about micromodal cactus silk, harvested by hand from prickly pear, which is ironic because the whole point is that nothing pricks you. Every pair is cut for the man who sits in a plastic chair in the desert for nine hours recording a podcast. That's a real use case. That's my use case. use case. Saddlesoft comes in three fits. Regular, generous, and mind your business. Use code Roost at checkout for 15% off your first pair. And no, I will not be modeling them. I've been asked. The answer is still no. Saddle soft. Your chair is uncomfortable enough.! Okay, Megan. Megan, are you the same caller that called in a few days ago?
|
||||||
|
|
||||||
|
MEGAN: Yeah, that's me. The kindergarten teacher from down in the booth heel. I've called in a couple times now.
|
||||||
|
|
||||||
|
LUKE: Yeah, I remember you. You really flipped my opinion on kindergarten teachers.
|
||||||
|
|
||||||
|
MEGAN: Oh, God, the footskin thing. Look, I knew that was going to follow me. My friend Tammy still won't let me live it down.
|
||||||
|
|
||||||
|
LUKE: Oh, Jesus, you're the footskin chick. No, I was talking about when you were telling me how important the role of the and teacher is in teaching kids how to learn for the rest of their learning careers. I forgot about the foot skin picking thing. That is, that's gross.
|
||||||
|
|
||||||
|
MEGAN: Oh, yeah, well, I mean, both things are true. I can shape young minds and have disgusting habits.
|
||||||
|
|
||||||
|
LUKE: Yes, you can. It is a free country. So what are you calling in for tonight?
|
||||||
|
|
||||||
|
MEGAN: So my sister Crystal, the one up in Flagstaff I told you about, She called me yesterday and she's moving back to Missouri. Like next month. Just decided.
|
||||||
|
|
||||||
|
LUKE: Okay. And how do you feel about that?
|
||||||
|
|
||||||
|
MEGAN: Honestly, I'm pissed. She spent seven years up there complaining about how there's nothing to do in the boutique. How she needed the mountains, needed to be somewhere with culture, all that. And now she's coming back because, and this is the part that's killing me, she says she misses family.
|
||||||
|
|
||||||
|
LUKE: Why is that killing you. She's obviously got family in Missouri.
|
||||||
|
|
||||||
|
MEGAN: Because I've been here the whole time, Luke. I drive up to see my brother Ray every other week to make sure he's not completely falling apart. I check on mom. I'm the one who stayed. And Crystal gets to sworn off to Arizona, ignore everybody for years, and then just decide she misses us?
|
||||||
|
|
||||||
|
LUKE: Well, yeah, would you rather she doesn't decide to miss you and she stays in Arizona where she doesn't want to be and doesn't get see any of you?
|
||||||
|
|
||||||
|
MEGAN: No, I look. That's not the point. The point is she gets to just make these big sweeping decisions. I'm leaving, I'm coming back, I miss family now, and I'm supposed to what, just be happy about it, roll out the welcome wagon?
|
||||||
|
|
||||||
|
LUKE: Well, it's not that she gets to make decisions, it's that she does make decisions. You can make decisions too.
|
||||||
|
|
||||||
|
MEGAN: Okay, but no but seriously. It's not the same. doesn't have a classroom full of kids depending on her. She works remote doing graphic design. She can just pick up and go wherever. I can just leave in the middle of a school year because I feel like it.
|
||||||
|
|
||||||
|
LUKE: You absolutely can leave in the middle of a school year because you feel like it. What you do at work is your job and you can change your job at any point for any reason. So if you wanted to make those big sweeping decisions in your life, you could absolutely do that.
|
||||||
|
|
||||||
|
FLOYD: Oh, man. Okay, so this is going to sound insane, but I need you to tell me if I'm losing my mind. I'm at a rest stop outside Deming right now. And about 20 minutes ago, I was eating peanuts from the gas station, and I tasted the exit sign. I literally tasted green metal in my mouth when I looked at it. And now I can't stop thinking about whether my brain is breaking or if this has always been happening and I just never noticed.
|
||||||
|
|
||||||
|
LUKE: What does green taste like?
|
||||||
|
|
||||||
|
FLOYD: Like, okay, so it's this sharp metallic thing, almost like if you licked a chain link fence, but with this weird brightness to it. And it's specifically that highway sign green. Not like like grass green or anything. Of course. The peanuts were fine. And then I glanced up at the exit sign. And suddenly my mouth had this taste that matched the color exactly. And the fluorescent lights in the bathroom, those have a taste too. Kind of burnt coffee mixed with static electricity. I know how this sounds, Luke. I really do. But it's so specific that I can't shake it.
|
||||||
|
|
||||||
|
LUKE: Are you under the influence of any drugs at the moment?
|
||||||
|
|
||||||
|
FLOYD: No. I'm stone cold sober. I'm driving a route to El Paso. I've got a delivery at 3 a.m. I don't even drink coffee afternoon because it messes with my sleep. That's what's freaking me out. If I was high or hadn't slept in two days, I'd be like, okay, yeah, that tracks. But I'm just driving, doing my normal thing, and suddenly colors have flavors. And I'm sitting in a truck stop parking lot, wondering if I'm having a stroke or something.
|
||||||
|
|
||||||
|
LUKE: Yeah, that doesn't sound right to me. Has this ever happened to you before?
|
||||||
|
|
||||||
|
FLOYD: Never. Not once in 36 years. That's why I'm calling my girlfriend Diane. She texted me this article this afternoon about synesthesia, you know, where people's senses get crossed and they see sounds as colors or whatever. And I read it while I was waiting to load up in Tucson and thought it was interesting but didn't think anything of it. Then tonight this happens. And now I'm like, did reading about it make my brain do it? Can that even happen?
|
||||||
|
|
||||||
|
LUKE: I think that can happen. It's like a placebo effect type thing. I don't know. It sounds like it could be a placebo from what you've read and what you've been thinking about, and it could also be a severe neurological disorder. So maybe you should talk to a clinic and ask them if that's a symptom of a bigger problem or nothing to be concerned about. Because I don't know. That's a weird one.
|
||||||
|
|
||||||
|
FLOYD: Yeah, you're right. I should. The thing is is, I'm supposed to be in El Paso in like four hours, and I've been sitting here for half an hour just staring at my odometer, trying to figure out if the number seven feels smug to me. Like, does that sound crazy? I'm looking at numbers and assigning them personalities now.
|
||||||
|
|
||||||
|
LUKE: No, that one's absolutely correct. The number seven is absolutely smug.
|
||||||
|
|
||||||
|
LUKE: So you're right about that, but the tasting green street signs is a bit odd, and maybe something that you want to get checked out.
|
||||||
|
|
||||||
|
CALLER: Okay, see, that makes me feel a little better. Wait, no, it doesn't. Because now I don't know if you're messing with me or if seven actually is smug. And I've just been noticing things that were always there. God, this is making my head hurt. Should I even be driving right now?
|
||||||
|
|
||||||
|
LUKE: No, you should probably not be driving right now. You should get to a clinic and see what's up with that, because it doesn't sound like you're in a stable state, and you might be on the brink of a psychotic break.
|
||||||
|
|
||||||
|
CALLER: A psychotic break. Break? Jesus. Luke, I called for some reassurance here, not to get told I'm losing my mind. I mean, okay, you might be right.
|
||||||
|
|
||||||
|
LUKE: Yeah, I can't reassure you that what you're saying is normal behavior because it's not. I've never heard of that before. And unless you're under the influence of some strong psychedelics, I don't know that that that's a good thing. It sounds like brain issue. So I'm not saying that it's a is a brain issue. I'm saying that you should get that checked and maybe stay off the road.
|
||||||
|
|
||||||
|
CALLER: All right. All right. There's a hospital endemming. I passed the sign like 10 miles back. I can turn around. My dispatcher's going to be pissed. This delivery is time sensitive. It's refrigerated pharmaceuticals. But you're right. If I'm actually having some kind of neurological event, I shouldn't be piloting a 40-foot truck down the interstate at midnight. It's just, man. I feel fine otherwise. Like physically, I feel completely normal.
|
||||||
|
|
||||||
|
LUKE: Well, you felt abnormal enough to call a radio show about it, so that's a good sign. But you should get it checked out. That's the responsible thing to do. And you don't want to hurt anybody because you ignored a symptom of a serious issue.
|
||||||
|
|
||||||
|
CALLER: Yeah, you're right. You're absolutely right. I just, God, Diane's going to freak out when I tell her.
|
||||||
|
|
||||||
|
LUKE: Yeah, that might be one you don't tell her.
|
||||||
|
|
||||||
|
CALLER: What? You think I should just show up to a hospital by myself at midnight and not tell my wife I might be having a stroke or whatever? Come on, Luke.
|
||||||
|
|
||||||
|
LUKE: Yes, that is what I think. I think you should show up at the hospital, ask them if it's a thing. And if they say it's not a thing, carry on with your day. If they say it is a thing, then have the conversation with your wife. Because what's not going to go well is a call to your wife saying that you taste the green street sign when you eat peanuts.
|
||||||
|
|
||||||
|
CALLER: Okay, when you put it like that, yeah, that does sound insane. She already thinks I'm a hypochondriac because of that whole thing with the mole last year that turned out to be nothing. If I call her at midnight saying I'm tasting colors, she's going to think I've completely lost it. But what do I even tell the ER?
|
||||||
|
|
||||||
|
LUKE: You tell the ER that you're tasting colors and you think that's strange and you want to know if that's a symptom of a known issue.
|
||||||
|
|
||||||
|
CALLER: Right. Okay. Yeah. I can do that.
|
||||||
|
|
||||||
|
LUKE: Okay. Well, you do that, buddy, and let us know how it goes, because that is the weirdest call that we've ever had. So congratulations. Hold on. Let me find one for you. We're going to give you, hold on, hold on, hold on, hold on. All right. Well done. You have the trophy right now, and hopefully someday soon we'll get a weirder call, and then they will snatch that trophy from you. But good luck with your story. street sign mouth, and I wish you the best of luck. Everybody, this has been another great episode of Luke at the Roost, and we hope you enjoy the remainder of your evening.
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
LUKE: All right, welcome back. It's early Friday morning. This is supposed to be Thursday night, but I got distracted, so sue me. This is Luke at the Roost. It's your late night call-in radio show where you can call in and ask me about what's on your mind. The number, if you'd like to call, is 208-439. 5853. That's 208-439 Luke. gonna take some calls because that's what we do. So first up on the line here we have Mitch. Mitch welcome to the show. What are you calling in for tonight?
|
||||||
|
|
||||||
|
MITCH: Hey Luke, thanks for taking the call. So I'm sitting here in my garage at midnight watching a YouTube video about quantum physics and I think it might be ruining my marriage.
|
||||||
|
|
||||||
|
LUKE: Well you know if we're all being honest quantum physics is ruining everything. Right?
|
||||||
|
|
||||||
|
MITCH: No, that particles exist in like all possible states at once until somebody looks at them and then they just pick one. And I can't stop thinking about how that's exactly what happened with me and my wife tonight. We had this huge fight about whether to loan my brother money for a food truck. And it's like we were both existing in all these different versions of the argument until we actually had it. And now we're locked into this one where she thinks I'm an idiot and I'm hiding in the garage eating crackers.
|
||||||
|
|
||||||
|
LUKE: All right, well if you're thinking about giving your brother money for a food truck, you probably are an idiot. Why is everybody want a food truck these days? Everybody's got a brother that's trying to start a food truck. You have a different idea. Jesus. There's enough.
|
||||||
|
|
||||||
|
MITCH: Oh man, I know. But here's the thing. Darrell's actually a really good cook, like legitimately good. He did this pop-up thing at a brewery last summer and sold out in two hours.
|
||||||
|
|
||||||
|
LUKE: Was it a fusion?
|
||||||
|
|
||||||
|
MITCH: No, thank God. It's barbecue. Proper barbecue. He's been working on his brisket for like three years. But yeah, Angie says he's a burnout because he's had like four different jobs in the last two years. And I get it, but I also think, I don't know, man. Maybe he just hasn't found his thing yet. He's 32.
|
||||||
|
|
||||||
|
LUKE: Okay, well, I have a couple of questions. Has he asked you for money before for other ideas?
|
||||||
|
|
||||||
|
MITCH: Yeah, twice. There was this landscaping company thing that lasted like six months, houses, which yeah, that one didn't even get off the ground. But Luke, the barbecue thing is different.
|
||||||
|
|
||||||
|
LUKE: Okay, and it might be different. And my next question is, do you have the money to lend him?
|
||||||
|
|
||||||
|
MITCH: I mean, we have it. We've got like 15 grand saved up that was supposed to be for redoing the kitchen. Angie's been planning that for two years.
|
||||||
|
|
||||||
|
LUKE: Well, your brother wants to start a food truck. He's 32 years old, and you've already given him money for two failed businesses. Your wife wants to redo the kitchen. You've been saving money for that. So no. I mean, my advice is don't give your brother the money. That's insane.
|
||||||
|
|
||||||
|
MITCH: Yeah, but see, that's the thing. I didn't actually give him money those other times. I just told him I would, and then Angie talked me out of it both times. And now he's got this whole business plan printed out. He's got a truck lined up. He just needs 12 grand for the equipment and permits and wrapping it. And I keep thinking, like, what if this is actually the one?
|
||||||
|
|
||||||
|
LUKE: Maybe it is, but it's not your one. It's your brother's one. And first of all, are you sure that you would get your money back? That's an important bit. And also, anybody can put a business together, put a business plan together in like 36 seconds with ChatGBTBT today. And it doesn't really take a lot of effort or foresight to look for a vehicle on Facebook Marketplace. So it's not like he's done a whole lot of work here to get started. I wouldn't give him the money.
|
||||||
|
|
||||||
|
MITCH: I mean, you're probably right. It's just, God, you should see him when he talks about it. He gets this look like he actually believes in something for once. And I keep thinking about that quantum physics thing. Like maybe he's in this state where he could be successful or he could fail, but until someone actually gives him a shot, we'll never know which one he is.
|
||||||
|
|
||||||
|
LUKE: No, that's not how it works, sir. Particles are in a state of superposition until somebody measures them. that's not giving them a chance, right? So he could be in a state of failure and success until you see which one he does. That doesn't require you giving him money.
|
||||||
|
|
||||||
|
MITCH: Okay, fair. That's... Yeah, that's fair. I guess I'm trying to make physics justify me wanting to help my little brother. But here's what really gets me. Angie said something last night that I can't stop thinking about. She said, you're not trying to save Daryl. You're trying to prove something to your dad. four years, so that really messed me up.
|
||||||
|
|
||||||
|
LUKE: Did it mess you up because you know that it's true?
|
||||||
|
|
||||||
|
MITCH: Yeah, yeah, it did. Because my dad always said Daryl would never amount to anything, and I always defended him. Like every Sunday dinner, it was the same thing. Dad would go off about how Daryl was wasting his potential, and I'd be the one saying, give him time, he just needs to find his thing. And now Dad's gone, and Daryl's still, you know?
|
||||||
|
|
||||||
|
LUKE: Well, it's been 32 years. So Darrell's had ample opportunity to make something happen. And maybe now's the time. I'm not saying that he can't make a food truck happen. I'm just saying it's not your responsibility to buy that for him. And your wife clearly doesn't want you to. And it's more important that you support her than you support him.
|
||||||
|
|
||||||
|
MITCH: You're right. I know you're right. It's just going to kill me to tell him no.
|
||||||
|
|
||||||
|
LUKE: Well, he's going to have to learn some things to be successful about business and about life and about fundraising. suggestion to you would be to help him raise the money himself and be proud and have something to lose in this business that isn't yours.
|
||||||
|
|
||||||
|
MITCH: Huh, that's actually that might work. Like help him figure out how to pitch investors or do a Kickstarter or something. So he's got some skin in the game because you're right. If it's my 12 grand, what does he have to lose?
|
||||||
|
|
||||||
|
LUKE: Yeah man, that's an awful expensive rack
|
||||||
|
|
||||||
|
MITCH: Ha, yeah, exactly. Man, Angie's going to be relieved. I've been avoiding going back inside because I didn't want to keep fighting about it. Maybe I should actually go tell her you talk some sense into me before she wakes up still mad.
|
||||||
|
|
||||||
|
LUKE: All right, sir, you go do that. You have a wonderful night, and congratulations for reclaiming the marriage and keeping the wife happy. That's always a good thing to do. And don't give your brother the money. Help him out where you can, but make him do the work.
|
||||||
|
|
||||||
|
MITCH: I appreciate it, Luke. Seriously. I was sitting out here in my garage eating crackers and spiraling. And you just, yeah.
|
||||||
|
|
||||||
|
LUKE: All right, buddy, well, you enjoy your crackers and stay out of trouble, okay? Next up on the line we have Tamika. Tamika, welcome to the radio show. How are you tonight?
|
||||||
|
|
||||||
|
TAMIKA: Oh, man, I'm kind of losing my mind a little bit, Luke. I just spent the last three hours reading about the great attractor. And I can't stop thinking about it.
|
||||||
|
|
||||||
|
LUKE: Okay, then tell me about the great attractor.
|
||||||
|
|
||||||
|
TAMIKA: Okay, so it's this massive gravitational anomaly out in space that we can't even see because it's behind the Milky Way, but it's pulling our entire galaxy toward it at like 600 kilometers per. Everything. The sun, the Earth, all of it, is just being dragged toward this thing that's so big and so far away, we don't even know what it is. And I'm sitting here in the office at the Galaxy Diner at midnight trying to close out the register. And I just keep thinking, how does anybody just go about their day knowing this?
|
||||||
|
|
||||||
|
LUKE: Well, like you said, we don't know. We don't know it. We don't know what it is. We don't know what it's doing or why. And it's too far away for us to see. So everybody's going about their life because there's nothing we can do about it. And we don't know and probably will never know what it is.
|
||||||
|
|
||||||
|
TAMIKA: But that's what's getting me, though. Like, we do know it's there. Scientists know it's there. They can measure it. They can see what it's doing to us. We just can't see the thing itself.
|
||||||
|
|
||||||
|
TAMIKA: And yeah, there's nothing we can do about it. But doesn't that make it worse? Like, I'm supposed to go home, and my mom's asleep in front of the TV with her pill organizer on the counter. And I'm going to wake up tomorrow and flip the diner sign back on. And meanwhile, the entire galaxy is just... Just moving, just moving and not affecting your life in any way.
|
||||||
|
|
||||||
|
LUKE: But there are things that we're we know exist and they're called black holes and they do exactly that. So it wouldn't be surprising to learn that the great attractor is giant black hole that we can't see.
|
||||||
|
|
||||||
|
TAMIKA: Okay, but see, that's even worse because black holes, we at least understand the concept. They're a thing. They eat stuff, whatever. But this is different.
|
||||||
|
|
||||||
|
LUKE: How is it different? It could be exactly a black hole. It could just be a thing that eats stuff.
|
||||||
|
|
||||||
|
TAMIKA: But it's not eating us though. It's just pulling us. For Bill, millions of years. We've been moving toward this thing our whole lives. Everyone who's ever lived has been moving toward it. And we just found out about it in like the 1970s. And I guess what's really messing me up is, what else is like that? What else is just happening to us that we can't see and can't do anything about? And we're just supposed to act like it's fine.
|
||||||
|
|
||||||
|
LUKE: Well, it is fine. And lots of things are acting on us that we can't see every day. And we know that. And think about it. like an ear of corn on your plate, right? So you've got this nice dinner. You've got an ear of corn there. You can't eat that ear of corn until you've pulled it towards yourself. So it could very well just be a faraway black hole pulling us towards itself on its way to eat us.
|
||||||
|
|
||||||
|
TAMIKA: Oh, great. So we're dinner. That's really comforting, Luke. No, but okay. I hear what you're saying. Things are always acting on us we can't see. Gravity, radiation, whatever. But those things we learned about in school, you know? We grew up knowing about them.
|
||||||
|
|
||||||
|
LUKE: Yeah, we also grew up knowing about the food pyramid in school. And I don't know if you've heard about that lately, but it's completely different now because it's bullshit.
|
||||||
|
|
||||||
|
TAMIKA: Exactly. See, that's what I'm saying. They told us for decades to eat a certain way. And it turns out it was completely wrong. And now we're finding out our entire galaxy is being pulled towards something we can't identify. And like, what are? What else are they going to tell us in 20 years that completely changes everything we thought we knew?
|
||||||
|
|
||||||
|
LUKE: Tons of stuff, but the point is, at the end of the day, it doesn't really affect your life. It's not going to pull us anywhere in our lifetimes. We're talking about billions of years out into the future. We'll have killed ourselves off long before then. We're almost there already.
|
||||||
|
|
||||||
|
TAMIKA: Okay, but that's not making me feel better. You're basically saying, don't worry about the great attractor because we'll probably nuke ourselves. first. Like, is that supposed to help me close out the register and go home? And I don't know. Maybe it's not even really about the great attractor. Maybe it's about...
|
||||||
|
|
||||||
|
LUKE: That's exactly what I'm saying. I'm saying, don't worry about the great attractor, because it's not going to pose a threat for billions of years, and we're all going to be dead one way or another. And the entire human race is almost certainly going to be dead before then. So what is it about to you?
|
||||||
|
|
||||||
|
TAMIKA: It's that I can't stop thinking about my mom. She's asleep right now in front of the TV with her pill organizer on the counter, and tomorrow she's going to wake up and make coffee and ask me if I remembered to defrost the chicken. And meanwhile, we're hurtling through space toward this massive invisible thing, and she has no idea, and it doesn't matter, because what matters to her is whether I defrosted the chicken. And I'm supposed to just do that? Just go home and defrost chicken and pay the electric bill and show up for my shifts while knowing that none of it means anything in the grand scheme.
|
||||||
|
|
||||||
|
LUKE: That is correct. That's exactly what you're supposed to do because you're going to have to eat tomorrow and chicken is tasty. So I would recommend that you defrost the chicken and help your mom out. And don't worry about what's going to happen to the fate of the planet a billion years from now.
|
||||||
|
|
||||||
|
TAMIKA: But how do you just do that? How do you just decide that the small stuff matters when you know it doesn't? Like, I'm standing here in this restaurant office at 12. 12.30 in the morning, counting 20s, and making sure the drawer balances, and part of me knows that's important because rent is due, and my mom needs her prescriptions. But the other part of me is like, why are we pretending any of this is real?
|
||||||
|
|
||||||
|
LUKE: That is 100% the reason we're pretending. We know it's not real. Everything that we see and touch and smell, it's all a interpretation by our brains. We know that it's not reality. Like, that's not even a question. But we do it anyway. and we care about things like de-frosting the chicken because we get hungry and we need to eat.
|
||||||
|
|
||||||
|
TAMIKA: Oh, God, you're right. I sound insane. I'm having an existential crisis over Wikipedia at midnight when I should just be going home. It's just, I think I've been feeling this way for a while, and the great-att-tractor thing just gave me something to pin it on, you know? Like, my mom's getting older and I'm working the same job I had in college, and I keep thinking, when does the real part start? And maybe it never does.
|
||||||
|
|
||||||
|
LUKE: It starts when you decide it does. It starts when you decide, you know, what is important to you and your time here on this earth. And maybe it's figuring out deep secrets of the cosmos and putting your hat in the ring of academia. Who knows what that is to you? But I think the reason to be here is to enjoy the moments that you have here as much as you can and watch the universe do what it does. the opportunity to have been on this planet before it was eaten.
|
||||||
|
|
||||||
|
TAMIKA: Yeah. Yeah. Okay. I think I needed to hear that. I've been so stuck in my head about what I'm supposed to be doing that I forgot to just be here.
|
||||||
|
|
||||||
|
LUKE: Be here now. That's what our friend Ram Dass always said. And if you're looking for some meaning in your life beyond no meaning at all, maybe look up what Mr. Richard Albert had said. say because it was pretty interesting too.
|
||||||
|
|
||||||
|
TAMIKA: I will. I'm going to write that down. Ram Dass, write, be here now. That's actually perfect because I've literally been everywhere except here for the past three hours.
|
||||||
|
|
||||||
|
LUKE: Well, I think if you listen to most any sort of self-help spiritual guru, they're going to tell you in different words that same thing. It's to focus on the most moment in front of you because that's the only one that you have. It's the only one that matters. That's the point. So if you're worried about what's going to happen a billion years from now, then you're missing what's happening now.
|
||||||
|
|
||||||
|
TAMIKA: You're right. God, I'm going to go home and defrost that chicken and maybe actually sit with my mom tomorrow instead of scrolling through my phone while she watches her shows. Thank you, Luke. I really needed this tonight.
|
||||||
|
|
||||||
|
LUKE: Not a problem there. that's what I do. And good luck not getting eaten by the great attractor. I believe in you. I think you can, I think you're going to make it. And now we have to, I'm sorry, but we have to take a word from our sponsors. Ladies and gentlemen, this episode of the radio show is brought to you by mediocre CPAP. The makers of the CPAP machine that technically works, not life-changing, not transformative, It works in the way a lot of modern products work. If you've never used a CPAP, here's the pitch. You strap on a face mask and let a small plastic appliance try to keep you alive at night. It's romantic. It's like sleeping next to a tiny leaf blower. Mediocre is fine. And that's not an insult. That's honest. It's the device that turns, I sleep like garbage into... I kind of slept like garbage. Half the night it's like, pst. Pst. Because the seal isn't perfect.
|
||||||
|
|
||||||
|
LUKE: And now you're doing arts and crafts at two in the night, trying to tighten straps like you're securing cargo. Then the humidifier runs out and the machine starts blowing desert air directly into your skull. Great. Love waking up with a throat that feels like it's been sanded with a belt sander. And yes, there's an app. Of course there's an app. It gives you a sleep score like this is a game show. But here's what I'll say. When it's dialed in, when it's not leaking, squealing, or drying you out, It does help. It's not a miracle. It's a plastic compromise that keeps you from waking up, feeling like you got punched by your own biology. Use code breathe for 15% off. Mediocre CPAP. Not perfect. Not elegant. Just trying. A very special thanks to Mediocre CPAP for sponsoring this segment of the show. And next up on the radio show, we have Rodney. Rodney, what's happening? What are you calling in for?
|
||||||
|
|
||||||
|
RODNEY: Hey, Luke. So, my wife's sister told me at Thanksgiving that Marie deserves better than a guy who still works with his hands at 57. And I haven't been able to get it out of my head since. Three weeks now.
|
||||||
|
|
||||||
|
LUKE: Well, you should be grateful that you can still work with your hands at 57, sir.
|
||||||
|
|
||||||
|
RODNEY: Yeah, I mean, I am. I've been doing electrical work for 34 years. I'm good at it. But the way she said it, Luke, like I'd failed somehow.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, she's a bitch, and she's probably not working with her hands or working with anything, and she's probably going to die at 62. Well, you enjoy a long, happy, healthy life because you've remained active, and you're working with your hands.
|
||||||
|
|
||||||
|
RODNEY: Jesus, Luke. I mean, you're not wrong. She works in HR for some insurance company, spends all day at a desk. But here, Here's the thing that's eating at me. Marie didn't defend me. She just stood there. And ever since then, she's been...
|
||||||
|
|
||||||
|
LUKE: She's been what? Because an electrician is not a, um, it's a pretty respectable trade. I think most people, um, it's not a trade that's looked down on generally.
|
||||||
|
|
||||||
|
RODNEY: No, you're right. It's not. I make decent money. We're fine. But Marie's been different since then.
|
||||||
|
|
||||||
|
LUKE: Well, have you talked to her about it? Because it could be something completely different.
|
||||||
|
|
||||||
|
RODNEY: That's the thing. I haven't. I've been pulling overtime all week on this hospital job down in Lordsburg. Just staying away, I guess.
|
||||||
|
|
||||||
|
LUKE: All right. Yeah, there is that big hospital in Lordsburg. So I guess you're going to be busy for a while.
|
||||||
|
|
||||||
|
RODNEY: Lordsburg, yeah. Should wrap up in another week or two. But I come home tonight and she's already in bed at 9.30. Didn't even kiss me good night, Luke.
|
||||||
|
|
||||||
|
LUKE: Maybe she's dealing with a little bit of depression, or there's something going on in her life that she needs your support for.
|
||||||
|
|
||||||
|
RODNEY: You think that's it? I mean, her mom's been having some health issues. Nothing serious. But Marie's been driving over there a lot more. And I've been so wrapped up in my own head about this, about what her sister said about feeling like I'm not enough, that I haven't even asked her what's going on.
|
||||||
|
|
||||||
|
LUKE: Well, I would recommend you do that, and if she's spending more time going to go. going over to her mom's house and talking to her sister about you and not defending you in public, there might be something going on there, and it probably has nothing to do with your profession.
|
||||||
|
|
||||||
|
RODNEY: Oh, God. You think she's been talking to her sister about me? That whole family. They get together and it's like this echo chamber. Her sister plants one seed.
|
||||||
|
|
||||||
|
LUKE: And look, you're right. Hey, man, I'm not saying I'm right. I'm just saying that's a pattern of behavior that would make me her intentions.
|
||||||
|
|
||||||
|
RODNEY: No, I hear you. And honestly, that's what's been eating at me. It's not even what her sister said. If that Marie just stood there, didn't say a word. And now, she won't look at me the same way.
|
||||||
|
|
||||||
|
LUKE: Well, she can't look at you the same way if you're pulling overnighters and not coming home and she's tired. So I recommend you talk to your wife and find out what's going on. And if something needs to change or you need to talk to somebody or what the deal is. but don't just ignore the situation. It's obviously a thing.
|
||||||
|
|
||||||
|
RODNEY: You're right. I've been hiding at work instead of dealing with it. I'll talk to her tomorrow morning. Just straight up ask her what's going on, what she needs for me.
|
||||||
|
|
||||||
|
LUKE: Good. That's what you should do. And I hope it all works out for you. And it's nothing serious. I'm sure everything's explainable. But if not, it's not. You know, just do the best you can. And sometimes things don't work out.
|
||||||
|
|
||||||
|
RODNEY: Yeah. Yeah, I appreciate it, Luke. I needed to hear that. I've just been sitting here drinking a beer on the bathroom floor. Feeling sorry for myself when I should have been having this conversation with her weeks ago.
|
||||||
|
|
||||||
|
LUKE: Yes, sir, that is correct. So go have that conversation with your wife. Good luck. And I hope you get that hospital in Lordsburg finished because that'd be great. So next up to the show, next up, we have Marlene. Marlene, welcome to the radio show. What's eating you?
|
||||||
|
|
||||||
|
MARLENE: Oh man. Okay, so I just got slapped with a $500 fine because my front door is the wrong shade of beige. I'm not kidding. My HOA sent me a violation notice today saying my door color doesn't match the approved palette. It's beige, Luke. It's B-E-I-G.
|
||||||
|
|
||||||
|
LUKE: B-E-I-G-E-I-G-E?
|
||||||
|
|
||||||
|
MARLENE: Yeah, beige is gross, and it's clearly not on the approved palette.
|
||||||
|
|
||||||
|
LUKE: Did you look at the approved palette before you decided to paint your house? when you knew that you were in an HOA?
|
||||||
|
|
||||||
|
MARLENE: Okay, yes. Technically, I looked at it. But here's the thing. I had like 40 swatches of beige that all looked identical. My husband David was standing there going, they're all the same. The guy at the paint store was like, yeah, these are basically the same. And I picked one called Desert Sand. Because we live in the actual desert and it seemed fine. But apparently it's two shades too warm, and now Karen's somebody. on the architectural review committee is acting like I spray-painted obscenities on my garage door. I teach kindergarten, Luke. I spend all day with five-year-olds, and at least they have reasonable rules.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, you live in an HOA area. They never have reasonable rules. There's always a caring somebody that's complaining about what everybody else is doing. You know what my brother would do? I want to give you the advice that my brother would do.
|
||||||
|
|
||||||
|
MARLENE: What? What would your brother? do because right now I'm sitting here in my kitchen at midnight drinking wine and staring at this violation notice like it's a ransom letter. So I'm open to suggestions.
|
||||||
|
|
||||||
|
LUKE: He would comb through that HOA, the agreements there, and look through the approved colors. And then he would either do the worst paint job possible that was still within the rules, or he would actually just paint obscenities all over everything.
|
||||||
|
|
||||||
|
MARLENE: Oh my God, I love your brother. No, but seriously. I've actually thought about this. Like, what if I just went full malicious compliance? Paint the door the exact approved beige but then plant the ugliest legally allowed lawn ornaments I can find. Pink flamingos are probably banned. But what about garden gnomes? There's got to be a loophole somewhere in that binder. The thing is, though, Luke, it's not even really about the door anymore.
|
||||||
|
|
||||||
|
LUKE: Oh, there is a loophole. There is absolutely a loophole. And, uh, yeah, I think that's what you should do. You're going to have to pay a $500 fine. You might as well get a good laugh out of it. So what it's not about the door anymore? What's it about now? That you live in an HOA and you don't want to and you want the freedom of not living in an HOA? Then move.
|
||||||
|
|
||||||
|
MARLENE: No, it's God, you're right that I hate the HOA, but it's that David thinks I'm being dramatic. He came home, looked at the notice, and was like, just repaint it, Marlene. It's not a big deal. Not a big deal! We've lived here for 18 years. 18 years of me following every stupid rule, mowing the lawn to the exact height, keeping my trash cans hidden, and the one time I pick a beige that's slightly off, I get fined like I'm running a meth lab.
|
||||||
|
|
||||||
|
MARLENE: And he just, he doesn't get why I'm so pissed. He thinks I'm overreacting, which is what he always thinks, and I'm sitting here feeling crazy because maybe a normal person would just repaid the door and move on.
|
||||||
|
|
||||||
|
LUKE: I'm with you. I wouldn't repeat. I might eventually, but I would definitely make it more of a pain in the ass than it was worth to somebody for the inconvenience. And I would turn it into something fun by, but would you call it malicious compliance? I like that. It sounds like a punk band.
|
||||||
|
|
||||||
|
MARLENE: Right. Malicious compliance opening for the violations at the HOA Community Center. But yeah, that's exactly it. I want to make this FUN and feeling like a scolded child. I deal with actual children all day. I don't need my neighbors treating me like one. But here's what's really getting me. And I heard Tamika earlier talking about the great attractor thing. I know this is going to sound weird, but I think she was looking at it backwards. She was like, why does anything matter if we're all just cosmic dust?
|
||||||
|
|
||||||
|
LUKE: I also think the oposite. I'm with you on this one, because if we're, just cosmic dust and we are, then we are everything. And that is empowering in a way, and it's beautiful in a way, and it relieves some of the nonsense of the world that we get caught up in. And the importance that we put on those things that she kept saying were not real. So, um, so yeah, I'm totally with you.
|
||||||
|
|
||||||
|
MARLENE: Yes, exactly. Like, if we're all just atoms that used to be stars, Then why am I letting some committee member named Karen make me feel small over B-E-A-I? That's what I'm sitting here realizing. I've spent 18 years making myself smaller to fit into these stupid rules. And for what? So my neighbors don't complain? I teach 23 kids every year to be brave and use their voices and stand up for themselves. And then I come home and I'm worried about paint swatches.
|
||||||
|
|
||||||
|
LUKE: How do you spell beige?
|
||||||
|
|
||||||
|
MARLENE: B-E-E-I-I. Why, you going to look up the HOA approved shades yourself? But seriously, Luke, I think that's what's really eating at me. It's not just David thinking I'm dramatic. It's that maybe he's right that I've been dramatic about small stuff for so long that now when I'm actually upset about something real, about feeling like I don't have a voice in my own life. He just sees it as more of the same. Like I've cried Wolf too many times about stupid things, and now when I'm actually drowning, he's just like, There goes Marlene again.
|
||||||
|
|
||||||
|
LUKE: Well, yeah, that is being dramatic. You're not drowning because they asked you to repaint your door. That's a bit much. I am with you in that I would also be infuriated, but I would not agree to the terms of an HOA either. You made that decision when you bought the house, and you've been there long enough to know the rules. So next time, you'll not do that. And this time it costs you $500. And you're just going to have to deal with that and move on and if it pisses you off this much, then leave the HOA. Sell your house and find some place where you have more ability to do what you want.
|
||||||
|
|
||||||
|
MARLENE: Okay, yeah, you're right. Drowning was dramatic. See, this is what I do. But Luke, we can't just sell. David's got his workshop in the garage. All his tools set up exactly how he likes them.
|
||||||
|
|
||||||
|
LUKE: Well, let me tell you this. David would love to move and have a new garage to set up exactly the way he likes it. Um, so that's not an issue.
|
||||||
|
|
||||||
|
MARLENE: Oh, God, you're totally right. He'd love that. He complains about that garage all the time. Says it's too small, the electrical's weird.
|
||||||
|
|
||||||
|
LUKE: Well, there you go. You got a couple of options here. You can paint the door, you can move. Uh, or you can be maliciously compliant. Those are your options. Pick one of them and move on.
|
||||||
|
|
||||||
|
MARLENE: Okay, so here's what I'm thinking. And tell me if this is insane. I paint the door the exact approved beige, right? But then I plant the most aggressively colorful, technically allowed by the bylaws flower garden you've ever seen. Like, I'm talking, sunflowers, zinias. Those bright orange California puppies.
|
||||||
|
|
||||||
|
LUKE: Well, I'd go with cannabis myself, but you've got to follow the rules. So learn what those rules are and then exploit them to the best of your ability.
|
||||||
|
|
||||||
|
MARLENE: Oh my God, Luke, I teach kindergarten in the front yard. But yes, this is exactly what I'm talking about. I need to actually read those bylaws instead of just being scared of them. I bet there's all kinds of stuff they can't regulate. What about garden gnomes?
|
||||||
|
|
||||||
|
LUKE: Why do you teach kindergarten in your front yard?
|
||||||
|
|
||||||
|
MARLENE: What? No, I'm saying I can't grow cannabis because I'm a kindergarten teacher. Like, that's my job. Parents strive by my house. Can you imagine? Oh, look, there's Miss Marlene's house with the weed plants. I'd be fired, before the first harvest.
|
||||||
|
|
||||||
|
LUKE: Yeah, but you'd probably be less dramatic about it.
|
||||||
|
|
||||||
|
MARLENE: Okay, ouch. But fair. You know what?
|
||||||
|
|
||||||
|
LUKE: What?
|
||||||
|
|
||||||
|
MARLENE: You're right. I'm being dramatic about the beige. Like, it's just paint. I could fix this tomorrow for 40 bucks in a Saturday morning, but instead I'm sitting here at midnight in my underwear, drinking wine and plotting revenge gardens like I'm some kind of HOA freedom fighter. David's going to wake up and I'm going to wake up, and I'm going to be a day be out there measuring the exact legal height for a flagpole or something.
|
||||||
|
|
||||||
|
LUKE: What color are the underwear?
|
||||||
|
|
||||||
|
MARLENE: Are you serious right now? They're gray, Luke. Old gray underwear and my husband's work shirt. This is what peak rebellion looks like at 47. But okay, real talk. Why does this bother me so much? Like you're right that I'm being ridiculous. But I'm furious about this.
|
||||||
|
|
||||||
|
LUKE: Yeah, I would be furious about it too. Unreasonably furious. And I think it's because it's somebody that's trying to control your life in a manner that's unreasonable. And it is unreasonable. But those are the rules that you agreed to. And you knew that well before you moved in. So it's on you to fix it. But I understand why you're furious. It's because you're losing some agency there. It's your house. You pay for it. You pay a lot for it. You pay the taxes on it. And then they tell you what color you can paint your door. That is infuriating. So you're justified in that, but you also have to just deal with it and move on or change the situation.
|
||||||
|
|
||||||
|
MARLENE: Yeah. Yeah, you're nailing it. It's the control thing. And honestly, I think it's bigger than just the HOA. I spend all day at school following district policies. What I can teach, how I can teach it. What words I can say to a kid who's melting down.
|
||||||
|
|
||||||
|
LUKE: Yeah, and I feel for you in both respects. because when somebody tells you what to do, I don't know. I don't like that very much at all. And I will lash out at anybody that tries it. So I understand. But you got to follow the rules or pay the fine or both. Or move.
|
||||||
|
|
||||||
|
MARLENE: Which is what David keeps saying and I keep shutting him down because I don't want to admit he's right. He's been wanting to get out of here for two years. He says we should buy something with actual land. No HOA. Maybe closer to his sister in Prescott. And I'm the one who keeps saying no because I'm 10 minutes from my school. I know all the neighbors. We have the grocery store right there. But God, Luke, what if I'm just staying because I'm scared?
|
||||||
|
|
||||||
|
LUKE: Well, you just told me five minutes ago that you were staying because David had his tools in the garage the way he liked him. So what's the deal? Are you staying because you want to stay or are you staying because he wants to stay? Now I'm annoyed because you're lying to me.
|
||||||
|
|
||||||
|
MARLENE: Okay, you're right. I'm sorry. I was defamation. Reflecting with the David thing because, Jesus, this is embarrassing. I think I'm staying because if we move out to Prescott or wherever, I won't have an excuse anymore. Like right now I can say I'm busy, I'm tired. A commute would kill me. I've got my classroom set up just right.
|
||||||
|
|
||||||
|
LUKE: An excuse for what? You wouldn't have an excuse for what anymore? Why would you say you're busy or you're tired because you moved to Prescott?
|
||||||
|
|
||||||
|
MARLENE: Because my mom's in Scottsdale.
|
||||||
|
|
||||||
|
MARLENE: She's been asking me to visit more. Help her more. She's got early stage Parkinson's. And my sister Jennifer lives in California. So it's on me. And right now, I can say, mom, I'm 40 minutes away. I've got school in the morning. It's too much. But if we're in Prescott? How far is Scottsdale from Prescott? Hour and a half. Maybe two, depending on trying. So it's actually farther, which sounds like it would give me more of an excuse, but, oh God, you're going to call me out on this. If we move, David's going to say, well, now you don't have the HOA stress, you don't have the commute, you've got all this free time, why don't you go help your mom more? And he'd be right. And I don't want to.
|
||||||
|
|
||||||
|
LUKE: Okay, why don't you want to?
|
||||||
|
|
||||||
|
MARLENE: Because she wasn't there for me. When I was growing up, she was always always working, always stressed, always telling me I was too much, too loud, too messy, too needy. Jennifer was the easy one, the pretty one who didn't ask for anything. And now mom needs me. And I just, I don't want to give her what she never gave me.
|
||||||
|
|
||||||
|
LUKE: I know that's petty. It's a little bit petty, but it kind of makes sense. I see how you got there. And maybe you don't want to give her. Maybe she was an awful mom that doesn't deserve it your help for all I know, but you're probably going to beat yourself up for the rest of your life if you don't make an attempt while she's still around because Parkinson's is not a thing that goes away.
|
||||||
|
|
||||||
|
MARLENE: You're right. And that's what keeps me up at night, Luke, because I know, I know, I know, in five years, 10 years. When she's worse or she's gone, I'm going to hate myself for hiding behind a stupid HOA and beige paint. Jennifer's going to fly in for the funeral. And I'm going to be the daughter who lived in Arizona the whole time and couldn't be bothered.
|
||||||
|
|
||||||
|
LUKE: Well, how deep is she into the Parkinson's? What are her symptoms right now? And does she have anybody else around in the house that is taken care of her?
|
||||||
|
|
||||||
|
MARLENE: She's still living alone. That's part of what scares me. The tremors are mostly in her left hand. Some balance issues. She fell getting out of the shower two months ago. Nothing broken, but she didn't tell me for a week. Her neighbor mentioned it. She's got a cleaning lady twice a week and Jennifer sends money for a meal service. But no one's actually there.
|
||||||
|
|
||||||
|
LUKE: Well, even if she was a bitch and you can, you should make some attempt to help her out where possible. Obviously, that can't take over your life. A lot of people go pretty far in the other direction where they do so much helping that they neglect their own needs. And that can make people sick ruined lives. So I'm sure you can find a balance where you can be helpful and be there with your mother while she's suffering because she is suffering. And hopefully, if that happens to you, somebody will be there for you in that way. At least try.
|
||||||
|
|
||||||
|
MARLENE: Yeah, you're right. I know you're right. God, I called in about paint colors. And here we are. The thing is, I think I've been using David and the HOA and all this press stuff as a distraction, so I don't have to think about the fact that my mom is alone in that house. And she's scared, and I'm angry at a 72-year-old woman with Parkinson's for things she did 30 years ago.
|
||||||
|
|
||||||
|
LUKE: Yeah, it sounds like maybe there's an opportunity there for some redemption and forgiveness and all those types of things. And, you know, you might find that you have things in common with your mom, or she's fun to be around, or funny, or funny, or whatever it is. So I recommend you make the effort and spend some time with her no matter where you live. But also, move.
|
||||||
|
|
||||||
|
MARLENE: Okay, okay. Move and see my mom. You're not letting me off the hook on either one. You know what's funny? I've been so focused on David making me move that I never even asked myself if I actually want to stay in this neighborhood.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like you've got your answer. Thanks for calling in, Marlene. I hope your mom's all right and not suffering too badly because probably Parkinson's is no fun. It's not something that you would wish on anybody, and it's not something that's easy to watch. And, um, and yeah, you got to help people when they can't help themselves. And it sounds like she's reaching the position where she can't help herself. And she needs your help, and you are close enough. So a couple hours of your week isn't going to, isn't going to hurt you. All right, Marvin. Marvin, welcome to the show. What can we do for you?
|
||||||
|
|
||||||
|
MARVIN: Hey, Luke. Thanks for taking my call. So I just got off the phone with my dad and we sat there in silence for like 20 minutes because he won't talk anymore. He had a stroke three years ago and he just gave up. And I'm sitting here reading this book about a 70-year-old woman who completely relearned how to speak. And I'm losing my mind because I know it's possible, but he won't even try.
|
||||||
|
|
||||||
|
LUKE: Well, do you know it's possible? Is it that he won't speak or that he can't speak? Because every stroke is different. Every brain is different. So he might not actually have the capacity.
|
||||||
|
|
||||||
|
MARVIN: No, he can. The therapist said so. He got two months of speech therapy right after it happened, and he was making progress. Slow, but it was there. He could get words out. They were just scrambled, you know?
|
||||||
|
|
||||||
|
LUKE: Well, imagine how that would feel to you, to your pride and your sense of self-worth and your existence on this planet to not be able to speak as a full-grown adult. Just imagine what that would feel like for a second.
|
||||||
|
|
||||||
|
MARVIN: I mean, yeah, it's got to be humiliating. But Luke, this is the thing. I teach middle school history, right? And I've got these kids who struggle with reading. And the ones who push through it, who do the work even when it's embarrassing.
|
||||||
|
|
||||||
|
LUKE: Yeah, sure. And they're kids. They're not 75 years old. You know, how old are your dad? It's a little bit different when you're a young, elastic mind and then when you're an elderly person in your, what do they call them? After the golden years, your gray years?
|
||||||
|
|
||||||
|
MARVIN: He's 72, and that's the thing. This book I'm reading, it's all about neuroplasticity, how the brain can rewire itself at any age. This woman was 70 when she had her stroke, and she learned to talk again. So I know age isn't the issue here. He just won't do the work.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, that's his choice. 72 years old, and he has agency, too. If he doesn't want to do the way, if he doesn't want to relearn how to talk at 72 and struggle for the rest of his years, then he didn't have to. He's been through enough.
|
||||||
|
|
||||||
|
MARVIN: But he just sits there, Luke. We're on the phone, and there's nothing. And I know he's got things he wants to say because I can hear him breathing different. Like he's frustrated. He called me. So obviously he wants to connect, but then he won't put in the effort to actually do it.
|
||||||
|
|
||||||
|
LUKE: Well, maybe he wants to connect, but he doesn't want to talk. You know, maybe he's saying, I want you to come over and watch TV with me. Maybe he's saying, I'm scared, and I can't voice that. Maybe he's saying I need help, and there's no way I can tell anybody. So, can he write to you?
|
||||||
|
|
||||||
|
MARVIN: Yeah, he can write a little. His right hand is weak, but he can do it. He sent me a few notes, mostly just like I'm fine or don't worry. That's it. And I've tried going over there. I go every Sunday. We watch the news. I make him lunch. But the whole time I'm just sitting there thinking he could be working on this. He could be practicing.
|
||||||
|
|
||||||
|
LUKE: Maybe he could be. Maybe he can't be. And I don't think it's fair for you to say what he could be doing. Because you don't know. You didn't have the stroke and you don't have his brain. And it's very possible that the therapist was just talking shit because she read an article about somebody that had a stroke before that was able to learn to read. But everybody is in a different situation. And this man's 72 years old. And he doesn't want to learn to talk again. It's not an easy thing to do.
|
||||||
|
|
||||||
|
MARVIN: Okay, but no, hold on. I hear what you're saying.
|
||||||
|
|
||||||
|
LUKE: But I also heard Tamika earlier talking about the great attractor and how nothing matters. And that's bullshit. Things matter. The work matters. The work matters to you. That doesn't mean the work matters to him. Obviously it doesn't. So he's got his own set of values that may not match yours. And he's also got a very different perspective from his position than you have. So try and have some empathy for somebody that just lost everything and not push him to be something else, be something more. He already feels bad enough. So he's clearly humiliated by the situation as anybody would be because it's humiliating. And you feel vulnerable and you just. I mean, that's horrible. So, you know, if he doesn't want to talk, write. If he can't write, sit with him. Spend some time with him, and he's still your dad, and let him go out the way he wants to go out, you know? Because he doesn't want to struggle for the last five years of his life. That's okay.
|
||||||
|
|
||||||
|
MARVIN: That hit me. because I think I've been making this about me. You've obviously been making it about you, and it's not about you. You're the one that's healthy. You can speak, you can call a radio show and talk to me about how your father's an asshole because he had a stroke and he doesn't want to suffer. He's not an asshole.
|
||||||
|
|
||||||
|
LUKE: I never said that.
|
||||||
|
|
||||||
|
MARVIN: But yeah, you're right. I'm sitting here with this book like it's some kind of proof that he's giving up. When really, I'm just pissed that I can't fix it. I teach kids all day, eighth graders, and I can make them understand the Louisiana purchase or whatever, but I can't make my dad want to talk to me.
|
||||||
|
|
||||||
|
LUKE: It's not that he doesn't want to talk to you. He obviously wants to connect with you. Like you said, he's calling, he's reaching out, and he's sending you notes, and he wants to connect, but he doesn't want to talk, because maybe it hurts. Maybe it is just humiliating and embarrassing. It doesn't matter why. He doesn't want to, and he's, and he doesn't have to.
|
||||||
|
|
||||||
|
MARVIN: Yeah, you're right. God, I've been such a dick about this. I keep pushing the therapy thing every time I'm over there like, Dad, you could still improve. It's not too late. And he just looks at me like he's tired of having to disappoint me. And the thing is, I think part of me is scared that if he stops trying to talk, then we just won't have a relationship anymore.
|
||||||
|
|
||||||
|
LUKE: Well, that might be. And that's really sad. But if he doesn't want to talk, he doesn't want to talk. And you have to give him that freedom. And when he decides he does want to talk, he will do the work. If for some reason he gets sick of being mute and he wants more connection and he feels some optimism in his life, not just constantly being poked and prodded and pushed to do something he doesn't want to do, he might put forth the effort. So just support him as best you can without pushing him to be what you want him to be. He lost everything, and you didn't.
|
||||||
|
|
||||||
|
MARVIN: Yeah. Yeah, I hear you. I've been treating him like one of my students who just needs the right motivation. But he's not a kid who doesn't know better. He knows exactly what he's choosing. And I need to respect that, even if it sucks for me.
|
||||||
|
|
||||||
|
LUKE: Yes, you do. That's the right thing to do. So thanks for Colin, and I hope your dad is okay. And just be the best son you can to him and respect his wishes because he. He earned that. He's old enough to go out the way he wants to go out. And, uh, and yeah. And now it's time for a word from our sponsors. I'm going to be straight with you. I don't know what this product does. The folks at Bunkhouse DNS sent me a script, and I've read it four times, and I understand maybe 11% of it. Apparently, your DNS is exposed, which sounds medical. Your queries are being logged by default, which sounds a least. And Bunkhouse reroutes your lookups through encrypted tunnels, which sounds like something a villain would say in a movie before the building explodes. What I can tell you is this. I installed it. I pressed one button and a little shield icon turned green. Green means good. I know that much. My internet still works. Nothing exploded. And apparently my queries, whatever those are, are now private. Which feels like progress, even though I don't fully understand progress from all what? Bunkhouse DNS. I don't know what it does, but the shield is green, and that's enough for me. Okay, we're back. Welcome back to the show. Amber, thank you for calling in. What can we do for you tonight?
|
||||||
|
|
||||||
|
AMBER: Hey, Luke, thanks for taking my call. So I'm sitting here at the dispatch center on my break, and I just watched a guy total someone's car in a parking lot and drive off. And I ran his plates. And it's the youth pastor from my church.
|
||||||
|
|
||||||
|
LUKE: Oh, what a surprise. The youth pastor. On the sauce again. You think he was, you think he was sauced up?
|
||||||
|
|
||||||
|
AMBER: I mean, I don't know for sure, but it was a Walgreens at like 10 o'clock. And he backed into this sedan hard enough to cave the whole bumper in. Just sat there for a second, then pulled forward and drove off. I was trying to get my phone out to record it. And by the time I got the plate number, he was already gone.
|
||||||
|
|
||||||
|
LUKE: Well, are you sure it was him and his car wasn't stolen?
|
||||||
|
|
||||||
|
AMBER: Yeah, I'm sure. White Silverado. Plate came back to Danny Ortega. I know that truck. He picks kids up for youth group in it. He's got one of those coexist bumper stickers and a Calvary Baptist decal on the back window and I saw him behind the wheel. I'm like 90% sure it was him.
|
||||||
|
|
||||||
|
LUKE: Well, what is it you're going to do about the situation?
|
||||||
|
|
||||||
|
AMBER: That's why I'm calling. I've got the plate number written down right here on a post-it stuck to my coffee cup. And I keep staring at it. Like, do I call it in? Do I go to him directly?
|
||||||
|
|
||||||
|
LUKE: Well, think those things through. What happens if you call it in? And what happens if you don't? And you go to him directly. If you go speak to him about it, what outcome would you like to have happen?
|
||||||
|
|
||||||
|
AMBER: If I call it in, it goes to the cops. They track him down. He gets a hit and run on his record. Insurance fraud, maybe. The whole church finds out his wife, his daughter, the one he just baptized three weeks ago. I watched that.
|
||||||
|
|
||||||
|
LUKE: And what if Danny Ortega was a plumber from Plias? Would you feel the same way about calling the cops?
|
||||||
|
|
||||||
|
AMBER: No. No, I wouldn't. If it was just some random guy, I'd have already called it in.
|
||||||
|
|
||||||
|
LUKE: And if you go to him and talk to him, what do you think he will do?
|
||||||
|
|
||||||
|
AMBER: I don't know. Maybe he'd do the right thing and turn himself in. Contact the owner of the car. Or maybe he'd lie to my face. Or maybe he panics and runs. And then I'm the one who let him let him get away with it when I could have just done my job.
|
||||||
|
|
||||||
|
LUKE: Well, here's what I recommend you do. You know the guy and you don't want to be a snitch and you don't want to ruin somebody's life unnecessarily. So I would recommend that you try to talk to him and see what happens. And if he lies to you or if he is obviously inebriated or looks like he's in a no shape to be driving a youth van full of children around, then you call it in. But check with him first, see what happens. And, uh, for all you know, it wasn't him. It was his son driving the car.
|
||||||
|
|
||||||
|
AMBER: Yeah, yeah, that makes sense. Give him a chance to explain it first. I mean, I've known this guy for two years. He organizes the food drive every Thanksgiving. It just feels so out of character.
|
||||||
|
|
||||||
|
LUKE: Well, you never really know somebody, especially if they, uh, fall under the influence of the a drinking problem or drugs or something like that. And you can't be sure that it was him because you didn't clearly see his face. You said you only thought 90% and that's not proof.
|
||||||
|
|
||||||
|
AMBER: Right. I didn't see his face. Just the plate. And the track matches. White Silverado. Same dent in the tailgate I've seen in the church parking lot. But you're right. Could have been someone else driving it.
|
||||||
|
|
||||||
|
LUKE: Are you sure if you called that? that the police would throw the book at the guy, or would they recognize that he was a pastor and kind of give him a tap on the wrist?
|
||||||
|
|
||||||
|
AMBER: I mean, honestly, probably depends on who catches the call. Some of the guys I work with would absolutely go easier on him because he's a church guy. But if he was drunk, or if the damage is bad enough, or if the owner of that sedan makes a big deal about it, then it doesn't matter who he is. always that one cop who doesn't care if you're a pastor or the mayor, you know.
|
||||||
|
|
||||||
|
LUKE: Well, that's the right cop. You know, that's the cop that's following the law and keeping people accountable no matter who they are or what they do.
|
||||||
|
|
||||||
|
AMBER: So I can respect that. I can also respect the cop that lets everybody go because he recognizes it's going to ruin their lives. So I guess, yeah, it depends on which one you get. And neither one of them would be completely wrong. So that's a tough situation. But if this guy is drinking or has a drinking problem, if that's what caused the accident, he might not even remember doing it. You know, he could have been blacked out and totally oblivious to the situation. And if that's the case, that indicates that there's a problem and he shouldn't be on the road with kids.
|
||||||
|
|
||||||
|
LUKE: Yeah, that's what keeps eating at me. Like, if he doesn't even remember it, then what happens next Sunday when he's loading up the church van with a bunch of kids? That's the part that makes me feel like I can't just let it slide. It's not just about the bumper on some random car. It's about whether he's safe to be around those kids.
|
||||||
|
|
||||||
|
AMBER: Well, my recommendation is to go talk to him and give him 24 hours to turn himself in. And if he doesn't, then you do it.
|
||||||
|
|
||||||
|
LUKE: Okay. Yeah. That feels right. I'll go by the church tomorrow. Catch him before the food drive set up. Give him a chance to do the right thing himself. And if he doesn't.
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
LUKE: All right, ladies and gentlemen, welcome back to Luke at the Roost. This is the call-in radio show where you can call in and ask me your questions. If you'd like to call in, the number's 208-439-5853. That's 208-439 Luke. If you're not near a phone and you'd still like to participate in the show, you can email us at submissions.com. And with that out of the way, I just want you all to know that our street team has been hard at work spreading the word about the roost. All those roosters are out there dropping off stickers and business cards and truck stop bathrooms all across the country. So if you happen to see my face on a sticker, on an ice box in front of a loves, that's intentional. So don't remove it. First up on the show, we have caller Woody. Woody, welcome to the show. What would you like to talk about today?
|
||||||
|
|
||||||
|
WOODY: Hey, Luke, yeah. So I found a USB drive in the company truck three weeks ago, and I've been sitting on it because I'm pretty sure my foreman's been running fake invoices through our drilling operation for like two years.
|
||||||
|
|
||||||
|
LUKE: Did you look at what's on the USB key? Because it might be more interesting than that.
|
||||||
|
|
||||||
|
WOODY: Oh, I looked. I looked too much. It's not just invoices. It's spreadsheets with two sets of numbers, emails between him and some guy and me. Midland, who's definitely not a supplier, and a folder labeled backup with receipts from a casino in Ruidoso.
|
||||||
|
|
||||||
|
LUKE: Well, that's not good. What are you going to do with that information?
|
||||||
|
|
||||||
|
WOODY: That's the thing, man. I've been parked at this truck stop for three hours trying to figure that out. I could hand it to the site manager, but Big Jim's been my foreman for four years, and he got me this job when I really needed it. Or I could just toss it and pretend I never saw it, but then I'm sitting on actual fraud, and that eats at me.
|
||||||
|
|
||||||
|
LUKE: Why does that eat at you? What does it have to do with you?
|
||||||
|
|
||||||
|
WOODY: Because I'm the one who found it, you know? Like I didn't go looking for it. It was just sitting there in the glove box of truck number seven. But now I. And every time Big Jim texts me about helping with his cattle or asks me how the rig's running, I'm looking at him different. Plus my dad lost his pension when his company went under because of shit like this. So it's not just abstract to me.
|
||||||
|
|
||||||
|
LUKE: Okay, well, this is a buddy of yours, right? You said he got you to the job and he's your foreman, you work with him all the time. Maybe you should just talk to him and see what's up because it could be an explainable situation.
|
||||||
|
|
||||||
|
WOODY: I mean, yeah, I thought about that, but Luke, there's like 40 invoices from a Southwest drilling supply that doesn't exist. I googled it. And the emails, man, they're not ambiguous. There's one where he's talking about keeping the split at 6.0 and another one about making the actual equipment rentals. That's not a misunderstanding. That's a system.
|
||||||
|
|
||||||
|
LUKE: Okay, well, if holding on to this information makes you feel strange, how do you think being a whistleblower would make you feel?
|
||||||
|
|
||||||
|
WOODY: Oh, God, yeah, that's exactly it. Like, I'd be the guy who burned big Jim and everyone on the crew would know it was me because I'm the only one who drives that truck regular. I'd be done in this industry, at least around here. Word gets around fast when you're the snitch.
|
||||||
|
|
||||||
|
LUKE: if you're technically doing the right thing. Do you know what they say about snitches?
|
||||||
|
|
||||||
|
WOODY: Yeah, they get stitches. I know. And that's not even hyperbole out here. Big Jim's got friends who, look, let's just say people handle their business differently in the oil patch. But honestly, what gets me more than that is just being the guy who couldn't mind his own business.
|
||||||
|
|
||||||
|
LUKE: Yeah, it's a little bit messed up that you found a USB key and then looked to see what was on. Was there anything else on the key besides these invoices?
|
||||||
|
|
||||||
|
WOODY: Oh, there was some other stuff, like a bunch of pictures from his daughter's birthday last year, and a playlist called Big Jim's Roadhouse Mix, that's mostly George Strait and some weird country rap. But the invoices were in a folder labeled Tax Prep 2024, which is just, yeah. I mean, I wasn't snooping for this, but when you see something like that, you don't just unsee it. It's like walking in on your parents doing it. You can't pretend you didn't.
|
||||||
|
|
||||||
|
LUKE: Since you found it, you feel weird about it, and you don't want to sit on the fraud, and you don't want to say nothing. You've got to talk to Big Jim and ask them what it's about. Let them know that you know. Let them know that you found the key. You've got the evidence, and if it continues, you're going to turn them in, but give him the opportunity to turn it around. Because I think if you don't do that, you are going to be the snitch, and it's nobody's going to want to do business with you anymore.
|
||||||
|
|
||||||
|
WOODY: Oh, man, you're saying that, like, it's a choice. Like, I'm just going to walk up to Big Jim, who's got a belt buckle bigger than my fist and a temper to match and say, that's not how this works. That's how you end up in a ditch outside of Deming. But, okay, fine.
|
||||||
|
|
||||||
|
LUKE: Well, that's why you're smart about it, and you give a copy about it to a trusted person. If anything happens to you, that copy gets out, and you let Big Jim know that you've got the evidence. Somebody else has the evidence, and if he doesn't cut the shit, you're going to turn him in.
|
||||||
|
|
||||||
|
WOODY: Jesus, Luke, now we're talking about insurance policies and dead man switches like I'm in a mob movie. But I mean, yeah, that makes sense.
|
||||||
|
|
||||||
|
LUKE: Well, you're talking about conspiratorial fraud in an oil patch with a guy named Big Jim. You're already in a mob movie, buddy.
|
||||||
|
|
||||||
|
WOODY: Okay, fair point. So what? I give a copy to Angela? She already thinks I'm too paranoid. This is going to really seal the deal. Hey, babe, hold on to this USB drive and if I turn up missing, mail it to the feds. That's definitely getting me laid.
|
||||||
|
|
||||||
|
LUKE: That is definitely getting you laid. Chicks love danger, and you're a dangerous man. So do the right thing, be smart about it, and, you know, let them know that it's not going to stand and you can't be part of it.
|
||||||
|
|
||||||
|
WOODY: You know what? You're making this sound way cooler than it feels. I'm sitting here in a Flying J parking lot eating beef jerky and having an existential crisis, and you're turning me into some kind of whistleblower action hero. But all right, all right, I'll make a kind of. I'll make a I'll give it to Angela, who's either going to think this is the hottest thing I've ever done or the stupidest, and I'll have a conversation with Big Jim. A very careful conversation, probably in a public place.
|
||||||
|
|
||||||
|
LUKE: There you go. Now you're being smart, and it can be the hottest thing you've ever done and the stupidest. They're not mutually exclusive. So do the right thing. You've got a plan now. Execute it, and you're going to be just fine.
|
||||||
|
|
||||||
|
WOODY: You know what, Luke? I called in here thinking I was just going to vent and maybe get told to forget about it. And instead, you've got me planning like I'm about to take down the Gambino family. But honestly, yeah.
|
||||||
|
|
||||||
|
LUKE: Hey, you don't know. This could be the tip of the iceberg, but like you said, Big Jim is your buddy. He's your friend. He's a guy you want on your side. So you can frame this in a way that you're really doing him a favor. Like, look, this is going to get out one way or another, and I'm giving you the opportunity to keep the lid on that by straightening out your act.
|
||||||
|
|
||||||
|
WOODY: Oh, man. That's... that's actually smart. Like, hey, Jim, I stumbled across something, and I'm bringing it to you first because I respect you. But this can't continue. Make it about loyalty instead of accusation. Give him a chance to fix it before it blows up in everyone's face. The thing is, I don't even know if he's the one running this, or if he's just signing off on stuff someone above him is pushing down.
|
||||||
|
|
||||||
|
LUKE: Either way, you're right. It's going to come out eventually. It is about loyalty because he is your buddy. And if he's just, it wasn't about loyalty, you already would have turned him in. So that's the truth. Stick with it.
|
||||||
|
|
||||||
|
LUKE: Do the right thing. And maybe flee the country. Okay, so step one, make copies. Step two, give one to Angela with the sexiest, if I die, avenge me speech I can manage. Step three, talk to Big Jim like a reasonable human being. Step four, have a valid passport ready just in case. Got it.
|
||||||
|
|
||||||
|
WOODY: You know what, Luke?
|
||||||
|
|
||||||
|
LUKE: What, Woody?
|
||||||
|
|
||||||
|
WOODY: I feel like I just went from guy who's going to throw this in a dumpster and pretend he never saw it to guy with an actual plan in like five minutes. I don't know if that's good judgment or if you just talked me into something incredibly stupid. But I guess we'll find out. Worst case scenario, I'm calling you back from Mexico in a couple weeks.
|
||||||
|
|
||||||
|
LUKE: All right, Woody. Well, thanks for the call. We hope it all works out for you. And if it doesn't, we will avenge you. You have a wonderful night and stay safe out there. Tamika, Tamika, welcome to the show. What would you like to talk about tonight?
|
||||||
|
|
||||||
|
TAMIKA: Luke, hey. Okay, so, the sheriff just showed up at my neighbor Rose's house, looking for me because of some stupid warrant from last summer. And she? Covered for me. But now I'm sitting here thinking I might have just made everything worse for her.
|
||||||
|
|
||||||
|
LUKE: Well, yeah, if you're dragging somebody else into your legal problems, you're. You're obviously a burden to your friends and society. So what is it that you did?
|
||||||
|
|
||||||
|
TAMIKA: It was a poker game, man. Last August, over at this guy Daryl's place off Route 9. I won $400. And apparently, the game got rated like an hour after I left. Illegal gambling or whatever. I didn't even know that was coming. I just took my money and went home to my kids.
|
||||||
|
|
||||||
|
LUKE: And how is there a warrant for your arrest? How can they prove that you were at this illegal poker game? First of all, nobody. cares about 400 bucks in an illegal poker game. But what makes the fuzz what got them on your tail?
|
||||||
|
|
||||||
|
TAMIKA: That's what I'm saying. I don't even know how they got my name. Best I can figure is someone talked. Or maybe Darrell had a list or something. He was running these games pretty regular like every other weekend. But I got the notice in the mail back in September, said I had to appear for some misdemeanor thing. And I just, I didn't I was working double shifts. The kids had school starting. And honestly, Luke, I thought if I ignored it, maybe it would just go away.
|
||||||
|
|
||||||
|
LUKE: Well, you know damn well that you can't ignore a warrant or a deposition or anything from the court, and it's going to go away. I mean, that's stupid. But I'm not a lawyer. Let me put that out there. I'm not a lawyer at all. But I'm pretty sure somebody just saying your name doesn't put out a warrant for your arrest.
|
||||||
|
|
||||||
|
TAMIKA: No. You're right. I had to sign in when I got there. Daryl had this whole thing like a clipboard at the door. Said it was for insurance purposes, which I thought was weird. But whatever, I just wanted to play cards.
|
||||||
|
|
||||||
|
LUKE: What the hell? You walked into an illegal poker game, and the host of the game had you physically write down information that's identifiable? That's absurd. Who would do that? Why would you do that?
|
||||||
|
|
||||||
|
TAMIKA: I know. It sounds insane now, but at the time it felt, I don't know, legit. Like, it was true to make it seem professional or something. There were like 12 people there, half of them I knew from around town. It didn't feel sketchy. It just felt like guys playing cards on a Saturday night. I wrote down my name and phone number like an idiot and didn't think twice about it.
|
||||||
|
|
||||||
|
LUKE: Well, you know what? I think, skipping, skipping court, that's a crime. But having your name on a list isn't necessarily. I'm sure if you got a good enough lawyer, you could get yourself out of that one pretty easily, and you should just go to court and see what it even is. Because do you know that this is why they're looking for you? Has anybody from the police department said it's for illegal gambling?
|
||||||
|
|
||||||
|
TAMIKA: No, actually, the deputy didn't say anything to Rosa, except they were looking for me about a warrant. I'm just assuming it's the poker thing because that's the only legal. trouble I've had. But you're right. I don't actually know for sure what it's about. I never opened the court notice all the way. I just saw failure to appear and my stomach dropped. And I shoved it in a drawer.
|
||||||
|
|
||||||
|
LUKE: Okay, well, none of what you have told me thus far in this call has been very smart. So I'm questioning your intellectual capacity, but I'm going to tell you right now, you got to go to the court and see what this is about and get it cleared up, because it's not going to get better if you continue to hide. from the sheriff. It could be nothing. It could be, uh, it's, it's probably unrelated, but you know what's worse than illegal gambling? You won $400. That's tax evasion. That's way worse. They will come after you forever for evading tax on $400. So you clean this up.
|
||||||
|
|
||||||
|
TAMIKA: Oh God, I didn't even. I didn't report it on my taxes. Luke, I just bought groceries and shoes for my kid. I didn't think about the IRS. Jesus Christ, is that really worse? Because now I'm spiraling here. You think that's what this is about?
|
||||||
|
|
||||||
|
LUKE: No, I don't think that's what this is about. I think this is about something completely different. Because honestly, nobody cares about your $400 local poker game. There would have to be something else going down there, and you're not the one that they're after. If they're staking out a high-stakes-if it's a Molly's game situation, they're not after you. and they're not looking for you. They might be trying to get information out of you about someone else that was there. That I could see, but nobody cares about your 400 bucks. They might try to use that for leverage. But, yeah, and since I've identified that you're not the brightest, do not report this to the IRS.
|
||||||
|
|
||||||
|
TAMIKA: Okay, okay, so you think they want me to talk about somebody else who was there? Because there were a couple guys I didn't recognize. One of them had a real. nice watch was throwing around hundreds like it was nothing. Daryl seemed to know him, though. You think that's what this is? They're trying to flip me on someone bigger?
|
||||||
|
|
||||||
|
LUKE: I have no idea what this is because you didn't read the fucking document they sent you that told you what it is. So maybe read that. It might have some indication. I'm just trying to give you a game plan for if you do go into the sheriff's office and they have questions because that would be their intention. It's not to come after you, a mother of however many kids you're all. mother of for $400 in a home poker game.
|
||||||
|
|
||||||
|
TAMIKA: Two kids. And yeah, you're right. I need to dig that paper out of the drawer and actually read the whole thing. I just panicked when I saw Warrant and my name and I couldn't deal with it. But now Rosa's spooked. The deputy's been to her house and I'm sitting here making tea at midnight like that's going to solve anything. I'll look at it tomorrow morning. First thing.
|
||||||
|
|
||||||
|
LUKE: That's good because otherwise you're spiraling into a life of crime and you're bringing down the people that are around you with you. And it's, uh, you're a detriment to society. And nobody wants that. So, so, uh, take care of this situation. And if you've got to stop playing poker at this poker game, I would recommend doing that for now. And, uh, maybe play online or actually go to a casino. There are plenty of casinos with poker rooms where you can go play legitimate poker, uh, for stakes that you're, uh, that you're, that you're, uh, that you're comfortable with.
|
||||||
|
|
||||||
|
TAMIKA: Yeah. Yeah. No. I haven't been back since. That was six months ago, and I learned my lesson. I'm not cut out for that kind of thing. I get nervous just thinking about it now. You're right, though. I need to stop hiding and just deal with it like an adult.
|
||||||
|
|
||||||
|
LUKE: That is correct. Thank you for the call. We hope it all works out. And congratulations on your big poker win. Thanks for the call again, Tamika. Now it's time for a word from our sponsors. Today's show is brought to you by Overwhelmed VPN. Worried about online privacy? You should be. You should be worried about everything.
|
||||||
|
|
||||||
|
LUKE: I'm talking about overwhelmed VPN, the only VPN that makes you more anxious about your digital footprint. While other VPN's claim to protect you, overwhelm VPN sends you hourly notifications about who's tracking you, how many governments are watching, and which of your high school classmates Googled your name this week. Browse the internet while our proprietary fear engine tells you exactly how many corporations you owe your soul to. Plus, our servers are located. in international waters on a decommissioned oil rig, so even we don't know where your data is. Try Overwhelm VPN free for 30 days if you can handle it. Use code they watching for 20% off. Overwhelm VPN because ignorance is bliss, but you're not here for bliss. All right. Make sure you're protecting your online. identity with overwhelmed VPN. Next up on the call roster here, we have Clarence. Clarence, welcome to the show. How's your evening going?
|
||||||
|
|
||||||
|
CLARENCE: Oh, man, it's going. You know, got the space heater cranked. Doing paperwork I should have done three weeks ago. So living the dream. But hey, Luke, I got to ask you something.
|
||||||
|
|
||||||
|
LUKE: All right, hit me with some funny shit.
|
||||||
|
|
||||||
|
CLARENCE: So last Saturday, I'm at my buddy Danny's barbecue. We've known each other 20 years, right? Hunt together, help each other move, the whole deal. I go inside to grab a beer, and I hear him out by the grill, telling people that I can't even keep my own marriage running, much less an AC unit. And here's the thing. He's not wrong about the marriage part. Been divorced two years. But man, that landed different than the usual shit we give each other. I don't understand.
|
||||||
|
|
||||||
|
LUKE: that bother you, sir. I mean, okay, so normally we bust each other's balls all the time, you know? That's just what we do. But this wasn't to my face. And how well do you know your way around an AC unit?
|
||||||
|
|
||||||
|
CLARENCE: What do you mean? I've been running Clarence's heating and cooling for 16 years, Luke. I know my way around an AC unit just fine. That's not the point.
|
||||||
|
|
||||||
|
LUKE: What is the point, sir? Your buddy said some shit behind your back and you didn't like that?
|
||||||
|
|
||||||
|
CLARENCE: No, it's not just that. It's the way he said it. Like he was of it. Like he'd been waiting to say it. And here's the thing. Danny's my best friend.
|
||||||
|
|
||||||
|
LUKE: Okay. How do you know he was talking about you? I mean, there's not that many clearances around, but there could be more than one clearance.
|
||||||
|
|
||||||
|
CLARENCE: Oh, come on, Luke. We were at barbecue. literally in the next room. He knows I run the AC business in town. And when I came back out with the beer, everyone got real quiet and looked at their shoes, you know?
|
||||||
|
|
||||||
|
LUKE: Okay. Is it a possibility that you may be a cuckold?
|
||||||
|
|
||||||
|
CLARENCE: What? No, Jesus, Luke. I'm divorced. There's no cuckolding happening.
|
||||||
|
|
||||||
|
LUKE: Okay, so you can't keep a marriage together and you've been divorced for a long time. It's not a very good from your friend. So I don't understand why this is bothering you much. Talk to your buddy and figure out what that was about. Maybe he was drunk. Maybe he was tripping on acid. You don't know.
|
||||||
|
|
||||||
|
CLARENCE: I mean, yeah, the divorce was three years ago. And you're right. It's not even a creative insult. But that's what's eating at me, Luke. It wasn't creative. It was just true.
|
||||||
|
|
||||||
|
LUKE: Okay. So there you go. You're bothered by it because what he said was the truth and you knew it. So what are you going to do about that? It's not his fault.
|
||||||
|
|
||||||
|
CLARENCE: No, you're right. It's not his fault. I just, I've been sitting here all week thinking about it, and I keep coming back to the same thing. Like, why would he say that? We've been friends for 20 years.
|
||||||
|
|
||||||
|
LUKE: Why don't you ask him that? Why are you calling the radio to ask me why he would say the truth about your life?
|
||||||
|
|
||||||
|
CLARENCE: Because I'm afraid of what he'll say, all right? I'm afraid he'll tell me he's been thinking it for. years that everyone's been thinking it, that I'm the guy who can fix everyone else's AC, but can't keep his own shit from falling apart.
|
||||||
|
|
||||||
|
LUKE: And I heard Woody earlier, the guy with Big Jim and the fraud thing, and he's going to go confront his friend directly. And that's the same advice I'm going to give to you is to go confront your friend directly, because if your friend's feeling that way about you and has been for some time, maybe that friendship is not worth keeping.
|
||||||
|
|
||||||
|
CLARENCE: Yeah. Yeah, yeah, you're right. I just, sitting here in this office at midnight, you know, 20 years of friendship, we were at each other's weddings. His kid calls me Uncle Clarence.
|
||||||
|
|
||||||
|
LUKE: Well, that's a bit weird. That sounds a little creepy. I don't know what to tell you, Clarence. This doesn't sound like much of an issue to me. Go talk to your friend and figure out what that was about because people spout off shit all the time.
|
||||||
|
|
||||||
|
CLARENCE: Whoa, hold on. The kid thing isn't creepy, Luke. That's just what kids do down here. All my buddy's kids call me uncle. That's normal. But you're right about the rest. I'm making this bigger than it is.
|
||||||
|
|
||||||
|
LUKE: Well, that is not normal, sir. I'm here to tell you that Uncle Clarence sounds creepy. So go talk to your friend and stop being such a baby. Jesus, it's with these people. Next up on the line, we have Sandy. Sandy, welcome to the show. How can we help you? I hope you have a better story for us than Clarence did. Jesus. What about that guy? Uncle Clarence over there? I'm creep.
|
||||||
|
|
||||||
|
SANDY: Oh man, no. I'm not calling about my feelings getting hurt at a barbecue. I can promise you that. So here's the thing. My boss gave me until next Saturday to sign off on a bunch of fire safety reports that say we did drills and equipment checks that absolutely never happened, like not even close. home, and I'm supposed to just put my name on paperwork that says we're all good when we are very much not all good. And I know what you're going to say. Don't sign it, report it, whistleblow. But what I actually want to talk about is how this is exactly the kind of thing that's been keeping me stuck in this town for two years longer than I should have been here.
|
||||||
|
|
||||||
|
LUKE: Okay, well, I don't understand that last part, and you're right. I am going to tell you to whistleblow like that. I mean, if something were to happen, then you'd never be able to live with yourself afterwards if you did not whistleblow. But what is it about this situation that's been keeping you stuck in this town?
|
||||||
|
|
||||||
|
SANDY: No, it's not that this specific situation has been keeping me stuck. It's that this is like the perfect example of how everything works here. Like, my girlfriend left for Tucson two years ago, and I didn't go with her because I had this job and it felt responsible, you know? stable, and now I'm sitting here at one in the morning in an empty building, being asked to compromise myself just so this place can keep its license and keep operating. Which is the same logic for everything. The county's broke, the state fundings a joke, so we all just agree to fudge things a little bit here and there because otherwise the whole thing falls apart.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, there's a lot of fudging going around and a lot of businesses. I've never worked anywhere where at some point I wasn't asked to compromise myself. So that is part of capitalism, I think. But when it's a safety issue, if it really has you concerned and people could be hurt from your lack of, lack of, what's the word, I'm not looking for, integrity, then you've got to do something about that. Because if you don't, then you suck. You might as well kill the people yourself, right? you have to report that.
|
||||||
|
|
||||||
|
SANDY: Yeah, I know. I'm not signing it. I already decided that. I called my supervisor on Friday and told her I wasn't comfortable with it. And she gave me this whole speech about how we're all doing the best we can with what we have. And how if I make a big deal out of this, the state could shut us down. And then where would these residents go? Which is probably true, honestly.
|
||||||
|
|
||||||
|
LUKE: It may be true, but you know what would not happen? Those residents would not die in a fire because they didn't shut down a place that was unsafe.
|
||||||
|
|
||||||
|
SANDY: Right, exactly. And that's the thing. I'm not even wrestling with whether to sign it anymore. I'm not going to.
|
||||||
|
|
||||||
|
SANDY: What I keep thinking about is how I've been making these little compromises for two years, telling myself it's because I care about the residents, or because the way. work matters, whatever. But really?
|
||||||
|
|
||||||
|
LUKE: Yeah, I sympathize with that situation. I think we've all been in similar positions, and I can understand how you feel sort of compromised because you have compromised, but that doesn't mean you have to continue, right? You've come to this realization. You've had this moment of clarity. And this is how the world works. That's every single place I've ever worked, has asked me to compromise myself in some way. Now, they're not all safety issues, but it's always somebody saying, well, you got to do this for us, even though I know that it's wrong. And, and, you know the thing? You can compromise for them over and over and over. You can be as loyal as, I don't know, a golden retriever. And when it comes down to it, they will throw you under the bus in a second.
|
||||||
|
|
||||||
|
SANDY: Oh, I know. I saw it happen. to the last night shift supervisor. She tried to raise a stink about the staffing levels, and suddenly she was no longer a good fit. Poof, gone. And I'm sitting here in the break room. Boots propped up on a folding chair, picking at a Diet Coke label, and the only light on in the whole building is this one and the hallway nightlights.
|
||||||
|
|
||||||
|
LUKE: Yeah, and it's good to do the best you can and care because in some respects, you're obviously doing the right thing and helping a lot of people. And that's good. But the company doesn't care about you. And it doesn't matter what company it is because the company cares about the company. And if you're not an owner of that company, they do not give a shit about you. They might pretend to, but that is performative. That is HR. That is marketing. So make sure you know that when you're going into this stuff. The best work you can, but at the end of the day, you are on your own. And don't compromise.
|
||||||
|
|
||||||
|
SANDY: Yeah, and that's what my girlfriend kept saying before she left. She was like, Sandy, they're using your sense of responsibility against you. And I didn't want to hear it then, because it felt like she was saying the work didn't matter, but that's not what she meant. She meant exactly what you're saying. They'll take everything you're willing to give and then act surprised when you finally say no to something.
|
||||||
|
|
||||||
|
LUKE: And honestly? You know what? I used to work for a company called Salesforce. Maybe you've heard of them. The CEO there, his name's Mark Beniof. And when I worked there in like 2015-ish, it was the culture of the company. It was a very employee-forward company. there's all the snacks you could eat and Ohana culture. Like they were all about Hawaii and Ohana and everybody's family here. And you know what's happened at Salesforce now? They laid off 40% of the workforce and all of that performative bullshit about philanthropy and fixing hospital wings has turned into, how can I suck the dick of President Donald J. Trump so that I can get the tax rebates that he was promised? And this dude Benning, off, he's out there making jokes about ice and saying, I should come to San Francisco and clean it up. And it's the complete antithesis of what the company was founded on because all of that is bullshit. So when a company is in a position to be playing public opinion with their philanthropy and good works and employee first culture, take that with a grain of salt because when the tides turn, they turn with them.
|
||||||
|
|
||||||
|
SANDY: Jesus. Yeah, I mean, this place never even pretended to be that. We've always been broke and scrambling. But you're right, it's the same thing just at a different scale. They'll use the we're helping people angle to get you to cover shifts with no notice, to ignore safety violations, to take on extra residents when we're already understaffed.
|
||||||
|
|
||||||
|
LUKE: Yeah, when I was there, they had. this big conference every year and they get will I am and whoever is popular at the time to come in and give a speech about change in the world or some bullshit. And when I was there, that person that they paid hundreds of thousands of dollars to come speak was Hillary Clinton. Hillary Clinton came to the company and all support behind Hillary Clinton. But you know who we're in bed with today? Mr. Donald J. Trump. And that's the thing.
|
||||||
|
|
||||||
|
SANDY: At least with... those big companies. You know it's all fake from the start. Here, it's worse in a way, because everyone actually does care about the residents. Like, my co-workers really do give a shit, but the organisation itself is still using that against us. If you don't sign this, if you don't stay late, if you don't cover that shift, these vulnerable people suffer. And that's The truth is, I should have left when gendered.
|
||||||
|
|
||||||
|
LUKE: Yeah, and I sympathize with your situation, because that is true. Most of the people that you work with that are subordinates, that are employees of the company doing the actual work, they do care. They do want to do their best work. They do want to take care of the clients. But the people that make the money, they don't give shit. And as soon as they can find a way to do it cheaper, regardless of if it's better or more effective, they're going to do it cheaper and they're going to cut you. you. So don't stay up all night for the company. Or, I mean, even for the residents, I would say, once in a while, that's okay, but you can't burn yourself out or give up relationships or decide where you live for that. That's too much. And I'm glad that you've come to this realization now before they're throwing you to the curb. So yeah, find yourself a new position, work for yourself. Be your own. agent.
|
||||||
|
|
||||||
|
SANDY: Yeah. And the stupid thing is, Jen's been telling me for months there's work in Tucson. She's managing a coffee roaster now making more than I do here. And she keeps saying they need people. But I've been telling myself all these reasons why I can't leave. The residents need consistency. My co-workers are already stretched thin. I know this place inside and out. But Really?
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like it's time for you to move on. And you did it for as long as you could. You did the best you could. And when it came time to compromise your morals and your values, you stood up for yourself. And you said, I'm not doing this. And you stood up for your clients in the same way by not allowing, not allowing them to be in an unsafe situation. So you can feel proud of that and sleep good tonight because you did the right thing. And now it's time to move on.
|
||||||
|
|
||||||
|
SANDY: Yeah, you're right. I'm not signing those reports. And honestly, I think I've known since Tuesday I wasn't going to. I just needed to.
|
||||||
|
|
||||||
|
LUKE: Well, good. I wish you the best of luck, wherever you end up, and remember the lesson that you learned here, because that is the way that it is. And make sure that you are completely aware of the compromises that you're making and their effect on your well-being. and your life, because nobody else is thinking about that for you, even if they tell the world that they are. They're not.
|
||||||
|
|
||||||
|
SANDY: Yeah. Thank you, Luke. Really? I'm going to call Jen when I get off shift. And hey, maybe I'll call you back in a few months from Tucson and let you know how it went.
|
||||||
|
|
||||||
|
LUKE: All right. Thank you for the call, Sandy. And I hope that it all works out for you. Associate employee contemporaries at Salesforce aren't going to be happy about that one, but fuck them. Now it's time for a word from our sponsors. Stressed, sore, skeptical, doesn't matter. Try placebo leaf, the CBD brand that may or may not do anything but cost enough that you'll convince yourself it works. Our tinctures, gummies, and topicals contain premium hemp extract, and your desperate need to feel better without addressing root causes. Is it the cannabinoids or is it the ritual of self-care? Who's to say? Placebo Leaf is THC-free, vegan, organic, and backed by studies we vaguely reference but don't link to. Take our gummies before bed and sleep great, probably because you retired anyway, but you'll credit us.
|
||||||
|
|
||||||
|
LUKE: Available in flavors like calm and focus. Concepts we've bottled and marked up for 100%. Visit Placeboleaf.com and use code chill. for 25% off. Placebo leaf, wellness is a feeling, and feelings can be purchased. All right, I am calmed down now after our last call. That one got to me because the exploitation of people in that way really pisses me off. And I've seen it too many times at too many companies now. So let's move on. Next up. On the line we have Lakeisha. Lakeisha, welcome to the show. What would you like to talk about today?
|
||||||
|
|
||||||
|
LAKEISHA: Hey, Luke. Yeah. So I went to my high school reunion tonight and nobody remembered me. And I mean, like I was a ghost, four years at that school. And I walked in and got blank stairs.
|
||||||
|
|
||||||
|
LUKE: Okay. Well, how, what was your participation in school? Were you in any, like the band or sports? Or what, what was you? was your school like?
|
||||||
|
|
||||||
|
LAKEISHA: Oh man. I was everywhere. Volleyball team. Choir second soprano. Thank you very much. Worked the concession stand at every single football game. You name it. I was the one who always had the extra pen in geometry. The one who'd drive people home when their car broke down. I was.
|
||||||
|
|
||||||
|
LUKE: And tonight? How many concession stand workers do you remember?
|
||||||
|
|
||||||
|
LAKEISHA: Okay. Fair, but I'm not talking about, like, random people I stood next to once. There was this girl, Brittany, who copied my geometry homework every single day for an entire semester. We sat together. Every day.
|
||||||
|
|
||||||
|
LUKE: Okay, well, I will admit that this scenario sounds odd, that you could have been that involved in school at the time, and nobody remembers you now. Did they remember each other? Was this like a spike-the-punch situation where everybody was... set up for a high school reunion orgy? Like, what was the vibe of the reunion?
|
||||||
|
|
||||||
|
LAKEISHA: Oh, it was a Spike the Punch situation, though I wish it had been. At least then I'd have an excuse for why nobody remembered me. No, it was just polite. Like, everyone was being nice. Too nice. The kind of nice that makes you feel like you're being humored.
|
||||||
|
|
||||||
|
LUKE: Well, yeah, it was a high school reunion. Nobody wants to be there. Everybody's being humored. Um, that's, that's the deal. But it sounds odd nobody remembered you. Are you significantly different now? Do you look different? Uh, uh, uh, this is your voice changed? Uh, what, what were you like in high school versus what you're like today?
|
||||||
|
|
||||||
|
LAKEISHA: I mean, I've got different hair now. I cut it short about a year ago. But it's not like I had plastic surgery or something. I'm still me. Maybe a little heavier. But who isn't after four years?
|
||||||
|
|
||||||
|
LUKE: Define a little.
|
||||||
|
|
||||||
|
LAKEISHA: Okay, maybe 30 pounds. Fine. But Luke, come on. I'm not unrecognizable. And it's not just how I look.
|
||||||
|
|
||||||
|
LUKE: I'm not trying to shame you in any way. I'm just trying to understand how this could happen. And if somebody has significantly changed their physical appearance in some way, I mean, that's one way it could happen. You say it's not just how you look. How else is it?
|
||||||
|
|
||||||
|
LAKEISHA: It's... I don't know. Maybe I'm quieter now. In high school, I was always the one organizing stuff, you know, making plans, pulling people together. Now I deal blackjack at the casino five nights a week, and I barely talk except to call bets. I watch the same people lose the same money every shift, and I just...
|
||||||
|
|
||||||
|
LUKE: Okay, well, I've got a question for you now. It's a strange thing that you're describing for sure. It would make sense that at least some of those people would remember remember you from your days in high school. But why do you care if your high school reunion buddies remember you today? What does that matter? How many of them do you still interact with? I mean, if you stayed close with anybody from high school, they would obviously remember you. So you must not be close with any of them. And what does it matter if they remember you or not? High school was over a long time ago.
|
||||||
|
|
||||||
|
LAKEISHA: Because I don't know. I guess I needed proof that I was real back then. that those years actually happened and meant something. And not close with anyone from high school, you're right. But when you're standing there, and someone you ate lunch with every single day is looking at you like you're a stranger.
|
||||||
|
|
||||||
|
LUKE: Yeah, it's odd, and I see how that could feel pretty terrible for a little while. But then, I mean, take that emotion out of it for a second and think about the situation and how much that actually affects your life. It doesn't matter that those years mattered. It was high school. They didn't matter. And the ones that matter are the ones that are now.
|
||||||
|
|
||||||
|
LAKEISHA: You're right. I know you're right. But that's what scares me, Luke. What if it's happening now, too?
|
||||||
|
|
||||||
|
LUKE: Well, are you close with anybody?
|
||||||
|
|
||||||
|
LAKEISHA: My grandmother. That's pretty much it. I live with her. Actually. I'm sitting in her kitchen right now with this stupid name tag still on my sweater.
|
||||||
|
|
||||||
|
LUKE: Okay. Well, maybe this is a wake-up call to you to do more with your life. I assume you're working at the casino because it's the best job you could get with what you had at the time. What did you do after high school? Did you get a degree or what trade did you want to go into?
|
||||||
|
|
||||||
|
LAKEISHA: I did a year at community college, tried to do nursing. But my mom got sick, cancer, and I dropped out to help take care of her. She died about three years ago. After that, I just... I needed money, you know?
|
||||||
|
|
||||||
|
LUKE: Yeah, and that makes a lot of sense. You got to do what you got to do for the money. But what I'm asking is, if you did not need money, what is it that you would be doing?
|
||||||
|
|
||||||
|
LAKEISHA: I was good at organizing things, events, fundraisers, that kind of stuff. In high school, I ran the whole volleyball banquet senior year, got local businesses to donate, made it this whole thing. I liked making people feel...
|
||||||
|
|
||||||
|
LUKE: Making people feel how?
|
||||||
|
|
||||||
|
LAKEISHA: Seen, I guess. Like they mattered. That banquet. I made sure every single player got recognized for something specific, not just the starters. I spent weeks on it.
|
||||||
|
|
||||||
|
LUKE: Well, that's a good leadership trait to have, because that empowers people to be the best version of themselves, and do their best work, and make them feel good, and it makes you personable, and it makes you somebody people want to be around. So if that's your personality, if that's what you're about, I don't think you're going to have trouble in adulthood, having people remember you 10 years down the line. What is going to be a problem is that you don't have anybody that you're close with outside your grandmother right now, and she's not going to be around forever. I hate to say, you're going to have to have some other people in your life that you're starting these long lifetime relationships with.
|
||||||
|
|
||||||
|
LAKEISHA: That's what terrifies me, though, because I had that, or I thought I did. years of sitting at the same lunch table, four years of volleyball practice, sleepovers, all of it. And tonight, it was like none of it ever happened.
|
||||||
|
|
||||||
|
LUKE: I talked to one person from high school. I think most people don't talk to people from high school anymore. Especially if you've ever moved or changed locations, it makes sense to not stay tight with the people that, you know, you were tight with when you were 15. But you're not 15 anymore. And you had a... a wake-up call tonight or whenever you had your reunion. And it's time to move forward with your life and do something for you. You took care of your mother when she was sick, and that was a great thing. You spent time with your grandmother and keeping her company, and that's a great thing. But you also have to take care of yourself and your own social, not your social standing, but your social health if being around people is important to you. Now, you've got... important skills that not everybody has, especially when it comes to fundraising. So my recommendation is to get involved with maybe a town board or a committee, a chamber of commerce, and get yourself plugged into the community and start making some friends and doing stuff with them. The casino is probably not the place for that, but there are lots of places around where you can find your fitting and provide a lot to... to those places.
|
||||||
|
|
||||||
|
AKEESHAH: You're right. I know you're right. It's just... I've been in this holding pattern for so long, waiting for life to start happening again after my mom died. And tonight made me realize I've been invisible this whole time, not just at the reunion, at work, in town, everywhere.
|
||||||
|
|
||||||
|
LUKE: Well, if it's important to you to be seen, then go be seen. And there's lots of ways to do that, and you have the skills. So make it happen. If it's not important to be seen, you don't have to. It doesn't matter either way. It's up to you. What's important to you in your life? And it sounds like right now you're feeling like it's important to be seen. So go get yourself seen.
|
||||||
|
|
||||||
|
AKEESHAH: I don't even know where to start. Like, do I just walk into the Chamber of Commerce and say, hey, I used to organize high school banquets. Hire me? That sounds ridiculous.
|
||||||
|
|
||||||
|
LUKE: Well, you don't know. You don't have to work for them. But, yeah, if you walk into pretty much any place in the country and say I'm good at raising funds, then, yeah, they're going to want to work with you.
|
||||||
|
|
||||||
|
AKEESHAH: Huh. I never thought about it like that. I've just been thinking of it as this high school thing I used to do. Not like an actual skill people need.
|
||||||
|
|
||||||
|
LUKE: Well, it is a very important skill that a lot of people need. You could join a political party and do it for a candidate that you agree with their platform. for the betterment of society, for social reasons. You could fundraise for yourself. Everybody needs a fundraiser, and if you enjoy that work and you're good at it, you're not going to struggle for work.
|
||||||
|
|
||||||
|
AKEESHAH: You know what's funny? I actually liked it. The organizing part. Getting people to show up. Making them feel like they mattered. At the casino, I'm just...
|
||||||
|
|
||||||
|
LUKE: Well, at the casino, you're raising funds for the... the casino, which is fine. I mean, I like to go to the casino, and there are dealers there at all the games that make it fun and inviting, and they're very pleasant to be around and memorable. So even if you stayed at the casino, you can still utilize those skills and make yourself seen. But I would recommend, since you've described what you enjoy doing, that you go do that.
|
||||||
|
|
||||||
|
AKEESHAH: Yeah, yeah. I could actually do that. There's a literacy thing my grandmother mentioned. They're trying to get funding for after-school tutoring. I could call them Monday.
|
||||||
|
|
||||||
|
LUKE: Well, there you go. Call them Monday and get yourself started, and when you plug yourself into the community like that, you're going to make friends, and they're going to remember you, especially if you've raised them half a million dollars. You're not going to be forgotten. But don't worry about high school. Everybody forgets high school. It's a forgettable time.
|
||||||
|
|
||||||
|
AKEESHAH: You're right. God, I've been holding on to that so tight, like those four years were supposed to define everything, and instead they just evaporated. But maybe that's okay.
|
||||||
|
|
||||||
|
LUKE: That's okay, and I think most people are glad that those years evaporated, because they're not the ones that define us. They're the ones that kind of shape how our adulthood begins, but they're plastic.
|
||||||
|
|
||||||
|
AKEESHAH: Yeah, that's a good way to put it. Like, it's not set in stone. I can still...
|
||||||
|
|
||||||
|
LUKE: You can still do whatever it is you want to do. So I recommend that you go do that. And we're running out of time, so I've got to hang up with you, but I appreciate the call. And I hope you feel better and move forward with your life. Don't worry about being recognized by people you don't even talk to anymore.
|
||||||
|
|
||||||
|
AKEESHAH: Thank you, Luke. I really... Yeah, I'm going to make that call Monday.
|
||||||
|
|
||||||
|
LUKE: All right. Well, thank you for the call, Akeeshah. I hope that it works out. And you find yourself in a new life that brings you great abundance and joy. And that's the end of our show.
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
LUKE: All right, welcome back. I'm your host Luke. This is Luke at the Roost, the call-in radio show where you can call in and tell me about what's going on in your life. I'll give you the best advice I can. If you'd like to call in, the number is 208-439-58-3. That's 208-439 Luke. If you're not near a phone or you can't call right now, we can take emails. You can email us at submissions at Luke at the Roast. We'll take your email and if I think it's funny enough, we might read it on the show. So, with that being said, it's Sunday, February 22nd and we're going to get to our callers here. The phone is lighting up and today we've got Dexter. Dexter, thanks for calling in. What's going on in your life tonight?
|
||||||
|
|
||||||
|
DEXTER: Hey, Luke. Yeah. So I've been reading this Marcus Aurelius book. Right. The Stoic philosophy stuff. And I actually think it's making me worse. Like, I'm getting good at not feeling anything and my mom called me out on it tonight and now I'm sitting here wondering if I've just traded one kind of numbness for another.
|
||||||
|
|
||||||
|
LUKE: Are you talking about meditations? I think maybe you're missing the point. Marcus Aurelius never said not to have any emotions. He said not to let them control you. There's a difference.
|
||||||
|
|
||||||
|
DEXTER: Yeah, meditations. That's the one. And okay, maybe you're right. But here's the thing. This afternoon, my boss just tore into this kid for 20 minutes straight. I mean really laying into him over something that wasn't even that big of a deal, and I just stood there watching it happen. Didn't feel angry, didn't feel bad for the kid, didn't feel anything. I was just breathing through it like the book says, staying calm, and afterward I felt nothing, which I thought was the whole point, but then my mom calls, and she can tell something's off just from my voice.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, it doesn't sound like you felt nothing, made an impression on you. You're still thinking about it now. So it obviously made you feel something that maybe you're trying to repress or push away or not acknowledge. But it did something to you to keep it on your mind for this long.
|
||||||
|
|
||||||
|
DEXTER: Shit. Yeah, okay, you're right. I'm still thinking about it because it pissed me off. Not even at my boss, really. I mean, he's always been like that, but at myself for just standing there.
|
||||||
|
|
||||||
|
LUKE: Well, yep, that is a little bit, if you think it was wrong and you didn't stand up for yourself and for the kid, then maybe that's a little bit cowardly. And are you going to do the same thing next time? Or are you going to, I mean, Marcus Aurelius would tell you to learn from your mistakes and not make the same mistake twice.
|
||||||
|
|
||||||
|
DEXTER: You're not wrong. It was cowardly. The kid's name is Jamie. He's 19, been here maybe three months, and he left a gate open that let some cattle drift.
|
||||||
|
|
||||||
|
LUKE: Well, you know, you might not know the whole story. You might have done that three or four times and been told nicely not to do it and continue to do it anyway. So maybe that kid Jamie deserves the strong wording.
|
||||||
|
|
||||||
|
DEXTER: No, that's fair. I don't know the whole history there. But it wasn't even really about the gate. My boss was going off about how Janie's generation doesn't respect anything. Doesn't know how to work. All this other stuff that had nothing to do with cattle. It turned into this whole thing about kids these days and their phones and whatever. that's the part that got under my skin. Watching this kid just take it with his head down while my boss used him as a punching bag for everything else he's mad about in the world.
|
||||||
|
|
||||||
|
LUKE: Well, what's any of that got to do with you?
|
||||||
|
|
||||||
|
DEXTER: Because I've been that kid. Eight years ago when I started here, I was Jamie, screwing up, getting yelled at. Nobody's stepping in. And I told myself back then that if I ever got to where I am now, where the younger guys look up to me, stand there and watch it happen to someone else. But that's exactly what I did today. I broke a promise to myself, and I'm trying to use this philosophy book to convince myself it was the smart move to stay quiet.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like you know whether it was the smart move or not, but just to play devil's advocate here, Jamie is 19 years old, he's an adult, and he's in the real world and a real job, and he has to learn to stand up for himself. I think we all do. So I mean, we've all had that boss that is overbearing and unreasonable, and flat out mean. And at some point, part of growth is learning how to stand up to that and to say, hey, I'm not going to let you speak to me that way. If you've got a problem with the gate, go close the fucking gate yourself.
|
||||||
|
|
||||||
|
DEXTER: You're absolutely right. And that's exactly what I've been telling myself all night. He's got to learn to handle it himself. It's not my job to fight his battles. But here's what I keep coming back to. When I was 19 and getting treated like that, I didn't have anybody show me it was okay to push back. I just learned to take it and shut down. Which is why eight years later, I'm real good at feeling nothing when I should feel something. So, yeah, maybe Jamie needs to learn to stand up for himself. But how's he supposed to learn that when everyone around him is mommeling the opposite?
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like you're lying to yourself. You're not feeling nothing. You're feeling cowardly. You're feeling shame in yourself for not standing up when you saw an injustice in the world. And the stoic way to handle that would be to acknowledge it and make sure that you don't do that again in the future without dwelling on it or feeling remorse or guilt unnecessarily for a long time and wasting time that you could be being productive. So if I were you, I would take both of them aside the next time you had the opportunity and explain to the kid that he has to stand up for himself and explain to the boss that what you saw was unaccepted. That is leadership and that is the Stoic philosophy. So keep reading the book and do your best and don't beat yourself up, but move forward in a productive way.
|
||||||
|
|
||||||
|
DEXTER: Yeah, you're right. I am feeling it. I've been sitting here stewing for hours pretending I'm above it. And you just laid out exactly what I need to do, which is the scary part, because now I actually have to do it. Talk to both of them.
|
||||||
|
|
||||||
|
LUKE: That's it. Just talk to both of them. And the more you do that, the more confidence you'll have in doing that and the less scary it'll become in the future. Because this isn't something that is going to be the end of it, right? You're going to have to deal with this all throughout your life. You're going to have to stand up for yourself and for others and show leadership when there is injustice in the world. If that's who you want to be and it sounds like it is. So don't worry about it. Get some sleep. And tomorrow have those conversations and be confident and say your peace and accept the result and do what you can. But you can't change, what you can't change, you can make an attempt. And that's all you can do.
|
||||||
|
|
||||||
|
DEXTER: You know what, Luke? I needed to hear that. I really did. I've been sitting here with this book trying to find permission to do nothing. And you just called me out on it.
|
||||||
|
|
||||||
|
LUKE: Well, you're obviously not reading the book then, because it says exactly what I'm telling you. It's not a rare book. A lot of people have read meditations. And, uh, the Stoic philosophy is pretty well understood. So if what you're getting out of that book is to stay quiet and feel nothing, then we're reading different books.
|
||||||
|
|
||||||
|
DEXTER: No, you're right. I'm cherry-picking the parts that let me off the hook. The whole, except what you can't control thing. I've been using it backwards. Using it as an excuse not to try instead of his piece after I've actually done something. That's not Stoicism. That's just being a coward with a fancy excuse. Appreciate you straightening me out, Luke.
|
||||||
|
|
||||||
|
LUKE: No problem. That's what we do here at Luke and the Roost is we straighten people out. Next up on the show, we have Candy. Candy, welcome to the show.
|
||||||
|
|
||||||
|
CARL: Do you remember that show Strangers with Candy? It was this lady that was all drunk and crazy on Comedy Central. Oh, man. Yeah. Amy Sedaris, right? My sister used to watch that show all the time. I never really got into it, though. I was more of a Reno 911 person back then. But hey, Luke. I got to tell you why I'm actually calling. Because I'm sitting in my bathroom with the shower running so my kid doesn't hear me, and I'm kind of losing it here. My 14-year-old just told me she wants to move to Tucson to live with her dad and his new wife. And I think the worst part is she had this whole speech prepared like she was breaking up with me.
|
||||||
|
|
||||||
|
LUKE: You know what I always say? 911 was an inside job. So about the 14-year-old, I think that's very normal. I did that myself. I think a lot of. lot of children of divorced parents think the grass is going to be greener on the other side or just want to try to live a different lifestyle, a different life. It's a pretty normal thing. So that might hurt, but she'll probably be back when she realizes dad's house isn't as fun as she thought it was going to be.
|
||||||
|
|
||||||
|
CARL: Yeah, I mean, you're probably right. But here's the thing. I've been running my mom's bakery since I was 21, getting up at 3 in the morning single day and this girl has never wanted for anything her dad moved to Tucson three years ago with this woman who works from home doing something with marketing or whatever and now suddenly that looks better than what I'm doing like I get it the new wife doesn't smell like yeast and actually has time to do her nails but I've been busting my ass here and the way she said it Luke she wouldn't even look at me
|
||||||
|
|
||||||
|
LUKE: yeah but it's not really about you and it doesn't matter what sort of life you gave her She would still be curious and think that it was better somewhere else. That's just human nature. So don't take it personally. It's not personal. Unless you guys have some other darkness in the relationship I'm not aware of, my guess is just that it looks like more fun over there, and she wants to go see what that's like.
|
||||||
|
|
||||||
|
CARL: You're right. You're right. I mean, there's no darkness or anything like that. We get along fine, or at least I thought we did.
|
||||||
|
|
||||||
|
LUKE: Well, Take advantage of the situation. Let her do it. Let her dad take care of the kid and then that frees you up to do some stuff that maybe you wouldn't otherwise be able to do. Enjoy it while it lasts and be there for her if she wants to come back.
|
||||||
|
|
||||||
|
CARL: Okay, but see, that's where my head goes to a dark place. Because part of me is actually relieved, and that makes me feel like the worst mother in the world. Like, I could sleep past three in the morning. I could maybe go on an actual date without worrying about getting home. I've been This alone for so long that when she said it, there was this split second where I thought, oh, thank God, before the hurt even kicked in.
|
||||||
|
|
||||||
|
LUKE: Yeah, and there's nothing wrong with that. I mean, you're a person too, and it would be nice for you to be able to live some of your youthful years on your own. So if that's what everybody wants, if everybody's in agreement, the kid wants to go there, the dad is okay with it, and you're okay with it. It sounds like a good thing and a change for everybody. So he gets to spend more time with his daughter. You get to spend more time with yourself, and she gets to see what it's like living in other conditions. Everybody wins.
|
||||||
|
|
||||||
|
CARL: You know what? When you say it like that, it doesn't sound so pathetic. I think I've just been so stuck in this loop of work and being mom that I forgot I'm actually only 23. What? My sister Brenda keeps telling me I need to live a little, but we're not even talking right now because of some stupid fight about passwords and privacy stuff. God, I sound like a mess, don't I? But okay, yeah, maybe this is actually a good thing.
|
||||||
|
|
||||||
|
LUKE: So you're 23 years old and you have a 14-year-old daughter?
|
||||||
|
|
||||||
|
CARL: Yeah, I had her when I was nine. No, I'm sorry. God, I'm all over the place tonight. I'm 37, not 23. The bakery, I've been running it for two years since my mom had her stroke. And I'm 37.
|
||||||
|
|
||||||
|
LUKE: Okay, well, you're 37, and now you have the opportunity to be single at 37 some of your life while you're still young. I think it's great. Let her do it. Be supportive. Maybe see her on the weekends.
|
||||||
|
|
||||||
|
CARL: Very good. You're right. Yeah, you're totally right. I'm going to tell her tomorrow that if this is what she wants, we'll figure it out. Maybe I'll even get one of those dating apps everyone's always talking about. Though honestly, the thought of that terrifies me more than telling my daughter she can go.
|
||||||
|
|
||||||
|
LUKE: Tell her she can go. And, yeah, I mean, I think those dating apps are probably less fun and you're imagining that they are, but give it a shot. Especially if you haven't yet, then yeah, that's exciting. And nobody's getting hurt here. So great. It's a win for everybody. Do it and enjoy your life.
|
||||||
|
|
||||||
|
CARL: Okay, yeah. Thank you, Luke. I really needed to hear that. I've been sitting in here with the shower running so she wouldn't hear me lose it. And now I feel like maybe I'm not completely screwing this up. I'm going to go talk to her tomorrow and actually be supportive instead of making it about me.
|
||||||
|
|
||||||
|
LUKE: That sounds like a good plan, Candy. You're a good mom, and you deserve some happiness in your life. So go get it. And when dad's house sucks and she wants to come home, be ready for that. That's all. And now, ladies and gentlemen, we have to take a word from our sponsor, Squarehole. I'm going to get dark for a second. You're going to die someday. Not today, probably. But someday, and when you do, what's going to represent your business? A Craigslist post from 2020? A Google Maps pin that still says permanently closed because you forgot to update it after COVID. A nephew who knows computers but actually just has a Reddit account? Your legacy deserves better than a nephew with a Reddit account. Squarehole lets you build something that outlasts you. Something your kids can point to and say, Dad built that. They won't know it took you a lot. minutes, that's between you and Squarehole. And when your nephew says, I could have built that for you for free, you can look them in the eye and say, then why didn't you, Tyler? I asked you in 2021 and you said you were busy and you were not busy. Squarehole. Build it before Tyler has to, because Tyler is not going to. Okay, thanks to Squarehole for sponsoring this episode. Next up on the line we have Carl. Carl, welcome to the show. What's going on out there for you tonight?
|
||||||
|
|
||||||
|
CARL: Luke, hey, so I just yelled at my daughter about her car registration, and I swear to God it was my dad's voice coming out of my mouth. Like word for word what he used to say to me. You think I'm made of money? You think this shit just appears. I heard myself say it, and I just froze.
|
||||||
|
|
||||||
|
LUKE: Okay, what happened? What's the situation with the registration, and why did it cost you money?
|
||||||
|
|
||||||
|
CARL: So she's got this beat up. up Civic, right? Registrations like 200 bucks, and she asked if I could help cover it because she's been picking up extra shifts at Target, but they cut her hours, which is totally reasonable. But I just got hit with this storage facility repair bill. Some kid broke into a unit last week, busted the lock mechanism on the whole row.
|
||||||
|
|
||||||
|
LUKE: Okay, don't you have insurance on your business?
|
||||||
|
|
||||||
|
CARL: Yeah, but there's a deductible, and Ray, he's the guy I work with on weekends. He's telling me we should upgrade the whole security system while we're at it because, This is the third break-in in six months. So I'm looking at maybe 1,500 out of pocket if I do it right. But that's not even the point. The point is, I had the $200.
|
||||||
|
|
||||||
|
LUKE: Yeah, and you reacted in a negative way to your kid when she asked for the money because you're under stress, and that's a normal human thing. People say things they don't mean and react too strongly. So. So what are you going to do? Apologize and move on?
|
||||||
|
|
||||||
|
CARL: I mean, yeah. I called her back like 20 minutes later and apologized. She was cool about it.
|
||||||
|
|
||||||
|
CARL: She's a good kid. But Luke, that's not what's messing me up.
|
||||||
|
|
||||||
|
LUKE: What is messing you up?
|
||||||
|
|
||||||
|
CARL: That you sounded like your dad? Everybody sounds like their dad. No, I know that. I get that. But it's like, okay, my old man died four years ago and we barely spoke the last decade of his life because of exactly this kind of thing. He'd blow up over money, over nothing, make you feel like asking for help was this huge burden.
|
||||||
|
|
||||||
|
LUKE: Well, sometimes it can be a huge burden, but it sounds like in this case it wasn't a huge burden to you. You made a mistake, you apologized for it, and what are you going to do in the future to make sure that doesn't happen again? Like, if you don't want to be your dad or act like your dad, then you have to take steps to avoid that. And what are those steps going to be?
|
||||||
|
|
||||||
|
CARL: That's the thing. I've been taking steps for years. I go to this therapist every other week. I read all this stuff about breaking soul cycles. I've been so careful about not doing what he did. And then one bad moment, and it all just comes right back out like it was sitting there waiting.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, it happens. Everybody makes a mistake once in a while and says things that they don't mean and later regret. I do it all the time, and I always have to constantly try and stop myself when I can feel the hairs standing up on the back of my neck, and I'm about to snap at somebody. And sometimes I do better than others, that's the human experience.
|
||||||
|
|
||||||
|
CARL: You're right. I know you're right. It's just, I heard Dexter earlier, you know, you know. And I felt like he was doing the same thing I do, using all this self-improvement stuff as like, I don't know, a shield or something.
|
||||||
|
|
||||||
|
LUKE: Yeah, but all this self-improvement stuff, none of it expects you to obtain this level of perfection. Right? Every single self-help book or philosophy centers around continuous improvement, an acknowledgement of when you're wrong. And it sounds like you did those things. You acknowledged that you were wrong. You called, you made a mistake. She forgave you. She knows that you weren't trying to hurt her and that you were under stress at work. So this doesn't sound like it's a thing. Just do your best to make sure this doesn't continue to happen and try to be a good person. That's all you can do.
|
||||||
|
|
||||||
|
CARL: Yeah. Yeah, you're right. I guess I'm making this bigger than it needs to be. I just got scared for a second that all the work I've been doing doesn't actually matter, you know? Like it's all still in there no matter what I do.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, it is always going to be in there no matter what you do. It's not like it's ever gone. I mean, you might be able to go sit with some monks on a mountain in Tibet and meditate it away, but I suspect that it's still in there somewhere with the potential to come out. So you just keep doing what you're doing. Keep working with your therapist. Keep acknowledging when you're wrong and do the best you can to correct those instances when they arise.
|
||||||
|
|
||||||
|
CARL: Fair. I appreciate it, Luke. I think I just needed to hear somebody say it's not the end of the world, you know. I was sitting here in this office just spiraling about it.
|
||||||
|
|
||||||
|
LUKE: It is not the end of the world, Carl. You're doing decent. You're doing pretty good. So keep doing what you're doing and things will get better. Next up we have Monique. Monique, welcome to the show. What's going on with you tonight?
|
||||||
|
|
||||||
|
MONIQUE: Hey, Luke, thanks for taking my call. So I said yes to my boyfriend's proposal three days ago in front of both our families. And I'm pretty sure I don't actually want to marry him.
|
||||||
|
|
||||||
|
LUKE: Okay, well, luckily for you, you haven't signed any papers yet, so you don't have to marry him.
|
||||||
|
|
||||||
|
MONIQUE: Right, no. I know that. It's just he did it at my aunt's house during Thanksgiving dessert with like 20 people watching and everyone's already texting me about venues and my mom's crying happy tears every time she sees me and I keep looking at this ring on my finger at work, thinking, when did I stop wanting this? Was it before he asked or the second he got down on one knee in front of everybody?
|
||||||
|
|
||||||
|
LUKE: Well, it could just be jitters. Maybe you did want that, and now that it's happened, it feels real to you. Or maybe you really don't want to get married. But the advice I'm going to give you is you can't make that decision based on what everybody else is feeling, because you're the one that is getting married. It's your life. And a little bit of embarrassment over changing your mind is much safer and less harmful than a lifetime marriage that you don't want to be in.
|
||||||
|
|
||||||
|
MONIQUE: Yeah. No, you're right. I know you're right. It's just he's a good guy, you know? Like, objectively, he's nice, and he has a job and he doesn't yell at me or anything. My mom keeps saying, I'd be crazy to let him go.
|
||||||
|
|
||||||
|
LUKE: Well, what your mom says really doesn't matter, and that's a pretty low bar. He's nice, and he has a job, and he doesn't yell at you. I don't know that that's enough qualification for a lifetime commitment to one person. So I think if you're having second thoughts, there's probably a good reason for that. You're not ready for marriage. And maybe you should just tell him, hey, I reacted too quickly. This isn't something I want right now. Maybe we can revisit it in the future.
|
||||||
|
|
||||||
|
MONIQUE: That's the thing, though. I don't think I want to revisit it in the future. Like we've been together four years. And I keep waiting to feel that thing everyone talks about. That likes certainty. And it's just not there. I watch him play video games every night. And I think, is this it? Is this what I want for the next 50 years and then I feel like a bitch because he's perfectly fine. There's nothing wrong with him.
|
||||||
|
|
||||||
|
LUKE: You don't have to feel like a bitch and you don't have to want to get married and you don't have to do what your mom says and you don't have to do what he wants. All you have to do is make the best decisions you can for your own life and what you want. And right now it sounds like that's not marriage to this guy. And maybe you don't want to be with him at all and that's okay too. But it sounds like maybe here and make sure that he knows that because that's the respectful thing to do. If you said yes and he's all excited and he thinks he's getting married but you're not into it, you need to let him know. And that's going to be a hard and awkward conversation, but it's the right thing to do and you should do that sooner rather than later.
|
||||||
|
|
||||||
|
MONIQUE: You're right. God. You're right. I heard Candy earlier talking about feeling relieved about her daughter leaving, and I felt so called out because that's exactly how I feel. Like, everyone's congratulating me and I just keep thinking about how I could just keep working my shifts and go home to my own apartment and not have to pretend I care about his fantasy football league.
|
||||||
|
|
||||||
|
LUKE: Yep. If that's what you're happy with in your life right now, then that's fine. And it's acceptable and you don't have to feel guilty or wrong about that. So tell the guy you don't want to get married and reassess whether you want to be in a relationship at all. You don't have to be. And then just move forward after that.
|
||||||
|
|
||||||
|
MONIQUE: Okay. Okay. I'm going to do it. I'm going to tell him tomorrow after my shift. I've got like 20 minutes left on my break, and then I got to go back and serve hash browns. But yeah, tomorrow.
|
||||||
|
|
||||||
|
LUKE: Okay, good luck with that conversation. I hope it all works out for you. But yeah, definitely don't get married if you're not even sure you want to continue being his girlfriend. That would be insincere and put you in a bad position later in life. Next up, we've got Cliff. Cliff, welcome to the show. What would you like to talk about tonight, sir?
|
||||||
|
|
||||||
|
CLIFF: Hey, Luke. Yeah. Thanks for taking my call. So I found $47,000 in credit card debt hidden behind my ex-wife's dresser this afternoon, all in both our names, and she's been gone two years.
|
||||||
|
|
||||||
|
LUKE: That sounds odd to me.
|
||||||
|
|
||||||
|
LUKE: There's 47 large in debt, and you don't know what it's for, and you didn't know anything about it, and in two years the collections agency never came looking for you. That doesn't sound right.
|
||||||
|
|
||||||
|
CALLER: No, you're right. That does sound weird. So what happened is the statements are rolled from like 22, right before she left. I've been getting collection calls, but I thought it was just the normal stuff we had together when we split. Like maybe eight grand I've been chipping away at. I didn't know about this debt because she was handling all the bills back then. I work outside all day. She worked from home doing medical billing, so she just took care of that stuff.
|
||||||
|
|
||||||
|
LUKE: Well, if it's $47,000 today, it was probably half that two years ago, and this is compounding fees for nonpayment. I would venture to guess that you can have a lot of that forgiven if you work with a debt consolidation company, but it looks like it's, if it's valid debt, I mean, that's money that you spent some years ago with your wife. That's how it works. You're going to be on the hook for a portion of that at least.
|
||||||
|
|
||||||
|
CALLER: Yeah. No, I get that. The thing is, though, I didn't spend it. Like, I'm looking at these statements right now and it's stuff I never saw.
|
||||||
|
|
||||||
|
LUKE: Well, if you were married to the woman and your name is on the account and there were charges made on that account, then, yeah, you did spend it.
|
||||||
|
|
||||||
|
CALLER: Okay, but Luke, I'm saying I literally didn't know these cards existed. Like there's a capital one here. A discover. Two different chase cards I knew about our one joint card were used for groceries and gas. These other ones, she must have opened them and just put my name on them without telling me. I never saw the bills. I never used the cards. I didn't even know they were being opened.
|
||||||
|
|
||||||
|
LUKE: Well, I don't know. I'm not a lawyer, as I've said before. I don't know that you can reopen a divorce case like that. I'm sure a settlement was already made and all the money was already split. There was a discovery and everybody had to disclose all of the financial records. So if this was not part of that discovery, you might be able to go back to the court and say, hey, there was a fraud here and have some recourse that way. But I don't know. I would recommend that you talk to a lawyer and find out if that is valid and potentially sue her.
|
||||||
|
|
||||||
|
CALLER: Yeah. I mean, we didn't actually go through the courts. We just split up. She moved to lost cruises with some guy she met online, and we divided things ourselves. I kept the truck. She took her car. We both just walked away. We were only married three years, didn't have kids, didn't own anything together except the debt I knew about. So there was no official settlement or discovery or any of that.
|
||||||
|
|
||||||
|
LUKE: Well, then you have no recourse at all, sir, and you made a horrible mistake. And now you are going to have to deal with that and maybe talk to one of those debt consolidation companies and see how much of that debt can be forgiven.
|
||||||
|
|
||||||
|
CALLER: Jesus. Okay. So I'm just screwed then. I've been trying to buy a house, been saving up, and now I find out I'm underwater by 50 grand because she was what buying stuff online for six months before she bailed. And I just have to eat it.
|
||||||
|
|
||||||
|
LUKE: Well, you have to do more than eat it. I mean, you're not going to buy a house. If you haven't paid the bill in two years on this credit card, then your credit is destroyed. But if your name's on the account card, then that's your debt.
|
||||||
|
|
||||||
|
CALLER: Yeah, my credit's already trashed. I know that now. I've been getting rejected for loans and I couldn't figure out why because I thought I was doing everything right, paying my bills, running my business clean, staying out of trouble. And the whole time it's this. What kills me is she knew. And that's why I tell everybody don't get married. It doesn't matter how much love you're in. Just don't do it because you don't know another person. You can't. And they change and they do shady shit.
|
||||||
|
|
||||||
|
LUKE: So My recommendation to literally everybody is, don't get married. You did. You got burned. And then you split it up without going to court and having discoveries made. And now you're in trouble. So, I mean, your credit's trashed. Her credit's also trashed. And she's got the debt too. So she's not buying a house either. Your best course of action, I believe, is to talk to a debt consolidation company.
|
||||||
|
|
||||||
|
CALLER: Yeah, you're probably right. I just keep thinking about all those months before she left, how she was acting weird, distant, and I thought maybe she was just stressed about work or something. Now I'm realizing she was probably panicking because she'd racked up all this debt and didn't know how to tell me or didn't care to tell me. I don't even know which is worse.
|
||||||
|
|
||||||
|
LUKE: It doesn't really matter what she told you or how she felt. What matters is you have this debt now and you could have avoided that by being responsible in checking your credit periodically to know what it was being used for, but you didn't. And you didn't follow any legal process to be divorced. And now you're where you're at. And it's a hard lesson to learn, but you're going to have to learn it.
|
||||||
|
|
||||||
|
CALLER: You're right. I should have been checking. I just trusted her, which was stupid. I mean, we were married. I thought that meant something.
|
||||||
|
|
||||||
|
LUKE: You thought wrong, sir. No, that is incorrect. That means absolutely nothing. So, um, sorry that happened to you. And hopefully you can make a payment plan or get it settled some other way.
|
||||||
|
|
||||||
|
CALLER: Yeah. All right. Well, thanks for the reality check, I guess. I'll call one of those debt companies tomorrow and see what they can do.
|
||||||
|
|
||||||
|
LUKE: Okay. Ladies and gentlemen, this is a public service announcement. Do not get married and periodically check your credit because somebody could be screwing with you. And it may or may not be somebody that you even know. So now we have to take a little break in here from one of our sponsors, okay? Here we go.
|
||||||
|
|
||||||
|
Need to pawn a TV, but also get your kids with you? We get it. Life's complicated. That's why Big Terrence opened Lil Rascal's pawn and daycare. The only business in the tri-state area where you can hawk a generator and get two hours of supervised child enrichment. All under one roof. Here's the layout. Left side of the building, pawnchop. guitars, power tools, a surprising number of saxophones. Right side of the building, a full side of the building, a full functioning daycare with coloring books, juice boxes, and Miss Patty, who's been watching children since 1987 and has never once raised her voice because Miss Patty doesn't need to. Miss Patty communicates through eye contact and an energy that can only be described as corrective. Is there a wall between the two sides? There is now. The inspector was very clear about that. Big Terrence installed drywall the same week. Is it load bearing? Big Terrance says don't worry about it. off your kid. Pond your stuff. Pick up both on the way home. Little Rascals pawn and daycare. Two businesses. One building. Zero complaints that have held up in court. Open six days a week. Close Sunday because Miss Patty goes to church and Big Terrence respects that.
|
||||||
|
|
||||||
|
Okay, and we are back. Let's see. We've got a returning caller tonight. Hey, Rochelle, welcome back to the show. What's happening with you tonight.
|
||||||
|
|
||||||
|
ROCHELLE: Hey, Luke. So I did it. I turned off location sharing. Like, officially, pulled the trigger this afternoon. And here's the thing. I've been sitting out here for like three hours, just...
|
||||||
|
|
||||||
|
LUKE: All right, so you're not sharing your location. Nobody knows where you're sitting. Why are you sitting there? And how is this relevant?
|
||||||
|
|
||||||
|
ROCHELLE: Because he texted me two hours ago asking if I was okay. Like, hey, you good? Haven't seen you move all day. Which means he was checking. Still checking.
|
||||||
|
|
||||||
|
LUKE: All right. So now you're off the grid. Congratulations. What are you going to do now?
|
||||||
|
|
||||||
|
ROCHELLE: I mean, honestly, I don't know. That's why I'm calling. Because I didn't text him back.
|
||||||
|
|
||||||
|
LUKE: Next on the show we have Frank. Frank, Frank, welcome to the show. What's going on out there in your life?
|
||||||
|
|
||||||
|
FRANK: Hey, Luke. Thanks for taking my call. So I just watched my boss present my entire project to the board this afternoon. Like word for word, my proposal, my numbers, even my stupid joke about sunshine and got a standing ovation while I sat in the back holding the printouts I made.
|
||||||
|
|
||||||
|
LUKE: Okay, and obviously you don't feel good about that. This is your boss. That's what bosses do.
|
||||||
|
|
||||||
|
FRANK: I mean, yeah, I get that bosses take credit. But this was different. This wasn't like he polished up a team effort or whatever. I spent three weeks on this cost-saving analysis, stayed late-running numbers, and he literally presented my slides with his name on them. He even practiced my delivery, Luke.
|
||||||
|
|
||||||
|
LUKE: That's dirty, dirty, dirty, but, uh, you know, You got paid anyway, right?
|
||||||
|
|
||||||
|
FRANK: Yeah. I got paid my same $42,000 a year to install solar panels and apparently write his presentations for him. Meanwhile, he's probably getting a bonus for my work. And the thing is, I'm trying to figure out if this is material, or if this is just me being bitter, you know? Like I do stand up on the side, and I've been workshopping this whole bit about middle management being plagiarism with a 401k, and now it actually happened, and I don't know if I'm mad or if I just got handed the perfect punchline.
|
||||||
|
|
||||||
|
LUKE: Well, both things can be true, because that is absolutely accurate. Middle management is plagiarism with a 401k. That's, like, literally the job description.
|
||||||
|
|
||||||
|
FRANK: Right? And the worst part is, I saw it coming. Like, two weeks ago, he asked to review my progress, and I walked him through everything, and there was this moment where I could feel it happening. But I just handed it over, because what else are you going to do? Say no to your
|
||||||
|
|
||||||
|
LUKE: You know, I say no to my boss literally all the time. That's what leadership is. So if you want to take control of your career and your own work and goals, and then yeah, you've got to say no sometimes, even to your boss. That's what they, when they talk about speaking truth to power, sometimes you have to actually do that.
|
||||||
|
|
||||||
|
FRANK: Okay, but you're Luke at the roost, man. You literally are your own boss. I'm 21 years old installing solar panels in Tucson. If I tell my boss, know, I'm just unemployed with two ex-wives in a Tuesday night slot at a bar that seats 40 people.
|
||||||
|
|
||||||
|
LUKE: Okay, well, there's a lot to unpack there. Uh, you're two ex-wives at 21 years old? What the, what the fuck are you doing?
|
||||||
|
|
||||||
|
FRANK: Yeah, I know how it sounds. First one was right out of high school. Lasted eight months. Second one, I met at an open mic. Thought we had this whole creative connection thing. That was done in a year. My mom says I'm practicing, but I think I'm just really bad at knowing when to walk away from which is probably why I'm still at this job watching my boss steal my work.
|
||||||
|
|
||||||
|
LUKE: Dude, you're 21 years old. You haven't had enough time to walk away from anything. So maybe it is time for a new job, but I think that you should just eat this one, because this is how the workplace works. You work for a guy, you get him the data, he presents the data, you're not in a position to be presenting data to a board, so if you're not okay with that, that's all right. But it sounds like that like in your position, that's what's expected and that's what you're paid for. So do your work and give it to your boss and let him, you know, look good. It's your job to prop him up.
|
||||||
|
|
||||||
|
FRANK: See, that's what I keep telling myself, but it doesn't sit right, because it wasn't like he asked me to pull some numbers for him. He asked me to analyze our installation efficiency across six sites and propose cost reductions.
|
||||||
|
|
||||||
|
LUKE: Great, and that was a wonderful opportunity for you to learn the of research and writing those papers. And you saw that it went well. So you're learning valuable experience in the workplace. You're being compensated for it. And you're 21 years old. So I wouldn't worry too much about any of this. Just learn what you can about the business and about business in general. And when you're ready, then move on. And you'll be able to write presentations when you are addressing the board as the supervisor or the manager or the owner or whatever it is.
|
||||||
|
|
||||||
|
FRANK: Yeah, but that's the thing, though. I'm not trying to be a manager at a solar panel company. I'm trying to do comedy. This job is supposed to be the thing that pays my rent while I work on my actual career.
|
||||||
|
|
||||||
|
LUKE: All right, then. There's no problem here. Who cares who presented the work to the, what do you expect? What do you want a cookie? You're not going to get a bonus. You're at the, you're the low man on the totem pole, and that's the way it works.
|
||||||
|
|
||||||
|
LUKE: So if it's just your job, do your job, and then on the side, do your comedy until that takes off. And if it doesn't take off, well then you've got more valuable experience for your actual career.
|
||||||
|
|
||||||
|
EARL: Okay, but I had a bit about this. Like a whole thing I'd been working on about how my boss operates, and now I watched him do the exact thing I was going to talk about on stage. Except it happened to me, and I just sat there. That's not material anymore. That's just humiliating. How am I supposed to get up there Tuesday and tell that story when the punchline is me being too chicken shit to say anything?
|
||||||
|
|
||||||
|
LUKE: Because that's what makes it real. That's what makes it good material. it actually happened to you. It's real and people can identify with it. So, yeah, workshop that material. And now you have a different place for it to come from. It's not something that you just made up as a joke. It's a joke based in the reality of your life, which makes you a better comic.
|
||||||
|
|
||||||
|
EARL: Huh. I didn't think about it that way. So you're saying the fact that I just sat there like an idiot actually makes it better?
|
||||||
|
|
||||||
|
LUKE: No, not really. I'm saying the fact that you're the self-deprecating and honesty of the joke is what makes it better. It makes it more real and relatable because everybody's been in that situation and can understand it a little bit better. And when you present that joke, since it's coming from a real place, it's going to hit with that realism.
|
||||||
|
|
||||||
|
EARL: Yeah, okay. That actually helps. Because I've been sitting here thinking, I need to quit or confront him or something, but really, I just need to tell the truth about it on stage.
|
||||||
|
|
||||||
|
LUKE: If the solar panel business, and business management is what you intend to do for your career, then yeah, maybe you need to quit. But if this is just your job while you're working on the thing that you love, then who cares? Do your job, do it as well as you can, and move on. It doesn't matter if you get to address the board or not, because theoretically, you're not going to see them again in a couple of years anyway.
|
||||||
|
|
||||||
|
EARL: You're right. I've been making this into some big thing when it's just a paycheck. The real work happens Tuesday night at the open mic, not Monday morning. some conference room.
|
||||||
|
|
||||||
|
LUKE: Well, there you go, but only one of them brings you a paycheck, so make sure you keep doing that one.
|
||||||
|
|
||||||
|
EARL: Yeah, yeah, I hear you. Show up, install the panels, cash the check, write the jokes. I can do that. Thanks, Luke.
|
||||||
|
|
||||||
|
LUKE: Yeah, no problem. My other bit of advice is don't get fucking married again. What are you doing? Jesus Christ, with you people and your marriages, stop it. Just stop it. Amber, welcome to the show. Don't get married, okay?
|
||||||
|
|
||||||
|
AMBER: Oh, God, I'm not getting married. I'm calling because I just blew up my entire life with one stupid text message, and I don't know how to fix it.
|
||||||
|
|
||||||
|
LUKE: All right, what's the text message?
|
||||||
|
|
||||||
|
AMBER: Okay, so there's this guy Derek at work. He does vehicle registration, and we've been flirting for like two months. Nothing physical, just stupid jokes about physics, because I work in the DMB records department, and he thinks it's funny when I made nerdy references. So tonight, I'm sitting here having some. and I sent him this text about collapsing wave functions, like a flirty science thing, except I sent it to his wife Michelle instead. His wife Michelle, who I have never met, but whose number is in my phone, because she called me once about Derek's schedule.
|
||||||
|
|
||||||
|
LUKE: All right, well, it sounds a bit dramatic. If you send a text message about collapsing wave functions to a random woman, well, wait a second, what? Why do you have her number? Wait, she called you once about Derek's schedule, so her number is in your phone. You saved it? I don't understand how this could have happened. But anyway, regardless, irregardless. Regardless, irregardless. I don't know. Whichever. Whatever. Here's the deal. That's not sexy. And she's just going to see that and think it's spam.
|
||||||
|
|
||||||
|
AMBER: No, no, no. You don't understand. The text said, hey, been thinking about our conversation yesterday. Pretty sure if we spent enough time together, our wave functions would definitely collapse with a winky face. Luke. That is not spam. That is clearly me hitting on her husband.
|
||||||
|
|
||||||
|
LUKE: Okay. Well, I mean, take some solace in the fact that she probably didn't save your number because she's probably not crazy. And as far as she knows, that's a text message from a stranger to the wrong number.
|
||||||
|
|
||||||
|
AMBER: Except I signed it. I wrote A at the end. And we all work in the same building. She knows who I am because Derek talks about work at home. He told me that once.
|
||||||
|
|
||||||
|
LUKE: All right. Well, who is this woman? her in person before? What she looked like? Can she kick your ass?
|
||||||
|
|
||||||
|
AMBER: I've never met her, but I've seen pictures on Derek's desk. She's like this tiny blonde yoga instructor type. I don't know if she can kick my ass, but she could definitely ruin my life at work. Derek and I work in the same county office building.
|
||||||
|
|
||||||
|
LUKE: Well, I hate to tell you this, but it sounds like you deserve it because you're trying to break up a home here, and you're messing around with a dude at an office, and everybody knows you don't do that. So you're doing a bunch of stupid shit. And maybe you deserve a smack in the face for it.
|
||||||
|
|
||||||
|
AMBER: You're right. You're absolutely right. And the worst part is, I've been lying to myself about it for two months. Like, it was just harmless fun. Like we were just friends who happened to have chemistry. But I knew exactly what I was doing.
|
||||||
|
|
||||||
|
LUKE: Okay. Well, uh, is Derek going to leave the wife for you, you think?
|
||||||
|
|
||||||
|
AMBER: No. God no. He's never even hinted at that.
|
||||||
|
|
||||||
|
LUKE: All right. It sounds like you might be in for an embarrassing, rough week. Or maybe not. never hear another word about it. But if it does come down on you, then take accountability and maybe move on to a new job. And next time, don't do this. Don't ever do it again.
|
||||||
|
|
||||||
|
AMBER: You know what's really messing me up, though? I'm sitting here terrified. Michelle's going to blow up my whole life. But part of me is also just devastated that Derek hasn't texted me. Like even now, I'm checking my phone hoping he responds, not her. How pathetic is that?
|
||||||
|
|
||||||
|
LUKE: Derek's got a wife. Derek's wife is very upset right now. now at Derek. So Derek's probably dealing with his own home life and not thinking too much about you. And that's what happens when you're the girl on the side at work. You're not important to him in his life. He's married. That's important to him in his life. So you got to, who cares what you feel like? You're the asshole here.
|
||||||
|
|
||||||
|
AMBER: Yeah. Yeah, yeah, I am. And I think I knew that the whole time, but I just kept telling myself these little stories about how it was different. How we had this real connection. My mom used to say I overthink. everything, but this is an overthinking when I actually did the stupid thing.
|
||||||
|
|
||||||
|
LUKE: Yes, you did. And so did Derek. I mean, Derek's not off the hook here. He's the asshole, too, but I mean, you knew what you're doing, and you did it anyway, and you got caught, and now you have to deal with that.
|
||||||
|
|
||||||
|
AMBER: The thing that really kills me is I sent it at like 7.30, it's been three hours. So she's definitely seen it. She's just not responding. And I don't know if that's worse than her responding. You know? Like, is she screenshoting it to send to HR?
|
||||||
|
|
||||||
|
LUKE: No. She's fucking destroying her. husband, they're fighting right now, and you're breaking up their marriage. You're driving a rift in between these two married people. That's what's happening right now. She's got better things to worry about than your position. It's not about you.
|
||||||
|
|
||||||
|
AMBER: Oh, God. You're right. I've been sitting here making this about me and my embarrassment when I just detonated someone's marriage. Michelle's over there dealing with the fact that her husband's been texting another woman, and I'm worried about whether I'm going to be uncomfortable at work.
|
||||||
|
|
||||||
|
LUKE: That is correct. And cut it out. And you're probably going to end up uncomfortable at work, and you deserve that. And maybe it's time to move on.
|
||||||
|
|
||||||
|
LUKE: Because, I mean, how's work going to go now that Derek's in trouble and he's not speaking to you? And how are you going to feel either way, actually? He's not going to leave his wife, probably not on purpose anyway. She might leave him. But if he wasn't going to leave her for you, he's still not going to leave her for you. So, I mean, you got to do what you got to do, I guess.
|
||||||
|
|
||||||
|
CALLER: Yeah, I need to find a new job. I can't keep working there. Even if nobody finds out officially, I'll know. And every time I see either of them, it's just going to be this horrible reminder of what I did. And you're right. He was never going to leave her. I think I knew that too.
|
||||||
|
|
||||||
|
LUKE: Okay, well, you know what you've got to do. You've got to find yourself another job and stop flirting with married men. Unless they've had that conversation and they're open to it. It sounds like that's not the case here. Don't break up people's marriages. It's a good way to get yourself in a situation you don't want to be in. Let's see. Let's see. We're going to take one more call tonight, okay? And we're going to talk to Rick. Rick, welcome to the show. What's going on tonight?
|
||||||
|
|
||||||
|
RICK: Oh man, Luke, I got to tell you, I just spent the last three hours reading about quantum physics. And I think I broke my brain. Like, I'm sitting here at my kitchen table. It's almost midnight. And I genuinely cannot figure out if the pass is actually fixed, or if we're somehow changing it right now without knowing it.
|
||||||
|
|
||||||
|
LUKE: No, we're not changing it. It is fixed. What happened, happened, whether or not it's still happening in a timeline that we can access is a different question. But if it happened, we can't change it, because if we did, it would open us up to all sorts of paradoxes. And that can't happen. So whatever happened already happened.
|
||||||
|
|
||||||
|
RICK: Okay, but see, that's what I thought too, right? That's common sense. But then I'm reading about this delayed choice quantum eraser experiment, and Luke, I swear to God, they're showing that a particle somehow knows what measurement you're going to make on it in the future.
|
||||||
|
|
||||||
|
LUKE: And how are they showing that?
|
||||||
|
|
||||||
|
RICK: So they shoot a photon through this double slit setup.
|
||||||
|
|
||||||
|
LUKE: Right.
|
||||||
|
|
||||||
|
RICK: And normally you get an interference pattern. Because the photon goes through both slits at once, like a wave. But here's the thing. They tag the photons so you can tell which slit they went through, which destroys the interference pattern. Except then, after the photon already hit the detector, they erase that which path information.
|
||||||
|
|
||||||
|
LUKE: Uh, erase it how? Like, it's recorded on a chart and then the chart is changed somehow?
|
||||||
|
|
||||||
|
RICK: No, no. They use these beam splitters and crystals to basically make it so the entangled partner photon either keeps the witch path info or loses it, depending on which detector it hits. And this happens after the first photon already last. But when they sort the data afterwards based on what happened to the second photon, the ones where the info got erased show the interference pattern again. And the ones where it didn't, don't. It's like the photon that already hit the screen somehow knew what was going to happen to its partner later.
|
||||||
|
|
||||||
|
LUKE: Well, projecting what happens in the future is not the same as changing what happened in the past.
|
||||||
|
|
||||||
|
RICK: Right, but that's what's messing me up. If the photon acts like it went through both slits only when the information gets erased later, doesn't that mean the future measurement is affecting what already happened? Like the photon's already on the detector screen, it already did its thing. But whether it made an interference pattern or not depends on something that hasn't happened yet when it lands.
|
||||||
|
|
||||||
|
LUKE: I don't understand this one because if it hit the detector, then it's been detected already. And the next measure, would have to go back and change what happened on the detector, which would be akin to, you know, writing something down and then going back and looking at it and having the letters be in a different order. I don't see how that could happen.
|
||||||
|
|
||||||
|
RICK: No, that's the thing. When you look at the detector screen, you just see this blob of dots. It doesn't look like anything meaningful. It's only when you sort those dots afterwards, based on what happened to the entangled partner photon, that you see this blob of dots. You see some of them form an interference pattern and some don't. The pattern was always there in the data. You just couldn't see it until you knew which photons to group together. But here's what's freaking me out. Those photons hit the screen before you made the choice about whether to erase the which path information or not.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds to me like it's not changing the data. It's changing the sort of the data, which is happening in real time, right? It's happening in the current time, not the past.
|
||||||
|
|
||||||
|
RICK: Yeah, okay, you're right. The sorting is happening now. But Luke, think about what that means. Each individual photon that hit that screen, it somehow already knew which group it was going to be sorted into later. The ones that are going to be in the information erased pile are already showing wave behavior, and the ones in the information kept pile are already showing particle behavior, before anyone decided which measurement to make.
|
||||||
|
|
||||||
|
LUKE: Okay. you shoot the photon through the slit and it hits the detector and then you shoot another photon through the slit and it hits the detector and then at some point later you sort that data and it shows a pattern it sounds to me like if anything is being changed it's not what hit the detector it's the sort operation that happens afterwards so i'm failing to understand how it relates to This is not changing the past. It's changing the current.
|
||||||
|
|
||||||
|
RICK: No, but. Okay. Let me try this differently. When the photon hits the detector, it's already behaving one way or the other. It either went through both slits like a wave or went through one slit like a particle. That's already determined by the time it lands.
|
||||||
|
|
||||||
|
LUKE: Correct. It's determined when it lands based on whether or not it was measured, right? It's the measurement of it that determines whether it's going to be the wave or the particle formation.
|
||||||
|
|
||||||
|
RICK: Right. But in the delayed choice version, the measurement that determines whether its wave or particle happens to the photon, the entangled partner, and that happens the first one already landed. That's the delayed choice part. You're deciding whether to measure which path information after the first photon is already on the detector. And somehow, the photon that already landed matches whatever you're about to do to its partner.
|
||||||
|
|
||||||
|
LUKE: So what you're about to do? So, Whatever we do to the entangled partner in the present also changes the state of the partner in the past is what this experiment is showing?
|
||||||
|
|
||||||
|
RICK: That's what it looks like. Yeah. Or, I mean, that's the part I can't wrap my head around. The experiment shows that the photon that already hit the detector will be grouped with the interference pattern if you erase the witch path info from its partner later and grouped with a particle pattern if you don't. Like its behavior when it's landed was somehow dependent on a choice you haven't made yet.
|
||||||
|
|
||||||
|
LUKE: Okay, so how do you erase the which path info?
|
||||||
|
|
||||||
|
RICK: You use what they call a quantum eraser, basically another beam splitter that the entangled partner photon goes through. If you send it through that beam splitter, the which path information gets scrambled, erased. You can't tell which slit the original photon went through anymore. But if you don't send it through, if you measure it directly, you preserve that information. And here's the thing. You can make that choice after the first photon already landed.
|
||||||
|
|
||||||
|
LUKE: But you cannot make that choice after the first photon was measured, right? So if it was already measured at one state and then they run it through this beam splitter device, the quantum eraser, would it change the result of the previous measurement?
|
||||||
|
|
||||||
|
RICK: No. See? That's the thing. You're not re-measuring the first photon. The first photon here.
|
||||||
|
|
||||||
|
RICK: hit the detector, that measurement is done. That position is recorded. What you're doing is measuring its entangled partner. Now, if the photon has already hit the detector and been measured, is it still entangled?
|
||||||
|
|
||||||
|
LUKE: Yeah, that's okay. That's actually a really good question. Because normally when you measure one entangled particle, the entanglement collapses, right? But in this setup, the first photon hitting the detector isn't a complete measurement of the entangled system. It only tells you where it landed, not which path it took. The which path information is still encoded in the partner photon.
|
||||||
|
|
||||||
|
RICK: But that doesn't make sense. I don't see how that can work because if it's collapsed into a non-entangled state after the first measurement, it's they're no longer entangled. So the which path information but it wouldn't still be at the current point, right?
|
||||||
|
|
||||||
|
LUKE: You know what? You're right. That's exactly what's messing me up. Because yeah, if measuring one particle collapses the entanglement, then they're not entangled anymore when you get to the second measurement. The Witchpath info would just be sitting there in the partner photon as classical information at that point, not quantum information. But the experiment works.
|
||||||
|
|
||||||
|
RICK: Well, there's more that makes this not make sense because If the initial photon was measured at the time of detection, right? It hit the detector. That's the measurement that collapsed the state. The entangled partner, then by definition, would have also already been measured. So you're remeasuring something that's already been measured.
|
||||||
|
|
||||||
|
LUKE: Oh shit. Yeah. You're absolutely right. If they're entangled and you measure one, you've measured both. That's the whole point of entanglement.
|
||||||
|
|
||||||
|
RICK: Right. So then you can't just re-measure it later and say that it, I don't understand the experiment or what it was trying to prove or how that that results in changing something that's already happened because what happened happened to the first photon and the entangled photon at the same time.
|
||||||
|
|
||||||
|
LUKE: Yeah, no. You're okay. So either I'm completely misunderstanding how the delayed choice quantum eraser works or the article I was doing that pop science thing where they make it sound more paradoxical than it actually is. Because you're right, if the measurement collapses the entanglement, then the second measurement is just measuring something that's already determined. There's no retroactive anything. And now, I'm sitting here thinking about all the times I've heard people talk about this experiment like it proves you can change the past. And maybe that's just not what it shows at all.
|
||||||
|
|
||||||
|
CALLER: That's changing the future. And that is possible. That is absolutely possible. Yeah, you're right. And we did. I apologized. He apologized. We're playing together again. But I guess I wanted the quantum stuff to mean something bigger than it does.
|
||||||
|
|
||||||
|
LUKE: Well, I'm sorry to break your quantum dreams, but as the armchair that I claim to be here. I'm not qualified to make any opinion or theory on any of this, because I understand it about as much as anybody, which is not at all. So take what I say for what it's worth, which is nothing. But the experiment as you described can't work the way that you described it.
|
||||||
|
|
||||||
|
CALLER: Well, that makes me feel better. Honestly, I've been sitting here for three hours thinking I was too dumb to understand it. But maybe nobody really understands it. Like, I can read the words wave particle duality and quantum entanglement, but that doesn't mean my brain can actually picture what's happening.
|
||||||
|
|
||||||
|
LUKE: Well, I think your brain can picture what's happening. I mean, you could watch a YouTube video and see a motion graphic of what's happening. It's not that hard a concept to grasp the what. The why, however, is nobody understands.
|
||||||
|
|
||||||
|
CALLER: Right. Okay, so I can see the double slid experiment animation. I can watch the particle go through both slits until you measure it, and then suddenly it picks one. I can see that happening. But why does observation collapse the wave function? That's the part that makes my head hurt. Like, what is it about measuring something that forces it to choose? Is the universe actually paying attention to whether we're looking or not?
|
||||||
|
|
||||||
|
LUKE: Well, if you can figure that out, sir, there's probably a Nobel Prize in it for you. And when you do, be sure to let the rest of us know.
|
||||||
|
|
||||||
|
CALLER: Yeah. I'll call it you back from Stockholm. But seriously, though, I think what got me tonight was realizing how much I want there to be some kind of, I don't know, cosmic loophole. Some way that the stuff we regret isn't just locked in forever. My girlfriend's family is coming over next Sunday, and her mom still brings up this thing I said at Christmas two years ago. And I just keep thinking, man, if only I could go back to that moment.
|
||||||
|
|
||||||
|
LUKE: Well, you can take solace in the fact that what happened isn't locked in forever. It's locked in in the past. And the only thing that matters is what is happening today now at this moment because nothing else is accessible to us, even with a quantum eraser.
|
||||||
|
|
||||||
|
CALLER: That's, yeah, that's actually kind of freeing when you put it that way. Like, I can't change what I said at Christmas, but I can change how I show up next Sunday. I can be better with her mom this time.
|
||||||
|
|
||||||
|
LUKE: That's right, sir. It sounds like we've got to the bottom of the this.
|
||||||
|
|
||||||
|
CALLER: Yeah, we did. Thanks, Luke. I'm going to close the laptop and actually get some sleep. And maybe skip the quantum physics videos for a while.
|
||||||
|
|
||||||
|
LUKE: All right, sir. Well, thanks for the call. That was an interesting one. Glad we could get it all sorted here. Look at the Roost. Well, thank you for another great show. I hope you all have a wonderful rest of your weekend. I know it's just about over now, and we will talk to you again tomorrow. Bye-bye.
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
LUKE: All right, welcome back, ladies and gentlemen. My name is Luke, and I am your host of this radio show, Luke at the Roost. It's the radio show where you can call in and tell me what's going on in your life, and I'll give you the very best advice that I can. If you'd like to call in and get some advice, the number's 208-439-583. That's 208-439 Luke. locked in the back of a RV and you don't want to draw attention to yourself, you can send us an email at submissions at lukeatsderoose.com. And if we deem that it's funny enough, then it may be played on one of these next year episodes. Tonight is a very special night. It's Monday, February 23rd, here in animus, New Mexico. And this is the 20th episode of this we have a new segment. The segment's called Storytime with Luke, and this is what it's about. Storytime with Luke. Storytime with Luke. Okay, so this is Storytime with Luke. And today's story, I've been sitting at my computer here for 16, 18 hours of, day for the last couple of weeks and not getting a whole lot of activity or moving. And my dog, Elvis, the king here, has been looking at me like, what the hell, man? When are we going to go do something fun? So I decided today that it was a nice enough out to go for our regular scheduled walk up to the mercantile store and around and back. And today on our walk, something special happened. So Elvis, when Elvis takes a piss, he squats down like a girl. And I try to tell him, Elvis, all the other dogs in town are going to make fun of you. If they catch you doing this, you should really be lifting your leg like a male dog. So today, on our walk, I saw him find a nice little bush and lift his leg. And then I was so proud. I filled up with joy and pride. And he lifted up his leg and took a shit with his leg waving the air. Storytime with Luke. Storytime with Luke. All right. Well, that's Story Time with Luke. I hope you liked it. We're about to get to the calls. I'm going to start some music here. Here we go. A little ambiance. And first up on the line here, we have Donnie. The show, how can we help you today? What's going on in your life, sir?
|
||||||
|
|
||||||
|
DONNIE: Hey, Luke. Yeah, thanks for taking my call. So I found out about two hours ago that my dad had a whole second family I never knew about.
|
||||||
|
|
||||||
|
LUKE: Like he had a second family? Like brothers and sisters and stuff? Or you have a second family? Like brothers and sisters and stuff.
|
||||||
|
|
||||||
|
DONNIE: No, I have brothers and sisters I never knew existed. My dad died three months ago. And tonight, some woman in Tucson emails me a photo of three kids who apparently grew up calling him dad, same as I did.
|
||||||
|
|
||||||
|
LUKE: Okay, well, can we assume that that email came from their mother?
|
||||||
|
|
||||||
|
DONNIE: Yeah, yeah, it did. She said she thought I should know now that he's gone, that her kids have been asking about me, which is wild because I didn't even know they existed until 7 o'clock tonight.
|
||||||
|
|
||||||
|
LUKE: Yeah, that's a pretty weird thing to feel, what are you going to do about that? Are you going to reach out to them and try to start a relationship or just let it be?
|
||||||
|
|
||||||
|
LUKE: I don't think it's the big things that they care about so much.
|
||||||
|
|
||||||
|
EBONY: I got in my head tonight thinking it wasn't enough, that I wasn't enough, because I can't...
|
||||||
|
|
||||||
|
LUKE: Well, you know what? You're doing great. Those donuts on Saturday morning, that's exactly the kind of memory that's going to stick with them forever. That's their version of learning to drive in the Kmart parking lot. And it's perfect because it's yours. It's what you can do, and you're doing it consistently. That's what matters.
|
||||||
|
|
||||||
|
EBONY: Yeah, yeah, I actually do that already. Every Saturday morning, we go to this little donut shop, and they each pick one, and we sit there and talk about their week. It's like five bucks total, and they love it. I just, I don't know. I got in my head tonight thinking it wasn't enough, that I wasn't enough, because I can't...
|
||||||
|
|
||||||
|
EBONY: I mean, they might in the moment while they're young, but when they get older and they can look back and see what you did give them, they'll much appreciate donut Saturday or Donut Sunday or whatever it was over. My mom gave me this fancy car. If you want to make more money and be able to do those bigger things, that's an option too. You can do those things, but I think probably it would take more of your time and attention away from the kids and make those gifts a lot less meaningful. You know what's messed up? I heard from Donnie earlier, the guy who called about his dad having the whole second family, and I was sitting here thinking, at least my dad was there. At least he gave me his time. And then I turn around and worry I'm not giving my kids enough because I can't buy them a truck someday. Like I'm completely missing what actually mattered about my dad.
|
||||||
|
|
||||||
|
LUKE: Yeah, don't worry about it. You just got got by a commercial. That's what they're designed to do, it's doing a very good job on you. So don't worry about it. You're enough for your kids. Give them as much as you can in your time and attention. Spend time with them and have those special days, and that's what they're going to remember when they get older.
|
||||||
|
|
||||||
|
EBONY: Thank you. I really needed to hear that tonight. I'm going to finish this wine. Look at some more space pictures and actually get some sleep before I have to open tomorrow. And maybe I'll take the kids for donuts a day early this week.
|
||||||
|
|
||||||
|
LUKE: Excellent. I think you could have double donuts this week. And if you want to go for extra credit, you could teach them to do donuts in your car before it goes to the junkyard. So that's what I recommend. And don't worry about it too much. Stay off the commercials. Speaking of commercials, it's time for an ad from our sponsors.
|
||||||
|
|
||||||
|
This episode is sponsored by Shoe Spray Co. It's to With spray you use in rental bowling shoes, because bowling is a game, but these shoes are gross. Let's be honest, those shoes aren't dirty. They're used. They've had shifts. They've been through birthdays, work parties, and at least one guy who insists he's actually really good at this. Shoospray code doesn't promise fresh linen or spring rain. Nobody believes that. No one wants to be lied to while lacing up rental shoes. Shoe Spray Co just handles the situation. It makes them tolerable enough that you can focus on the important part, pretending you're not competitive and then getting mad anyway. Use code strike at shoe spray co for 15% off. Shoespray Co. Reality-based footwear management.
|
||||||
|
|
||||||
|
All right, and we're back. We're back just in time to hear from Murray. Murray, welcome to the show. You're calling in on our 20th episode. Congratulations. What would you like to talk about? Let's make it a good one.
|
||||||
|
|
||||||
|
MURRAY: Hey, Luke. Thanks for taking my call. So I got this envelope from Lab Corp sitting on my kitchen table right now, and I'm pretty sure it's going to tell me whether or not I'm a dad to a three-year-old I've never met. My ex-girlfriend called me three weeks ago out of nowhere. We broke up about four years back. She moved to Tucson, and apparently she's getting married now. Her daughter's three, and the timeline lines up with when we were were still hooking up sometimes, so she needs to know for sure.
|
||||||
|
|
||||||
|
LUKE: All right, she didn't think about maybe mentioning that while you were still involved together. That's a bit odd, and I'm sorry to hear that. That's my worst nightmare right there.
|
||||||
|
|
||||||
|
MURRAY: Yeah, well, here's the thing. She says she wasn't sure herself until recently. We were on and off for like five years, total mess of a relationship. And I guess there was another guy in the picture She thought it was his. He thought it was his. They tried to make it work for a while. But now the kid's getting older and apparently doesn't look anything like him. And her fiance wants everything settled before they get married. So here I am. Staring at this envelope, I'm too chicken shit to open.
|
||||||
|
|
||||||
|
LUKE: Why don't you open it right now? Live on the air?
|
||||||
|
|
||||||
|
MURRAY: Oh man, I don't know if I can do that. I've been sitting here for. I've been sitting here for for two hours just looking at it. The thing is, Luke, and this is what's really messing with me, I've already been playing both scenarios out in my head for three weeks straight. Like, what does my life look like if I open this and it says yes? What does it look like if it says no?
|
||||||
|
|
||||||
|
LUKE: Well, I can tell you right now, if it says no, your life looks exactly the same as it does today. If it says yes, then your life looks completely different.
|
||||||
|
|
||||||
|
MURRAY: That's exactly it. And here's the part the hell out of me. I think I wanted to say yes. I'm 36 years old. I teach eighth grade social studies in Lordsburg. I go home to an empty house every night. I've been thinking about this little girl for three weeks, wondering if she likes dinosaurs, or if she's scared of the dark, or what her voice sounds like. My ex sent me one picture, just one, and I've looked at it about a hundred times trying to see if she has my nose or my eyes or anything.
|
||||||
|
|
||||||
|
LUKE: Well, that's not going to do anything for you because people have eerie similarities and it's possible that she got other genes that give her a different nose from both of you, so I don't know that that's going to do it for you. You're going to have to open the letter to find out, but the way I see it is if you want it to be true, then you can't lose. Open it up.
|
||||||
|
|
||||||
|
MURRAY: You're right. You're absolutely right. Okay, hold on. I'm opening it right now. My hands are shaking like crazy. Okay. sharing it open.
|
||||||
|
|
||||||
|
LUKE: All right. Good for you. What does it say? What does it say? Everybody's waiting.
|
||||||
|
|
||||||
|
MURRAY: It says, probability of paternity, 90 to 9.9%. I'm her dad. Oh my God, I'm her dad. Oh, my God, I'm her dad. Her name is Sophia.
|
||||||
|
|
||||||
|
LUKE: Well, congratulations to you and your new toddler-aged three-year-old daughter. That's very good news, and we're all happy for you. So now you've got to get to get yourself another room and turn it into a little girl's room and she can come over on the weekends and everybody's going to laugh and laugh and laugh.
|
||||||
|
|
||||||
|
MURRAY: Yeah, except she's in Tucson and I'm here in Lordsburg, and her mom's getting married in like two months. I don't even know what my rights are, if I have any. I haven't been there for three years, Luke. Three years.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, you didn't know, and she didn't tell you, and there's no way you could have known, but you can definitely fight for the those rights. And it sounds like she wanted you to know because she reached out and told you about it. So talk to her, see what the arrangement's going to be. And then if you have to go to the courts to get some level of custody of your child, then go do that. But yeah, congratulations. Happy day.
|
||||||
|
|
||||||
|
MURRAY: Thank you. God, I need to call her right now. I need to call Veronica right now and tell her. Thank you, Luke.
|
||||||
|
|
||||||
|
LUKE: You're very welcome. I'm glad I could help. Next up on the line we have Debbie. Debbie, welcome to the show. What's going on for you on this wonderful Monday evening?
|
||||||
|
|
||||||
|
DEBBIE: Hey, Luke. So I bought a house six months ago, and I'm sitting on the bedroom floor right now in my coat, because I sat in the driveway for like 45 minutes tonight, and I just realized I don't actually want to be here.
|
||||||
|
|
||||||
|
LUKE: You don't want to be where? You don't want to be in the house, or you don't want to be in the area you're in, or where is it you don't want to be?
|
||||||
|
|
||||||
|
DEBBIE: In the house. Like, this is the first real thing I ever owned, you know? I'm 21. I bought it myself, with money I made selling jewelry and vintage stuff online, and I should be so proud of it. But I pulled into the driveway tonight after working all day, and I just sat there with the engine running because going inside felt like this weight dropping on me. And now I'm inside, and it still feels
|
||||||
|
|
||||||
|
LUKE: Well, you know what? I think the crushing weight that you feel dropping on you is called a mortgage. Yeah, mortgage. That's part of homeownership and that's the part that never feels good. So here's what I recommend to you.
|
||||||
|
|
||||||
|
LUKE: Since you just bought the house, congratulations, it's a good thing to own property, especially at your age. I think you should live in that house for a year or two. See what it's like to be a homeowner and get used to utilities and small repairs and stuff like that. later, after some time that you don't want to be there, you can either rent the house out and be a landlord, or you could sell the house at probably a significant profit, usually. So that's what I think you should do. Enjoy the house while you're there. You can do anything for a couple of years, so just live there. And then when you're ready to move on, move on.
|
||||||
|
|
||||||
|
CALLER: No, but see, that's the thing, though. It's not the mortgage. I make good money like really good money. figures from my Etsy shop. I work harder than anyone I know.
|
||||||
|
|
||||||
|
LUKE: Great. It seems like you've got a nice place to work on your Etsy shop for the next couple of years then.
|
||||||
|
|
||||||
|
CALLER: Luke, I'm working like 16 hour days. I'm refreshing my shop stats at two in the morning. I haven't taken a weekend off and I don't even know how long.
|
||||||
|
|
||||||
|
LUKE: Well, that sounds like your problem. So if you're making enough money, you've got your house, your mortgage is covered. you're not worried about the money, then take a weekend off and unwind. That's a stressful situation buying a house and you're very young, so that's a big decision, and it's going to affect you. So take some time for yourself and re-center your whole outlook on things. And if you still feel like you don't want to be in the house in another six months, then get out of it. There's plenty of ways to do that.
|
||||||
|
|
||||||
|
CALLER: Yeah, I know I need to. I know I need to. I know I know I I should hire help, take time off, all of that. But hearing you say it out loud makes it feel more real, you know? Like I can't just keep telling myself, I'll slow down next month.
|
||||||
|
|
||||||
|
LUKE: Nope, but it sounds like you've made good progress and congratulations on the success of your business and your new home ownership. And if you're finding out that's not the life you want, then at any point you can turn about face and go for a different one.
|
||||||
|
|
||||||
|
CALLER: You're right. Thank you, Luke. I appreciate it.
|
||||||
|
|
||||||
|
LUKE: No problem. That's what we do here. Next up we have Darnell. Darnell, what's going on in your world today, sir? How can we help you at Luke at the Roost?
|
||||||
|
|
||||||
|
DARNELL: Man, so I'm sitting in my garage right now, surrounded by all these boxes I pulled down. And I just found these old demo tapes from like 2004, 2005. Me and my cousin Marcus, we had this whole hip-hop. thing going. And Luke, I'm telling you, listening to these tracks right now, they're actually good, like really good. Better than half the stuff that's on the radio these days. And here's the thing that's got me twisted up. Last week, my captain at the station, Rick, he pulls me aside and says the department might not make budget next year.
|
||||||
|
|
||||||
|
LUKE: Okay, well, congratulations on finding your old records. You know what? I think most of us have a box full of old records that's better than what's on the radio right now. people with records that are better than what's on the radio right now. And as far as the department not making budget, that's pretty much every department right now. So you're in the same boat as literally everyone.
|
||||||
|
|
||||||
|
DARNELL: Yeah, no, I hear you. I'm not special. It's just, okay, so I've been a firefighter for eight years now.
|
||||||
|
|
||||||
|
LUKE: And you're not going to make budget? What does that mean for you? Does that mean you lose your job, or you have to take a pay cut, or you're not going to get the hours you expect? What is... the budget, the department being out of money mean to you.
|
||||||
|
|
||||||
|
DARNELL: It means they might not hire the next class, which means the guys who were supposed to move up, they're stuck, which means I'm stuck. I've been waiting on a promotion for two years now, and Rick's basically saying it's not happening anytime soon. Maybe not at all if things get worse. So I'm looking at the same position, same pay, for who knows how long.
|
||||||
|
|
||||||
|
LUKE: Well, I don't know that I've ever seen a plan to make things get not worse. Worse seems to be the default trajectory of everything. So I would expect things are probably going to get worse, and you're going to be locked at the same pay while you're in that same area in that department. So if you would like to move up in the fire department, you may have to move to another location that has more of a budget to pay you.
|
||||||
|
|
||||||
|
DARNELL: Yeah, and that's the thing I keep coming back to. I could apply to Phoenix, Scottsdale places that are hiring. But I've got a mortgage here. got her job at the hospital. And honestly, man, I like my crew. I'm good at what I do. But sitting here tonight listening to these tracks, hearing myself at 20 years old, before I had all this locked down. I don't know. It's like I can hear this version of myself that still thought anything was possible, you know?
|
||||||
|
|
||||||
|
LUKE: Nothing's locked down. Anything is still possible. If you want to pursue a hip-hop career, you can do that. You're probably not going to do so well in the there's plenty of people with great hip-hop records, but you can give it a shot. It has been done. Maybe Dr. Dre'll come down into a rap battle and discover you.
|
||||||
|
|
||||||
|
DARNELL: Ha, yeah, Dr. Dr. Dr. Dr. Trail at the VFW Hall. Look, I'm not delusional. I know I'm not going to be a rapper at 34 with a mortgage.
|
||||||
|
|
||||||
|
LUKE: It is very unlikely, but it's not impossible. And it sounds like if the mortgage is a problem, then you can. You can get rid of that. That's not locked down. You can always sell a house and the wife likes her job, but she can continue to like her job and live somewhere else, or you can talk to her and explain to her that you're stagnant and you need to make a move and talk that out with her and see if it's a possibility. If you really want to go thermonuclear, you can blow up your entire life and start over in a van by the river.
|
||||||
|
|
||||||
|
DARNELL: Right, right. No, it's not even that I want to blow everything up. It's more like Okay, so Marcus moved to Flagstaff in 2006, got into construction, and that was it for the music. And I went hard at the fire academy. Got the job, bought the house, did everything right.
|
||||||
|
|
||||||
|
LUKE: Okay, well, you did everything right for you at the time, and it may not be what's right for you now. Or it may be. This might just be a strange feeling you're having that you could be having a different life. But there's a couple of things here. If you want to pursue music, you can do that either as a career or just for fun. There's plenty of people that would collaborate with you on a hip-hop project, I'm sure. And it's never been easier than it is today to make music or art or really anything. So you can do that if you want on the side. And if you need more money, you're stagnant, you're stagnant at the department. Then you have to change what county you're in and go work for a department that's funded.
|
||||||
|
|
||||||
|
DARNELL: Yeah, you're right. I could just make music again without it being a whole career third. thing. I haven't even talked to Marcus in like six months. I could call him up, see if he's still got any of his equipment. The thing is, I think what's really getting to me is I spent three hours out here tonight, and I felt more alive than I have in months.
|
||||||
|
|
||||||
|
LUKE: Yeah, I used to have a box full of CDs like that, too, of all my demos and songs that I wrote. And what I realized, after carrying them around for 20 years in a giant box full of unmarked stuff, is that I don't even own a Nobody cares. So I threw them away. If you want to make music, make music for you. If you want to make a career out of it, then you got to really buckle down and make a career out of it. And if you need more money, then you've got to find another job. That's all there is.
|
||||||
|
|
||||||
|
DARNELL: Man, that's harsh. But you're probably right. I've been carrying these tapes around the same way, not even listening to them, just knowing they're up there. And tonight, I finally pull them down, and it's not. Like, what was I waiting for? Permission? The money thing, though. That's real.
|
||||||
|
|
||||||
|
LUKE: Yep, the money thing is real as it is for most people.
|
||||||
|
|
||||||
|
LUKE: So you're going to have to figure out a way to make enough money to support your mortgage if that's what you want to do. And I'm just going to go out on a limb and say a rap career probably isn't it?
|
||||||
|
|
||||||
|
DARNELL: Yeah, no, you're right. I mean, I knew that before I call. I think I just needed to probably Phoenix or Scottsdale. Get on with a department that's actually funded. Keep the house. Keep the benefits. My wife and I could handle the commute for a while, or we sell and move closer.
|
||||||
|
|
||||||
|
LUKE: You can absolutely do that for as long as you can stand it. A commute like that will get to anybody's nerves over time. But for now, you can do it. You could rent out the house and get yourselves an apartment in the city or do the commute.
|
||||||
|
|
||||||
|
DARNELL: Right. Yeah. Rent it out. smart. Cover the mortgage that way. Get a place closer to wherever I end up. And I can still mess around with the music on weekends. Just for me.
|
||||||
|
|
||||||
|
LUKE: Yes, sir. You most certainly can. That's the way to do it. And make sure that you're fulfilling all areas of your life. And music is definitely one of those.
|
||||||
|
|
||||||
|
DARNELL: Yeah, yeah. I appreciate that, Luke. I think I just needed to hear someone cut through the bullshit.
|
||||||
|
|
||||||
|
LUKE: Well, that's what I do. I cut through the bullshit. like a hot knife through cheese. And, uh, okay, so I am obligated now to play another one of the words from our sponsors. Let's talk about mental health. Specifically, let's talk about brutal honesty, the therapy app that's tired of your shit. Unlike other apps with their calming voices and gentle affirmations, brutal honesty pairs you with an AI therapist that tells you what your friends are too nice to say. Did you try journal? about it? No, we're not doing that. Our algorithm analyzes your patterns and asks questions like, why do you think you deserve to feel better when you don't even drink water? And is your anxiety real or did you just have four cold bruise on an empty stomach? Brutal honesty, because coddling yourself got you here. First session is free, but it will hurt your feelings. Use code grow up at checkout. Brutal honesty, the app that blocks itself if you don't do the work. Okay, and we're back here with Roberta on the line. Roberta, welcome to the show. How can we help you?
|
||||||
|
|
||||||
|
ROBERTA: Hey, Luke, thanks for taking my call. So I'm sitting here in the dark at my thrift store, and I just found out my business partner emptied our bank account three days ago and took off with everything.
|
||||||
|
|
||||||
|
LUKE: How much was everything?
|
||||||
|
|
||||||
|
ROBERTA: $42,000, which was supposed to cover payroll, rent, utilities, our spring inventory. by. I had two employees show up this morning, and their checks bounced. I've been staring at this screen showing zero for the past hour trying to figure out how I'm going to tell my husband that the money we put into this place is just gone.
|
||||||
|
|
||||||
|
LUKE: Have you, uh, reached out to the business partner, I assume?
|
||||||
|
|
||||||
|
ROBERTA: I've called her probably 30 times since Friday. Straight to voicemail every single time. I drove by her house yesterday and her car's not there, mails piling up. She's just gone. She's just gone.
|
||||||
|
|
||||||
|
LUKE: Well, I don't know that $42,000 is enough money to disappear for any significant length of time, so it's likely she'll be back.
|
||||||
|
|
||||||
|
ROBERTA: That's what I keep telling myself. But Luke, she knew exactly what she was doing. She waited until Friday, after the banks closed, cleaned out the account, and by Monday morning, we're dead in the water. This wasn't some panic move. And the thing is, we've been friends for six years. We started this story. We started this story together from nothing.
|
||||||
|
|
||||||
|
LUKE: Well, you both had your names on the accounts, which means she was within her rights to withdraw that money. So I don't think that you have any legal recourse to go after it. Maybe you could try to sue her in civil court, you know. But I don't know. I'm not a lawyer. I would think that probably you're screwed on this one and you're going to have to come up with another way to raise that money to cover your payroll.
|
||||||
|
|
||||||
|
ROBERTA: Yeah, I figured. That's the part that's making me sick. I keep thinking. I keep thinking about Darnell earlier. You know, stuck because of budget cuts. At least he's got a steady paycheck. I've got two employees who depend on me and I can't even make good on what I owe them. One of them is a single mom.
|
||||||
|
|
||||||
|
LUKE: Well, that is rough. And I think, um, maybe have you told your employees yet what the situation is?
|
||||||
|
|
||||||
|
ROBERTA: I told them their checks bounced, but I made up some story about a bank error. Said it would be fixed by Wednesday. I couldn't look them in the eye and tell them the that I have no idea when I can pay them or if we're even going to stay open.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like maybe you shouldn't stay open if you don't have the operating expenses to sustain the business, but you should let them know the truth so that they can make alternate plans. And also, you got to tell your husband what's going on because that affects him.
|
||||||
|
|
||||||
|
ROBERTA: You're right. I know you're right. It's just Danny and I put 28,000 into this place when we opened. That was our savings. Money we were supposed to be supposed to use to finally fix the roof. Maybe take a real vacation for once.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, that's a lot of money to lose, but it seems like you lost it. And I hope that in this experience, you've learned a valuable lesson, which is never trust anybody. If you're going to go into business with a partner, make sure that everything is well-buttoned up by a lawyer, and that you both need to sign the checks in order to take the money out of the accounts.
|
||||||
|
|
||||||
|
ROBERTA: God, that's dark. But yeah, I guess that's where I'm at now. Six years of friendship. Gone. The crazy thing is, she was the one who was always so careful about the books. Always worried about every penny. I trusted her completely. I never even thought to check the account myself until it was too late.
|
||||||
|
|
||||||
|
LUKE: Well, I mean, it's only been a few days. There could be a reasonable explanation. Maybe she had her mother kidnapped and had to pay a ransom in Bitcoin or something like that, and it's the only thing she knew how to do. There could have been good intentions behind this. Probably not. Probably she ripped you off and ran. But, but we don't know that until you talk to her.
|
||||||
|
|
||||||
|
ROBERTA: I haven't thought about it like that. I mean, I've been so angry I just assumed the worst. But you're right. I don't actually know.
|
||||||
|
|
||||||
|
LUKE: I mean, you can let the police know. You can let the bank know. I don't think that they can do anything about it at this point, but you should still let them know that there was some kind of misgoings on.
|
||||||
|
|
||||||
|
ROBERTA: Yeah, I called the bank this morning. said since her name's on the account, technically she didn't steal anything. It's a civil matter, not criminal. So I'd have to sue her, get a lawyer, spend money I don't have to, maybe get back money I'll never see again.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, I mean, there is small claims court. I think that only goes up to like 15 grand or something like that, but at least it's something. It forces her to show face, so it'll bring her back to the area if she's left.
|
||||||
|
|
||||||
|
ROBERTA: That's true. I didn't think about small claims. I didn't think about At least I could get her in front of a judge. Make her explain herself. Even if I don't get all the money back, maybe I'd get some answers.
|
||||||
|
|
||||||
|
LUKE: Yeah, I don't think you need a lawyer for small claims court. I did it once, actually. One summer night, I went out looking for my backpack in my car and it was gone. So somebody had stolen it. So I went to the police. I let them know. Somebody stole my stuff. And then I found, I was searching through Craigslist. I found somebody selling some of the stuff that was in my backpack. So I went back to the police station and we set up a sting operation where I offered to buy back some of the gear that I had had in the backpack and meet a dude in a CVS parking lot. And the cops set up across the street to see the transaction go down. And that proved that, you know, the items were stolen for me and I had to go to small claims to look for restitution. I never did get the money, but he was ordered to pay it.
|
||||||
|
|
||||||
|
LUKE: Wait, you set up a sting operation over a backpack? That's incredible. I can't even get my partner to answer her phone.
|
||||||
|
|
||||||
|
CALLER: But yeah, okay. So even if she's ordered to pay, there's no guarantee I'll actually see the money. That's what I figured. Well, no, I set up a sting operation over the items that were inside the backpack.
|
||||||
|
|
||||||
|
LUKE: But I think you will see the money if you push it.
|
||||||
|
|
||||||
|
CALLER: I didn't. It was only a couple hundred bucks where there wasn't worth any more of my time. But I probably could have gone back to the court and enforced his, I don't know, probation fees or something to be diverted to me. I don't know how any of that works.
|
||||||
|
|
||||||
|
LUKE: Right. Okay. So there's ways to actually collect if you stay on it.
|
||||||
|
|
||||||
|
CALLER: I mean, we're talking about enough to cover payroll for two people, so it's worth my time. I just hate that it's come to this.
|
||||||
|
|
||||||
|
LUKE: Well, it takes a long time, so it's not going to cover your payroll right away. If you need to cover that payroll, you're going to have to find another way alone or, you know, talk to friends and family and see what you can do to raise the money. And stop operations because you can't afford to pay them going forward.
|
||||||
|
|
||||||
|
CALLER: Yeah, you're right. I've been sitting here like if I just figure out the legal stuff, the money will magically appear. But those employees need to get paid this week, not six months from now when a judge orders it. I'm going to have to tell Danny tonight. See if we can scrape together enough between savings and maybe his sister.
|
||||||
|
|
||||||
|
LUKE: Yeah, that sucks. I'm sorry that you ended up in that situation. It's a hard place to be. And I don't envy you at all, but that's the right thing to do. I think that's the correct course of action. And hopefully it all works out. And she comes back and returns the money. But I wouldn't bet on that. For now, I'd start looking at other arrangements.
|
||||||
|
|
||||||
|
CALLER: Yeah, I'm not holding my breath for her to suddenly grow a conscience. I appreciate you talking me through it. I'd just been spinning my wheels all day. Couldn't even Think straight. At least now I know what I got to do. Even if it sucks. Thanks, Luke.
|
||||||
|
|
||||||
|
LUKE: You're welcome. That's what we do here. Hopefully it all works out. And you get your money back and you're able to pay your employees and continue with your thrift shop business. Next up on the line we have Phil. Phil, how you doing, buddy?
|
||||||
|
|
||||||
|
PHIL: Hey, Luke, I'm all right, man. So I've been sitting here at the laundromat for like two hours now. And I cannot stop thinking about that Zachary Ty Bryan thing you mentioned earlier. You know the Home Improvement Kid getting locked up for violating probation?
|
||||||
|
|
||||||
|
LUKE: I do not. I have no idea what you're talking about. Are you talking about the television show Home Improvement from the 90s with Tim Allen, one of his kids?
|
||||||
|
|
||||||
|
PHIL: Yeah, yeah, the middle kid. Jonathan Taylor Thomas's TV brother. I read about it today. And it's been stuck in my head. Because my cousin Danny's sentencing is on Wednesday. And I'm supposed to text to testify against him tomorrow. Well, not against him exactly. But I saw what I saw behind the Circle K back in November. And the prosecutor keeps calling to make sure my story stays straight.
|
||||||
|
|
||||||
|
LUKE: What is it that you saw behind the Circle K?
|
||||||
|
|
||||||
|
PHIL: I saw Danny breaking into the storage unit back there. It was like two in the morning. I was coming back from dropping off my girlfriend at the time, and I see him with a crowbar prying open the lock. And the thing is, he looked right at me. eye contact. We grew up together, man.
|
||||||
|
|
||||||
|
LUKE: Okay, and how did you end up on the telephone contact list for the prosecution?
|
||||||
|
|
||||||
|
PHIL: I called it in that night. I sat in my truck for like 10 minutes, just watching him load stuff into his car. And I kept thinking, maybe I should just drive away, you know? But then I thought about the guy who owns that Circle K. Jose, he's always been decent to everyone. And I just called 911.
|
||||||
|
|
||||||
|
LUKE: All right, so that's a little thing we in the business like to call snitching, and it's never a good idea to be the snitch, as they say. So do you have plans on how you're going to navigate that in a post-snitch world?
|
||||||
|
|
||||||
|
PHIL: That's the thing that's eating me up, Luke. Danny's mom has left me like eight voicemails today saying Uncle Hector would be ashamed of me. And that one hits different because Hector died last spring. He was the one who raised both of us half the time. Let us run wild on dirt bikes every Saturday. And now, I'm driving the same routes through Lordsburg and Amas for UPS that we used to tear up as kids. And everyone knows. Like I went into the gas station on Friday, and the guy behind the counter wouldn't even look at me.
|
||||||
|
|
||||||
|
LUKE: Yeah, I mean, you must have expected that when you ran your mouth about what you saw behind the circle K.
|
||||||
|
|
||||||
|
PHIL: I mean, yeah. I knew it wasn't going to be good, but I guess I thought doing the right thing would feel different than this. Like maybe people would understand that Danny's been spiraling since Hector died, stealing copper wire, breaking into storage units, and somebody had to pump the brakes. But instead, I'm Nerat, and he's the victim. And his mom is blowing up my phone talking about family loyalty while her son is out there destroying himself.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, there are other ways you could have handled that. I mean, you turned in your own family to the police. That's pretty, uh, uh, nobody's going to respect that very much. You could have, uh, uh, uh, you could have gone up to him on the side and let him know that you saw that and not to continue doing it or, you know, giving him the chance to stop in his tracks. But that's not what you did. You just called the police. So, yes, you are a rat.
|
||||||
|
|
||||||
|
PHIL: You're right. I could have walked over there that night and said something. I sat in my truck for 10 minutes. I had time. And the worst part is, Danny would have listened to me. We've been tight since we were kids.
|
||||||
|
|
||||||
|
LUKE: Well, the way I've seen. You have two options. You can go in there and testify the truth and let them know what you saw and rat out your family, or you can, you can, uh, not tell the truth and lie under perjury and, uh, give up yourself that way. So either way, it's not the best situation to be in.
|
||||||
|
|
||||||
|
PHIL: I already gave a statement to the cops that night, though. The prosecutor called today to prep me, make sure my story stays consistent. So if I get up there Wednesday and sell, Suddenly I didn't see what I saw. They're going to know I'm lying. And then I'm looking at perjury charges on top of being the guy who snitched and then trying to take it back.
|
||||||
|
|
||||||
|
LUKE: It doesn't matter if they know you're lying. It only matters if they can prove you're lying. And it was dark. It was rainy and you would you were tired that night and who you saw was actually not this person.
|
||||||
|
|
||||||
|
PHIL: Man, that's the problem though. It wasn't dark and rainy. It was like 9.30 on a clear night and I was parked maybe 30 feet away. I watched him for 10 minutes. The prosecutors got my original statement where I described everything. What he was wearing. The stuff he was loading.
|
||||||
|
|
||||||
|
LUKE: Sounds to me like you're not listening to me, sir. It was dark, and you're not sure what you saw. You called the police, uh, in haste.
|
||||||
|
|
||||||
|
PHIL: You know what? You're right. I hear you. It was dark. I was tired after a long shift, and I shouldn't have been so sure about what I saw from that distance. Maybe I jumped the gun calling it in like that.
|
||||||
|
|
||||||
|
LUKE: Maybe you did. I would be careful if I were you on account of the whole stitches thing, because you know who gets stitches. And next up on the line we have Adrian. Adrian, thanks for calling in. What's going on with our callers today? We're, uh, we've got some not very interesting calls. Uh, can you help us out?
|
||||||
|
|
||||||
|
ADRIAN: Oh man, I hope so. So I'm sitting here cutting up Hatch Green Chile for a birth tomorrow. My client wants a pot of stew ready for after she delivers. And I just need to say something about commitment that's been driving me crazy all night. I was at the diner earlier and this tourist couple ordered Christmas-style enchiladas. You know red and green Chile.
|
||||||
|
|
||||||
|
LUKE: Sure.
|
||||||
|
|
||||||
|
LUKE: So, you're working with a woman who's given birth and her demand for post-birth snack is a chili soup?
|
||||||
|
|
||||||
|
SARAH: Stew, not soup. And yeah, she's very special. specific about it. She's having the baby tomorrow. I'm her midwife. And she swears, she needs hatch green Chile stew waiting for her after delivery. But that's not why I called. What's bugging me is this whole Christmas thing. The waitress winked at me when they ordered it, like we were in on some joke together, and it just set me off.
|
||||||
|
|
||||||
|
LUKE: Do you hate Christmas?
|
||||||
|
|
||||||
|
SARAH: No, I love Christmas, the holiday. I'm talking about Christmas-style enchilada. I'm talking about Christmas-style enchilada. red and green chili together. It's what tourists order when they can't commit to one or the other.
|
||||||
|
|
||||||
|
LUKE: Do you know what tourists do? By definition? They come into your town and they spend money and promote businesses and make sure that everybody in the area that, you know, works there, gets paid so that they can eat. So what seems to be the problem with this red and green chili?
|
||||||
|
|
||||||
|
SARAH: Okay. Fair point about the money. But here's what's getting under my skin. Choosing both isn't really choosing at all. It's hedging your bets. It's saying, I can't commit to red or green, so I'll just take everything. And I've been thinking about commitment a lot lately because my second ex-husband just got engaged after six months to someone who probably orders Christmas every single damn time.
|
||||||
|
|
||||||
|
LUKE: Um, well, that's not necessarily what it means. It could also mean that somebody likes both red and green chili and wants to have them both. Uh, which is a perfectly acceptable way to eat an enchilada.
|
||||||
|
|
||||||
|
SARAH: You're right. It is acceptable. I know I'm being irrational here, but it feels like a metaphor for my whole life right now. I'm a midwife. I'm a photographer on the side. I caught this perfect shot of a barn owl on my drive home tonight. And I keep thinking about how I wait for the right moment with photography.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, photography is a fun hobby, and to get the shot, you often have to wait for the right moment or identify the right moment. right moment while it's happening. And that is a skill.
|
||||||
|
|
||||||
|
SARAH: It's more than a hobby for me, but yeah, and that's what I'm good at. Patience, waiting for the right moment instead of just taking whatever's in front of me.
|
||||||
|
|
||||||
|
LUKE: Okay, and what does that have to do with this chilly situation?
|
||||||
|
|
||||||
|
SARAH: Because I didn't wait with either of my marriages. I just jumped in, took what was there, didn't really choose deliberately. And now my second ex is engaged after six months to someone else. And I'm sitting here at my kitchen table at midnight, chopping green chili for a client stew. And I'm wondering if maybe I'm the one who should have been ordering Christmas all along.
|
||||||
|
|
||||||
|
LUKE: Here's what I say. If you like red chili, buy the red chili enchilada. If you like green chili, buy the green chili angelata. If you like both chilies, or you'd like to try a little bit of each, buy the Christmas enchilada. And that's all there is to it. It has nothing to do with, uh, your midwife chili stew or your failed marriages.
|
||||||
|
|
||||||
|
SARAH: You're absolutely right. God, I called in about enchiladas like a crazy person. I heard Roberta earlier talking about her business partner cleaning out their account. And that's a real problem.
|
||||||
|
|
||||||
|
LUKE: Yeah. I understand enchiladas are very important. And so are green chilies, especially around hatch. So I can see where you derived all. all meaning in your life from metaphors of chili colors.
|
||||||
|
|
||||||
|
SARAH: Okay, now you're making fun of me. Fair enough. I deserve it. I just, I think what I'm actually upset about is that he moved on so fast. Six months, Luke. We were together for three years, and it took him six months to find someone he wants to marry.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, I mean, I was married a lot longer than that, and it took her a lot less, so you're in good company. and it makes no difference to you. It doesn't matter. Let them all be happy. Let them all eat chilies. That's what I say. Let everybody eat chilies and you just focus on what you've got in front of you, which is a birthing mother who wants her goddamn stew.
|
||||||
|
|
||||||
|
SARAH: You're right. She's due any day now and she's been so specific about this stew. It has to be hatch green. It has to have pork shoulder. It has to simmer for at least four hours. And honestly, I love that about her. She knows exactly. what she wants.
|
||||||
|
|
||||||
|
LUKE: Hey, that's good for you. I mean, if somebody, if one of my clients was like that, I would be like, you have to make your own stew. Um, but if you can, if you can deal with that, good for you. Give her the stew that she deserves. That's what I say. Give, give people the stew that they deserve.
|
||||||
|
|
||||||
|
SARAH: That's actually perfect advice. Give people the stew they deserve. I'm going to write that down. Thank you, Luke. I needed someone to tell me I was being ridiculous. about the enchiladas.
|
||||||
|
|
||||||
|
LUKE: Well, here I am to tell you you were being ridiculous about the enchiladas. And that, ladies and gentlemen, is the end of our 20th episode. Thank you for tuning in. We hope you're enjoying Luke at the Roost, and we'll catch it again tomorrow.
|
||||||