Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b02616bc44 | |||
| 28af0723c7 | |||
| d78b1f7f4e | |||
| 9fd977ad9f | |||
| cb5665bca8 | |||
| 95c2d06435 | |||
| 75f15ba2d2 | |||
| 953c501f75 | |||
| cee78b5d88 | |||
| 4becfd2122 | |||
| 7b7f9b8208 | |||
| 7d88c76f90 | |||
| 356bf145b8 | |||
| de5577e582 | |||
| c789069f6f | |||
| bd6c8ccbab | |||
| f654a5cbb1 | |||
| 6447edd0ae | |||
| 79e6bc79be | |||
| 9452b07c5c | |||
| aa3899b1fc | |||
| 73129374f4 | |||
| 164f8fbc6e | |||
| e45ba2617a | |||
| c03f46ea96 | |||
| 69b7078142 | |||
| b3fb3b1127 | |||
| cac80a4b52 | |||
| e30d4c8856 | |||
| eafcf27beb | |||
| d5fd89fc9a | |||
| 0a614eba6d | |||
| e979c4151d | |||
| e6b9401848 | |||
| d14000887c | |||
| 7adf1bbcad | |||
| a94fc92647 | |||
| b0643d6082 |
@@ -42,3 +42,24 @@ Required in `.env`:
|
|||||||
- OPENROUTER_API_KEY
|
- OPENROUTER_API_KEY
|
||||||
- ELEVENLABS_API_KEY (optional)
|
- ELEVENLABS_API_KEY (optional)
|
||||||
- INWORLD_API_KEY (for Inworld TTS)
|
- INWORLD_API_KEY (for Inworld TTS)
|
||||||
|
|
||||||
|
## Post-Production Pipeline (added Feb 2026)
|
||||||
|
- **Branch**: `feature/real-callers` — all current work is here, pushed to gitea
|
||||||
|
- **Stem Recorder** (`backend/services/stem_recorder.py`): Records 5 WAV stems (host, caller, music, sfx, ads) during live shows. Uses lock-free deque architecture — audio callbacks just append to deques, a background writer thread drains to disk. `write()` for continuous streams (host mic, music, ads), `write_sporadic()` for burst sources (caller TTS, SFX) with time-aligned silence padding.
|
||||||
|
- **Audio hooks** in `backend/services/audio.py`: 7 tap points guarded by `if self.stem_recorder:`. Persistent mic stream (`start_stem_mic`/`stop_stem_mic`) runs during recording to capture host voice continuously, not just during push-to-talk.
|
||||||
|
- **API endpoints**: `POST /api/recording/start`, `POST /api/recording/stop` (auto-runs postprod in background thread), `POST /api/recording/process`
|
||||||
|
- **Frontend**: REC button in header with red pulse animation when recording
|
||||||
|
- **Post-prod script** (`postprod.py`): 6-step pipeline — load stems → gap removal → voice compression (ffmpeg acompressor) → music ducking → stereo mix → EBU R128 loudness normalization to -16 LUFS. All steps skippable via CLI flags.
|
||||||
|
- **Known issues resolved**: Lock-free recorder (old version used threading.Lock in audio callbacks causing crashes), scipy.signal.resample replaced with nearest-neighbor (was producing artifacts on small chunks), sys import bug in auto-postprod, host mic not captured without persistent stream
|
||||||
|
|
||||||
|
## LLM Settings
|
||||||
|
- `_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)
|
||||||
|
- Grok (`x-ai/grok-4-fast`) works well for natural dialog; MiniMax tends toward terse responses
|
||||||
|
|
||||||
|
## Website
|
||||||
|
- **Domain**: lukeattheroost.com (behind Cloudflare)
|
||||||
|
- **Analytics**: Cloudflare Web Analytics (enable in Cloudflare dashboard, no code changes needed)
|
||||||
|
|
||||||
|
## Episodes Published
|
||||||
|
- Episode 6 published 2026-02-08 (podcast6.mp3, ~31 min)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"input_device": 13,
|
"input_device": 13,
|
||||||
"input_channel": 1,
|
"input_channel": 1,
|
||||||
"output_device": 13,
|
"output_device": 12,
|
||||||
"caller_channel": 3,
|
"caller_channel": 3,
|
||||||
|
"live_caller_channel": 9,
|
||||||
"music_channel": 5,
|
"music_channel": 5,
|
||||||
"sfx_channel": 7,
|
"sfx_channel": 7,
|
||||||
|
"ad_channel": 11,
|
||||||
"phone_filter": false
|
"phone_filter": false
|
||||||
}
|
}
|
||||||
@@ -20,15 +20,16 @@ class Settings(BaseSettings):
|
|||||||
signalwire_space: str = os.getenv("SIGNALWIRE_SPACE", "")
|
signalwire_space: str = os.getenv("SIGNALWIRE_SPACE", "")
|
||||||
signalwire_token: str = os.getenv("SIGNALWIRE_TOKEN", "")
|
signalwire_token: str = os.getenv("SIGNALWIRE_TOKEN", "")
|
||||||
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
|
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
|
||||||
|
signalwire_stream_url: str = os.getenv("SIGNALWIRE_STREAM_URL", "")
|
||||||
|
|
||||||
# LLM Settings
|
# LLM Settings
|
||||||
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
||||||
openrouter_model: str = "anthropic/claude-3-haiku"
|
openrouter_model: str = "anthropic/claude-sonnet-4-5"
|
||||||
ollama_model: str = "llama3.2"
|
ollama_model: str = "llama3.2"
|
||||||
ollama_host: str = "http://localhost:11434"
|
ollama_host: str = "http://localhost:11434"
|
||||||
|
|
||||||
# TTS Settings
|
# TTS Settings
|
||||||
tts_provider: str = "kokoro" # "kokoro", "elevenlabs", "vits", or "bark"
|
tts_provider: str = "inworld" # "kokoro", "elevenlabs", "inworld", "vits", or "bark"
|
||||||
|
|
||||||
# Audio Settings
|
# Audio Settings
|
||||||
sample_rate: int = 24000
|
sample_rate: int = 24000
|
||||||
@@ -37,6 +38,7 @@ class Settings(BaseSettings):
|
|||||||
base_dir: Path = Path(__file__).parent.parent
|
base_dir: Path = Path(__file__).parent.parent
|
||||||
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"
|
||||||
sessions_dir: Path = base_dir / "sessions"
|
sessions_dir: Path = base_dir / "sessions"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@@ -27,8 +27,16 @@ class AudioService:
|
|||||||
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 = 2 # 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.phone_filter: bool = False # Phone filter on caller voices
|
self.phone_filter: bool = False # Phone filter on caller voices
|
||||||
|
|
||||||
|
# Ad playback state
|
||||||
|
self._ad_stream: Optional[sd.OutputStream] = None
|
||||||
|
self._ad_data: Optional[np.ndarray] = None
|
||||||
|
self._ad_resampled: Optional[np.ndarray] = None
|
||||||
|
self._ad_position: int = 0
|
||||||
|
self._ad_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
|
||||||
@@ -45,6 +53,14 @@ class AudioService:
|
|||||||
self._music_volume: float = 0.3
|
self._music_volume: float = 0.3
|
||||||
self._music_loop: bool = True
|
self._music_loop: bool = True
|
||||||
|
|
||||||
|
# Music crossfade state
|
||||||
|
self._crossfade_active: bool = False
|
||||||
|
self._crossfade_old_data: Optional[np.ndarray] = None
|
||||||
|
self._crossfade_old_position: int = 0
|
||||||
|
self._crossfade_progress: float = 0.0
|
||||||
|
self._crossfade_samples: int = 0
|
||||||
|
self._crossfade_step: float = 0.0
|
||||||
|
|
||||||
# Caller playback state
|
# Caller playback state
|
||||||
self._caller_stop_event = threading.Event()
|
self._caller_stop_event = threading.Event()
|
||||||
self._caller_thread: Optional[threading.Thread] = None
|
self._caller_thread: Optional[threading.Thread] = None
|
||||||
@@ -62,6 +78,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
|
||||||
|
|
||||||
|
# Stem recording (opt-in, attached via API)
|
||||||
|
self.stem_recorder = None
|
||||||
|
self._stem_mic_stream: Optional[sd.InputStream] = None
|
||||||
|
|
||||||
# Load saved settings
|
# Load saved settings
|
||||||
self._load_settings()
|
self._load_settings()
|
||||||
|
|
||||||
@@ -78,8 +98,9 @@ class AudioService:
|
|||||||
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.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}, phone_filter={self.phone_filter}")
|
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}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to load audio settings: {e}")
|
print(f"Failed to load audio settings: {e}")
|
||||||
|
|
||||||
@@ -94,6 +115,7 @@ class AudioService:
|
|||||||
"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,
|
||||||
"phone_filter": self.phone_filter,
|
"phone_filter": self.phone_filter,
|
||||||
}
|
}
|
||||||
with open(SETTINGS_FILE, "w") as f:
|
with open(SETTINGS_FILE, "w") as f:
|
||||||
@@ -125,6 +147,7 @@ class AudioService:
|
|||||||
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,
|
||||||
phone_filter: Optional[bool] = None
|
phone_filter: Optional[bool] = None
|
||||||
):
|
):
|
||||||
"""Configure audio devices and channels"""
|
"""Configure audio devices and channels"""
|
||||||
@@ -142,6 +165,8 @@ class AudioService:
|
|||||||
self.music_channel = music_channel
|
self.music_channel = music_channel
|
||||||
if sfx_channel is not None:
|
if sfx_channel is not None:
|
||||||
self.sfx_channel = sfx_channel
|
self.sfx_channel = sfx_channel
|
||||||
|
if ad_channel is not None:
|
||||||
|
self.ad_channel = ad_channel
|
||||||
if phone_filter is not None:
|
if phone_filter is not None:
|
||||||
self.phone_filter = phone_filter
|
self.phone_filter = phone_filter
|
||||||
|
|
||||||
@@ -158,6 +183,7 @@ class AudioService:
|
|||||||
"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,
|
||||||
"phone_filter": self.phone_filter,
|
"phone_filter": self.phone_filter,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +228,17 @@ class AudioService:
|
|||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
if not self._recorded_audio:
|
if not self._recorded_audio:
|
||||||
print(f"Recording stopped: NO audio chunks captured (piggyback={self._host_stream is not None})")
|
piggyback = self._host_stream is not None
|
||||||
|
# Check what other streams might be active
|
||||||
|
active_streams = []
|
||||||
|
if self._music_stream:
|
||||||
|
active_streams.append("music")
|
||||||
|
if self._live_caller_stream:
|
||||||
|
active_streams.append("live_caller")
|
||||||
|
if self._host_stream:
|
||||||
|
active_streams.append("host")
|
||||||
|
streams_info = f", active_streams=[{','.join(active_streams)}]" if active_streams else ""
|
||||||
|
print(f"Recording stopped: NO audio chunks captured (piggyback={piggyback}, device={self.input_device}, ch={self.input_channel}{streams_info})")
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
# Combine all chunks
|
# Combine all chunks
|
||||||
@@ -228,16 +264,29 @@ 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
|
||||||
|
|
||||||
|
if max_channels == 0:
|
||||||
|
print(f"Recording error: device {self.input_device} has no input channels")
|
||||||
|
self._recording = False
|
||||||
|
return
|
||||||
|
|
||||||
# Store device sample rate for later resampling
|
# Store device sample rate for later resampling
|
||||||
self._record_device_sr = device_sr
|
self._record_device_sr = device_sr
|
||||||
|
|
||||||
print(f"Recording from device {self.input_device} ch {self.input_channel} @ {device_sr}Hz")
|
stream_ready = threading.Event()
|
||||||
|
callback_count = [0]
|
||||||
|
|
||||||
def callback(indata, frames, time_info, status):
|
def callback(indata, frames, time_info, status):
|
||||||
if status:
|
if status:
|
||||||
print(f"Record status: {status}")
|
print(f"Record status: {status}")
|
||||||
|
callback_count[0] += 1
|
||||||
|
if not stream_ready.is_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:
|
||||||
|
self.stem_recorder.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)")
|
||||||
|
|
||||||
with sd.InputStream(
|
with sd.InputStream(
|
||||||
device=self.input_device,
|
device=self.input_device,
|
||||||
@@ -247,11 +296,19 @@ class AudioService:
|
|||||||
callback=callback,
|
callback=callback,
|
||||||
blocksize=1024
|
blocksize=1024
|
||||||
):
|
):
|
||||||
|
# Wait for stream to actually start capturing
|
||||||
|
if not stream_ready.wait(timeout=1.0):
|
||||||
|
print(f"Recording WARNING: stream opened but callback not firing after 1s")
|
||||||
|
|
||||||
while self._recording:
|
while self._recording:
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
print(f"Recording: stream closed, {callback_count[0]} callbacks fired, {len(self._recorded_audio)} chunks captured")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Recording error: {e}")
|
print(f"Recording error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
self._recording = False
|
self._recording = False
|
||||||
|
|
||||||
# --- Caller TTS Playback ---
|
# --- Caller TTS Playback ---
|
||||||
@@ -323,6 +380,9 @@ class AudioService:
|
|||||||
while pos < len(multi_ch) and not self._caller_stop_event.is_set():
|
while pos < len(multi_ch) and not self._caller_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
|
||||||
|
if self.stem_recorder:
|
||||||
|
self.stem_recorder.write_sporadic("caller", audio[pos:end].copy(), device_sr)
|
||||||
pos = end
|
pos = end
|
||||||
|
|
||||||
if self._caller_stop_event.is_set():
|
if self._caller_stop_event.is_set():
|
||||||
@@ -440,6 +500,10 @@ class AudioService:
|
|||||||
indices = np.clip(indices, 0, len(audio) - 1)
|
indices = np.clip(indices, 0, len(audio) - 1)
|
||||||
audio = audio[indices]
|
audio = audio[indices]
|
||||||
|
|
||||||
|
# Stem recording: live caller
|
||||||
|
if self.stem_recorder:
|
||||||
|
self.stem_recorder.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)
|
||||||
|
|
||||||
@@ -450,10 +514,20 @@ class AudioService:
|
|||||||
|
|
||||||
def start_host_stream(self, send_callback: Callable):
|
def start_host_stream(self, send_callback: Callable):
|
||||||
"""Start continuous host mic capture for streaming to real callers"""
|
"""Start continuous host mic capture for streaming to real callers"""
|
||||||
|
if self._host_stream is not None:
|
||||||
|
self._host_send_callback = send_callback
|
||||||
|
return
|
||||||
if self.input_device is None:
|
if self.input_device is None:
|
||||||
print("[Audio] No input device configured for host streaming")
|
print("[Audio] No input device configured for host streaming")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Close stem_mic if active — this stream's callback handles stem recording too
|
||||||
|
if self._stem_mic_stream is not None:
|
||||||
|
self._stem_mic_stream.stop()
|
||||||
|
self._stem_mic_stream.close()
|
||||||
|
self._stem_mic_stream = None
|
||||||
|
print("[Audio] Closed stem_mic (host stream takes over)")
|
||||||
|
|
||||||
self._host_send_callback = send_callback
|
self._host_send_callback = send_callback
|
||||||
|
|
||||||
def _start():
|
def _start():
|
||||||
@@ -463,16 +537,20 @@ class AudioService:
|
|||||||
record_channel = min(self.input_channel, max_channels) - 1
|
record_channel = min(self.input_channel, max_channels) - 1
|
||||||
step = max(1, int(device_sr / 16000))
|
step = max(1, int(device_sr / 16000))
|
||||||
|
|
||||||
# Buffer host mic to send ~60ms chunks instead of tiny 21ms ones
|
# Buffer host mic to send ~100ms chunks (reduces WebSocket frame rate)
|
||||||
host_accum = []
|
host_accum = []
|
||||||
host_accum_samples = [0]
|
host_accum_samples = [0]
|
||||||
send_threshold = 960 # 60ms at 16kHz
|
send_threshold = 1600 # 100ms at 16kHz
|
||||||
|
|
||||||
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
|
||||||
|
if self.stem_recorder:
|
||||||
|
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
|
||||||
|
|
||||||
if not self._host_send_callback:
|
if not self._host_send_callback:
|
||||||
return
|
return
|
||||||
mono = indata[:, record_channel]
|
mono = indata[:, record_channel]
|
||||||
@@ -535,6 +613,55 @@ class AudioService:
|
|||||||
print(f"Failed to load music: {e}")
|
print(f"Failed to load music: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def crossfade_to(self, file_path: str, duration: float = 3.0):
|
||||||
|
"""Crossfade from current music track to a new one"""
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
if not self._music_playing or self._music_resampled is None:
|
||||||
|
if self.load_music(file_path):
|
||||||
|
self.play_music()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load the new track
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
print(f"Music file not found: {file_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True)
|
||||||
|
new_data = audio.astype(np.float32)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load music for crossfade: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get device sample rate for resampling
|
||||||
|
if self.output_device is not None:
|
||||||
|
device_info = sd.query_devices(self.output_device)
|
||||||
|
device_sr = int(device_info['default_samplerate'])
|
||||||
|
else:
|
||||||
|
device_sr = self.output_sample_rate
|
||||||
|
|
||||||
|
if self.output_sample_rate != device_sr:
|
||||||
|
new_resampled = librosa.resample(new_data, orig_sr=self.output_sample_rate, target_sr=device_sr)
|
||||||
|
else:
|
||||||
|
new_resampled = new_data.copy()
|
||||||
|
|
||||||
|
# Swap: current becomes old, new becomes current
|
||||||
|
self._crossfade_old_data = self._music_resampled
|
||||||
|
self._crossfade_old_position = self._music_position
|
||||||
|
self._music_resampled = new_resampled
|
||||||
|
self._music_data = new_data
|
||||||
|
self._music_position = 0
|
||||||
|
|
||||||
|
# Configure crossfade timing
|
||||||
|
self._crossfade_samples = int(device_sr * duration)
|
||||||
|
self._crossfade_progress = 0.0
|
||||||
|
self._crossfade_step = 1.0 / self._crossfade_samples if self._crossfade_samples > 0 else 1.0
|
||||||
|
self._crossfade_active = True
|
||||||
|
|
||||||
|
print(f"Crossfading to {path.name} over {duration}s")
|
||||||
|
|
||||||
def play_music(self):
|
def play_music(self):
|
||||||
"""Start music playback to specific channel"""
|
"""Start music playback to specific channel"""
|
||||||
import librosa
|
import librosa
|
||||||
@@ -582,25 +709,61 @@ class AudioService:
|
|||||||
if not self._music_playing or self._music_resampled is None:
|
if not self._music_playing or self._music_resampled is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Read new track samples
|
||||||
end_pos = self._music_position + frames
|
end_pos = self._music_position + frames
|
||||||
|
|
||||||
if end_pos <= len(self._music_resampled):
|
if end_pos <= len(self._music_resampled):
|
||||||
outdata[:, channel_idx] = self._music_resampled[self._music_position:end_pos] * self._music_volume
|
new_samples = self._music_resampled[self._music_position:end_pos].copy()
|
||||||
self._music_position = end_pos
|
self._music_position = end_pos
|
||||||
else:
|
else:
|
||||||
remaining = len(self._music_resampled) - self._music_position
|
remaining = len(self._music_resampled) - self._music_position
|
||||||
|
new_samples = np.zeros(frames, dtype=np.float32)
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
outdata[:remaining, channel_idx] = self._music_resampled[self._music_position:] * self._music_volume
|
new_samples[:remaining] = self._music_resampled[self._music_position:]
|
||||||
|
|
||||||
if self._music_loop:
|
if self._music_loop:
|
||||||
self._music_position = 0
|
|
||||||
wrap_frames = frames - remaining
|
wrap_frames = frames - remaining
|
||||||
if wrap_frames > 0:
|
if wrap_frames > 0:
|
||||||
outdata[remaining:, channel_idx] = self._music_resampled[:wrap_frames] * self._music_volume
|
new_samples[remaining:] = self._music_resampled[:wrap_frames]
|
||||||
self._music_position = wrap_frames
|
self._music_position = wrap_frames
|
||||||
else:
|
else:
|
||||||
|
self._music_position = len(self._music_resampled)
|
||||||
|
if remaining <= 0:
|
||||||
self._music_playing = False
|
self._music_playing = False
|
||||||
|
|
||||||
|
if self._crossfade_active and self._crossfade_old_data is not None:
|
||||||
|
# Read old track samples
|
||||||
|
old_end = self._crossfade_old_position + frames
|
||||||
|
if old_end <= len(self._crossfade_old_data):
|
||||||
|
old_samples = self._crossfade_old_data[self._crossfade_old_position:old_end]
|
||||||
|
self._crossfade_old_position = old_end
|
||||||
|
else:
|
||||||
|
old_remaining = len(self._crossfade_old_data) - self._crossfade_old_position
|
||||||
|
old_samples = np.zeros(frames, dtype=np.float32)
|
||||||
|
if old_remaining > 0:
|
||||||
|
old_samples[:old_remaining] = self._crossfade_old_data[self._crossfade_old_position:]
|
||||||
|
self._crossfade_old_position = len(self._crossfade_old_data)
|
||||||
|
|
||||||
|
# Compute fade curves for this chunk
|
||||||
|
start_progress = self._crossfade_progress
|
||||||
|
end_progress = min(1.0, start_progress + self._crossfade_step * frames)
|
||||||
|
fade_in = np.linspace(start_progress, end_progress, frames, dtype=np.float32)
|
||||||
|
fade_out = 1.0 - fade_in
|
||||||
|
|
||||||
|
mono_out = (old_samples * fade_out + new_samples * fade_in) * self._music_volume
|
||||||
|
outdata[:, channel_idx] = mono_out
|
||||||
|
if self.stem_recorder:
|
||||||
|
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
|
||||||
|
self._crossfade_progress = end_progress
|
||||||
|
|
||||||
|
if self._crossfade_progress >= 1.0:
|
||||||
|
self._crossfade_active = False
|
||||||
|
self._crossfade_old_data = None
|
||||||
|
print("Crossfade complete")
|
||||||
|
else:
|
||||||
|
mono_out = new_samples * self._music_volume
|
||||||
|
outdata[:, channel_idx] = mono_out
|
||||||
|
if self.stem_recorder:
|
||||||
|
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._music_stream = sd.OutputStream(
|
self._music_stream = sd.OutputStream(
|
||||||
device=device,
|
device=device,
|
||||||
@@ -616,15 +779,130 @@ class AudioService:
|
|||||||
print(f"Music playback error: {e}")
|
print(f"Music playback error: {e}")
|
||||||
self._music_playing = False
|
self._music_playing = False
|
||||||
|
|
||||||
def stop_music(self):
|
def stop_music(self, fade_duration: float = 2.0):
|
||||||
"""Stop music playback"""
|
"""Stop music playback with fade out"""
|
||||||
|
if not self._music_playing or not self._music_stream:
|
||||||
self._music_playing = False
|
self._music_playing = False
|
||||||
if self._music_stream:
|
if self._music_stream:
|
||||||
self._music_stream.stop()
|
self._music_stream.stop()
|
||||||
self._music_stream.close()
|
self._music_stream.close()
|
||||||
self._music_stream = None
|
self._music_stream = None
|
||||||
self._music_position = 0
|
self._music_position = 0
|
||||||
|
return
|
||||||
|
|
||||||
|
if fade_duration <= 0:
|
||||||
|
self._music_playing = False
|
||||||
|
self._music_stream.stop()
|
||||||
|
self._music_stream.close()
|
||||||
|
self._music_stream = None
|
||||||
|
self._music_position = 0
|
||||||
print("Music stopped")
|
print("Music stopped")
|
||||||
|
return
|
||||||
|
|
||||||
|
import threading
|
||||||
|
original_volume = self._music_volume
|
||||||
|
steps = 20
|
||||||
|
step_time = fade_duration / steps
|
||||||
|
|
||||||
|
def _fade():
|
||||||
|
for i in range(steps):
|
||||||
|
if not self._music_playing:
|
||||||
|
break
|
||||||
|
self._music_volume = original_volume * (1 - (i + 1) / steps)
|
||||||
|
import time
|
||||||
|
time.sleep(step_time)
|
||||||
|
self._music_playing = False
|
||||||
|
if self._music_stream:
|
||||||
|
self._music_stream.stop()
|
||||||
|
self._music_stream.close()
|
||||||
|
self._music_stream = None
|
||||||
|
self._music_position = 0
|
||||||
|
self._music_volume = original_volume
|
||||||
|
print("Music faded out and stopped")
|
||||||
|
|
||||||
|
threading.Thread(target=_fade, daemon=True).start()
|
||||||
|
|
||||||
|
def play_ad(self, file_path: str):
|
||||||
|
"""Load and play an ad file once (no loop) on the ad channel"""
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
print(f"Ad file not found: {file_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stop_ad()
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True)
|
||||||
|
self._ad_data = audio.astype(np.float32)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load ad: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._ad_playing = True
|
||||||
|
self._ad_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._ad_resampled = librosa.resample(
|
||||||
|
self._ad_data, orig_sr=self.output_sample_rate, target_sr=device_sr
|
||||||
|
).astype(np.float32)
|
||||||
|
else:
|
||||||
|
self._ad_resampled = self._ad_data
|
||||||
|
|
||||||
|
def callback(outdata, frames, time_info, status):
|
||||||
|
outdata[:] = 0
|
||||||
|
if not self._ad_playing or self._ad_resampled is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
remaining = len(self._ad_resampled) - self._ad_position
|
||||||
|
if remaining >= frames:
|
||||||
|
chunk = self._ad_resampled[self._ad_position:self._ad_position + frames]
|
||||||
|
outdata[:, channel_idx] = chunk
|
||||||
|
if self.stem_recorder:
|
||||||
|
self.stem_recorder.write_sporadic("ads", chunk.copy(), device_sr)
|
||||||
|
self._ad_position += frames
|
||||||
|
else:
|
||||||
|
if remaining > 0:
|
||||||
|
outdata[:remaining, channel_idx] = self._ad_resampled[self._ad_position:]
|
||||||
|
# Ad finished — no loop
|
||||||
|
self._ad_playing = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ad_stream = sd.OutputStream(
|
||||||
|
device=device,
|
||||||
|
channels=num_channels,
|
||||||
|
samplerate=device_sr,
|
||||||
|
dtype=np.float32,
|
||||||
|
callback=callback,
|
||||||
|
blocksize=2048
|
||||||
|
)
|
||||||
|
self._ad_stream.start()
|
||||||
|
print(f"Ad playback started on ch {self.ad_channel} @ {device_sr}Hz")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ad playback error: {e}")
|
||||||
|
self._ad_playing = False
|
||||||
|
|
||||||
|
def stop_ad(self):
|
||||||
|
"""Stop ad playback"""
|
||||||
|
self._ad_playing = False
|
||||||
|
if self._ad_stream:
|
||||||
|
self._ad_stream.stop()
|
||||||
|
self._ad_stream.close()
|
||||||
|
self._ad_stream = None
|
||||||
|
self._ad_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)"""
|
||||||
@@ -662,6 +940,10 @@ class AudioService:
|
|||||||
audio, _ = librosa.load(str(path), sr=device_sr, mono=True)
|
audio, _ = librosa.load(str(path), sr=device_sr, mono=True)
|
||||||
audio = self._apply_fade(audio, device_sr)
|
audio = self._apply_fade(audio, device_sr)
|
||||||
|
|
||||||
|
# Stem recording: sfx
|
||||||
|
if self.stem_recorder:
|
||||||
|
self.stem_recorder.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
|
||||||
|
|
||||||
@@ -680,6 +962,48 @@ class AudioService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"SFX playback error: {e}")
|
print(f"SFX playback error: {e}")
|
||||||
|
|
||||||
|
# --- Stem Mic Capture ---
|
||||||
|
|
||||||
|
def start_stem_mic(self):
|
||||||
|
"""Start a persistent mic capture stream for stem recording.
|
||||||
|
Skips if _host_stream is already active (it writes to the host stem too)."""
|
||||||
|
if self._stem_mic_stream is not None:
|
||||||
|
return
|
||||||
|
if self._host_stream is not None:
|
||||||
|
print("[StemRecorder] Host stream already capturing mic, skipping stem_mic")
|
||||||
|
return
|
||||||
|
if self.input_device is None:
|
||||||
|
print("[StemRecorder] No input device configured, skipping host mic capture")
|
||||||
|
return
|
||||||
|
|
||||||
|
device_info = sd.query_devices(self.input_device)
|
||||||
|
max_channels = device_info['max_input_channels']
|
||||||
|
device_sr = int(device_info['default_samplerate'])
|
||||||
|
record_channel = min(self.input_channel, max_channels) - 1
|
||||||
|
|
||||||
|
def callback(indata, frames, time_info, status):
|
||||||
|
if self.stem_recorder:
|
||||||
|
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
|
||||||
|
|
||||||
|
self._stem_mic_stream = sd.InputStream(
|
||||||
|
device=self.input_device,
|
||||||
|
channels=max_channels,
|
||||||
|
samplerate=device_sr,
|
||||||
|
dtype=np.float32,
|
||||||
|
blocksize=1024,
|
||||||
|
callback=callback,
|
||||||
|
)
|
||||||
|
self._stem_mic_stream.start()
|
||||||
|
print(f"[StemRecorder] Host mic capture started (device {self.input_device} ch {self.input_channel} @ {device_sr}Hz)")
|
||||||
|
|
||||||
|
def stop_stem_mic(self):
|
||||||
|
"""Stop the persistent stem mic capture."""
|
||||||
|
if self._stem_mic_stream:
|
||||||
|
self._stem_mic_stream.stop()
|
||||||
|
self._stem_mic_stream.close()
|
||||||
|
self._stem_mic_stream = None
|
||||||
|
print("[StemRecorder] Host mic capture stopped")
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
audio_service = AudioService()
|
audio_service = AudioService()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Phone caller queue and audio stream service"""
|
"""Phone caller queue and audio stream service"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -20,7 +22,21 @@ class CallerService:
|
|||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._websockets: dict[str, any] = {} # caller_id -> WebSocket
|
self._websockets: dict[str, any] = {} # caller_id -> WebSocket
|
||||||
self._call_sids: dict[str, str] = {} # caller_id -> SignalWire callSid
|
self._call_sids: dict[str, str] = {} # caller_id -> SignalWire callSid
|
||||||
self.streaming_tts: bool = False # True while TTS audio is being streamed
|
self._stream_sids: dict[str, str] = {} # caller_id -> SignalWire streamSid
|
||||||
|
self._send_locks: dict[str, asyncio.Lock] = {} # per-caller send lock
|
||||||
|
self._streaming_tts: set[str] = set() # caller_ids currently receiving TTS
|
||||||
|
self._screening_state: dict[str, dict] = {} # caller_id -> screening conversation
|
||||||
|
|
||||||
|
def _get_send_lock(self, caller_id: str) -> asyncio.Lock:
|
||||||
|
if caller_id not in self._send_locks:
|
||||||
|
self._send_locks[caller_id] = asyncio.Lock()
|
||||||
|
return self._send_locks[caller_id]
|
||||||
|
|
||||||
|
def is_streaming_tts(self, caller_id: str) -> bool:
|
||||||
|
return caller_id in self._streaming_tts
|
||||||
|
|
||||||
|
def is_streaming_tts_any(self) -> bool:
|
||||||
|
return len(self._streaming_tts) > 0
|
||||||
|
|
||||||
def add_to_queue(self, caller_id: str, phone: str):
|
def add_to_queue(self, caller_id: str, phone: str):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -36,18 +52,6 @@ class CallerService:
|
|||||||
self._queue = [c for c in self._queue if c["caller_id"] != caller_id]
|
self._queue = [c for c in self._queue if c["caller_id"] != caller_id]
|
||||||
print(f"[Caller] {caller_id} removed from queue")
|
print(f"[Caller] {caller_id} removed from queue")
|
||||||
|
|
||||||
def get_queue(self) -> list[dict]:
|
|
||||||
now = time.time()
|
|
||||||
with self._lock:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"caller_id": c["caller_id"],
|
|
||||||
"phone": c["phone"],
|
|
||||||
"wait_time": int(now - c["queued_at"]),
|
|
||||||
}
|
|
||||||
for c in self._queue
|
|
||||||
]
|
|
||||||
|
|
||||||
def allocate_channel(self) -> int:
|
def allocate_channel(self) -> int:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
ch = self.FIRST_REAL_CHANNEL
|
ch = self.FIRST_REAL_CHANNEL
|
||||||
@@ -94,6 +98,9 @@ class CallerService:
|
|||||||
print(f"[Caller] {call_info['phone']} hung up — channel {call_info['channel']} released")
|
print(f"[Caller] {call_info['phone']} hung up — channel {call_info['channel']} released")
|
||||||
self._websockets.pop(caller_id, None)
|
self._websockets.pop(caller_id, None)
|
||||||
self._call_sids.pop(caller_id, None)
|
self._call_sids.pop(caller_id, None)
|
||||||
|
self._stream_sids.pop(caller_id, None)
|
||||||
|
self._send_locks.pop(caller_id, None)
|
||||||
|
self._screening_state.pop(caller_id, None)
|
||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -105,8 +112,75 @@ class CallerService:
|
|||||||
self._caller_counter = 0
|
self._caller_counter = 0
|
||||||
self._websockets.clear()
|
self._websockets.clear()
|
||||||
self._call_sids.clear()
|
self._call_sids.clear()
|
||||||
|
self._stream_sids.clear()
|
||||||
|
self._send_locks.clear()
|
||||||
|
self._streaming_tts.clear()
|
||||||
|
self._screening_state.clear()
|
||||||
print("[Caller] Service reset")
|
print("[Caller] Service reset")
|
||||||
|
|
||||||
|
# --- Screening ---
|
||||||
|
|
||||||
|
def start_screening(self, caller_id: str):
|
||||||
|
"""Initialize screening state for a queued caller"""
|
||||||
|
self._screening_state[caller_id] = {
|
||||||
|
"conversation": [],
|
||||||
|
"caller_name": None,
|
||||||
|
"topic": None,
|
||||||
|
"status": "screening", # screening, complete
|
||||||
|
"response_count": 0,
|
||||||
|
}
|
||||||
|
print(f"[Screening] Started for {caller_id}")
|
||||||
|
|
||||||
|
def get_screening_state(self, caller_id: str) -> Optional[dict]:
|
||||||
|
return self._screening_state.get(caller_id)
|
||||||
|
|
||||||
|
def update_screening(self, caller_id: str, caller_text: str = None,
|
||||||
|
screener_text: str = None, caller_name: str = None,
|
||||||
|
topic: str = None):
|
||||||
|
"""Update screening conversation and extracted info"""
|
||||||
|
state = self._screening_state.get(caller_id)
|
||||||
|
if not state:
|
||||||
|
return
|
||||||
|
if caller_text:
|
||||||
|
state["conversation"].append({"role": "caller", "content": caller_text})
|
||||||
|
state["response_count"] += 1
|
||||||
|
if screener_text:
|
||||||
|
state["conversation"].append({"role": "screener", "content": screener_text})
|
||||||
|
if caller_name:
|
||||||
|
state["caller_name"] = caller_name
|
||||||
|
if topic:
|
||||||
|
state["topic"] = topic
|
||||||
|
|
||||||
|
def end_screening(self, caller_id: str):
|
||||||
|
"""Mark screening as complete"""
|
||||||
|
state = self._screening_state.get(caller_id)
|
||||||
|
if state:
|
||||||
|
state["status"] = "complete"
|
||||||
|
print(f"[Screening] Complete for {caller_id}: name={state.get('caller_name')}, topic={state.get('topic')}")
|
||||||
|
|
||||||
|
def get_queue(self) -> list[dict]:
|
||||||
|
"""Get queue with screening info enrichment"""
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
result = []
|
||||||
|
for c in self._queue:
|
||||||
|
entry = {
|
||||||
|
"caller_id": c["caller_id"],
|
||||||
|
"phone": c["phone"],
|
||||||
|
"wait_time": int(now - c["queued_at"]),
|
||||||
|
}
|
||||||
|
screening = self._screening_state.get(c["caller_id"])
|
||||||
|
if screening:
|
||||||
|
entry["screening_status"] = screening["status"]
|
||||||
|
entry["caller_name"] = screening.get("caller_name")
|
||||||
|
entry["screening_summary"] = screening.get("topic")
|
||||||
|
else:
|
||||||
|
entry["screening_status"] = None
|
||||||
|
entry["caller_name"] = None
|
||||||
|
entry["screening_summary"] = None
|
||||||
|
result.append(entry)
|
||||||
|
return result
|
||||||
|
|
||||||
def register_websocket(self, caller_id: str, websocket):
|
def register_websocket(self, caller_id: str, websocket):
|
||||||
"""Register a WebSocket for a caller"""
|
"""Register a WebSocket for a caller"""
|
||||||
self._websockets[caller_id] = websocket
|
self._websockets[caller_id] = websocket
|
||||||
@@ -119,12 +193,16 @@ class CallerService:
|
|||||||
"""Send small audio chunk to caller via SignalWire WebSocket.
|
"""Send small audio chunk to caller via SignalWire WebSocket.
|
||||||
Encodes L16 PCM as base64 JSON per SignalWire protocol.
|
Encodes L16 PCM as base64 JSON per SignalWire protocol.
|
||||||
"""
|
"""
|
||||||
|
if caller_id in self._streaming_tts:
|
||||||
|
return # Don't send host audio during TTS streaming
|
||||||
|
|
||||||
ws = self._websockets.get(caller_id)
|
ws = self._websockets.get(caller_id)
|
||||||
if not ws:
|
if not ws:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
lock = self._get_send_lock(caller_id)
|
||||||
|
async with lock:
|
||||||
try:
|
try:
|
||||||
import base64
|
|
||||||
if sample_rate != 16000:
|
if sample_rate != 16000:
|
||||||
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
|
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
ratio = 16000 / sample_rate
|
ratio = 16000 / sample_rate
|
||||||
@@ -135,9 +213,10 @@ class CallerService:
|
|||||||
pcm_data = (audio * 32767).astype(np.int16).tobytes()
|
pcm_data = (audio * 32767).astype(np.int16).tobytes()
|
||||||
|
|
||||||
payload = base64.b64encode(pcm_data).decode('ascii')
|
payload = base64.b64encode(pcm_data).decode('ascii')
|
||||||
import json
|
stream_sid = self._stream_sids.get(caller_id, "")
|
||||||
await ws.send_text(json.dumps({
|
await ws.send_text(json.dumps({
|
||||||
"event": "media",
|
"event": "media",
|
||||||
|
"streamSid": stream_sid,
|
||||||
"media": {"payload": payload}
|
"media": {"payload": payload}
|
||||||
}))
|
}))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -149,10 +228,10 @@ class CallerService:
|
|||||||
if not ws:
|
if not ws:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.streaming_tts = True
|
lock = self._get_send_lock(caller_id)
|
||||||
|
self._streaming_tts.add(caller_id)
|
||||||
|
chunks_sent = 0
|
||||||
try:
|
try:
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
|
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
if sample_rate != 16000:
|
if sample_rate != 16000:
|
||||||
ratio = 16000 / sample_rate
|
ratio = 16000 / sample_rate
|
||||||
@@ -161,23 +240,40 @@ class CallerService:
|
|||||||
indices = np.clip(indices, 0, len(audio) - 1)
|
indices = np.clip(indices, 0, len(audio) - 1)
|
||||||
audio = audio[indices]
|
audio = audio[indices]
|
||||||
|
|
||||||
|
total_chunks = (len(audio) + 959) // 960
|
||||||
|
duration_s = len(audio) / 16000
|
||||||
|
print(f"[Caller] TTS stream starting: {duration_s:.1f}s audio, {total_chunks} chunks")
|
||||||
|
|
||||||
chunk_samples = 960
|
chunk_samples = 960
|
||||||
|
chunk_duration = chunk_samples / 16000 # 60ms per chunk
|
||||||
|
|
||||||
for i in range(0, len(audio), chunk_samples):
|
for i in range(0, len(audio), chunk_samples):
|
||||||
if caller_id not in self._websockets:
|
if caller_id not in self._websockets:
|
||||||
|
print(f"[Caller] TTS stream aborted: caller {caller_id} disconnected at chunk {chunks_sent}/{total_chunks}")
|
||||||
break
|
break
|
||||||
|
t0 = time.time()
|
||||||
chunk = audio[i:i + chunk_samples]
|
chunk = audio[i:i + chunk_samples]
|
||||||
pcm_chunk = (chunk * 32767).astype(np.int16).tobytes()
|
pcm_chunk = (chunk * 32767).astype(np.int16).tobytes()
|
||||||
payload = base64.b64encode(pcm_chunk).decode('ascii')
|
payload = base64.b64encode(pcm_chunk).decode('ascii')
|
||||||
|
stream_sid = self._stream_sids.get(caller_id, "")
|
||||||
|
async with lock:
|
||||||
await ws.send_text(json.dumps({
|
await ws.send_text(json.dumps({
|
||||||
"event": "media",
|
"event": "media",
|
||||||
|
"streamSid": stream_sid,
|
||||||
"media": {"payload": payload}
|
"media": {"payload": payload}
|
||||||
}))
|
}))
|
||||||
await asyncio.sleep(0.055)
|
chunks_sent += 1
|
||||||
|
# Sleep to match real-time playback rate
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
sleep_time = max(0, chunk_duration - elapsed)
|
||||||
|
await asyncio.sleep(sleep_time)
|
||||||
|
|
||||||
|
print(f"[Caller] TTS stream finished: {chunks_sent}/{total_chunks} chunks sent")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Caller] Failed to stream audio: {e}")
|
print(f"[Caller] TTS stream failed at chunk {chunks_sent}: {e}")
|
||||||
finally:
|
finally:
|
||||||
self.streaming_tts = False
|
self._streaming_tts.discard(caller_id)
|
||||||
|
|
||||||
def register_call_sid(self, caller_id: str, call_sid: str):
|
def register_call_sid(self, caller_id: str, call_sid: str):
|
||||||
"""Track SignalWire callSid for a caller"""
|
"""Track SignalWire callSid for a caller"""
|
||||||
@@ -190,3 +286,11 @@ class CallerService:
|
|||||||
def unregister_call_sid(self, caller_id: str):
|
def unregister_call_sid(self, caller_id: str):
|
||||||
"""Remove callSid tracking"""
|
"""Remove callSid tracking"""
|
||||||
self._call_sids.pop(caller_id, None)
|
self._call_sids.pop(caller_id, None)
|
||||||
|
|
||||||
|
def register_stream_sid(self, caller_id: str, stream_sid: str):
|
||||||
|
"""Track SignalWire streamSid for a caller"""
|
||||||
|
self._stream_sids[caller_id] = stream_sid
|
||||||
|
|
||||||
|
def unregister_stream_sid(self, caller_id: str):
|
||||||
|
"""Remove streamSid tracking"""
|
||||||
|
self._stream_sids.pop(caller_id, None)
|
||||||
|
|||||||
@@ -7,14 +7,29 @@ from ..config import settings
|
|||||||
|
|
||||||
# Available OpenRouter models
|
# Available OpenRouter models
|
||||||
OPENROUTER_MODELS = [
|
OPENROUTER_MODELS = [
|
||||||
"anthropic/claude-3-haiku",
|
# Default
|
||||||
"anthropic/claude-3.5-sonnet",
|
"anthropic/claude-sonnet-4-5",
|
||||||
|
# Best for natural dialog
|
||||||
|
"x-ai/grok-4-fast",
|
||||||
|
"minimax/minimax-m2-her",
|
||||||
|
"mistralai/mistral-small-creative",
|
||||||
|
"deepseek/deepseek-v3.2",
|
||||||
|
# Other
|
||||||
|
"anthropic/claude-haiku-4.5",
|
||||||
|
"google/gemini-2.5-flash",
|
||||||
"openai/gpt-4o-mini",
|
"openai/gpt-4o-mini",
|
||||||
"openai/gpt-4o",
|
"openai/gpt-4o",
|
||||||
|
# Legacy
|
||||||
|
"anthropic/claude-3-haiku",
|
||||||
"google/gemini-flash-1.5",
|
"google/gemini-flash-1.5",
|
||||||
"google/gemini-pro-1.5",
|
|
||||||
"meta-llama/llama-3.1-8b-instruct",
|
"meta-llama/llama-3.1-8b-instruct",
|
||||||
"mistralai/mistral-7b-instruct",
|
]
|
||||||
|
|
||||||
|
# Fast models to try as fallbacks (cheap, fast, good enough for conversation)
|
||||||
|
FALLBACK_MODELS = [
|
||||||
|
"mistralai/mistral-small-creative",
|
||||||
|
"google/gemini-2.5-flash",
|
||||||
|
"openai/gpt-4o-mini",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -27,6 +42,13 @@ class LLMService:
|
|||||||
self.ollama_model = settings.ollama_model
|
self.ollama_model = settings.ollama_model
|
||||||
self.ollama_host = settings.ollama_host
|
self.ollama_host = settings.ollama_host
|
||||||
self.tts_provider = settings.tts_provider
|
self.tts_provider = settings.tts_provider
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None or self._client.is_closed:
|
||||||
|
self._client = httpx.AsyncClient(timeout=15.0)
|
||||||
|
return self._client
|
||||||
|
|
||||||
def update_settings(
|
def update_settings(
|
||||||
self,
|
self,
|
||||||
@@ -47,7 +69,6 @@ class LLMService:
|
|||||||
self.ollama_host = ollama_host
|
self.ollama_host = ollama_host
|
||||||
if tts_provider:
|
if tts_provider:
|
||||||
self.tts_provider = tts_provider
|
self.tts_provider = tts_provider
|
||||||
# Also update the global settings so TTS service picks it up
|
|
||||||
settings.tts_provider = tts_provider
|
settings.tts_provider = tts_provider
|
||||||
|
|
||||||
async def get_ollama_models(self) -> list[str]:
|
async def get_ollama_models(self) -> list[str]:
|
||||||
@@ -71,7 +92,7 @@ class LLMService:
|
|||||||
"ollama_host": self.ollama_host,
|
"ollama_host": self.ollama_host,
|
||||||
"tts_provider": self.tts_provider,
|
"tts_provider": self.tts_provider,
|
||||||
"available_openrouter_models": OPENROUTER_MODELS,
|
"available_openrouter_models": OPENROUTER_MODELS,
|
||||||
"available_ollama_models": [] # Fetched separately
|
"available_ollama_models": []
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_settings_async(self) -> dict:
|
async def get_settings_async(self) -> dict:
|
||||||
@@ -90,57 +111,73 @@ class LLMService:
|
|||||||
async def generate(
|
async def generate(
|
||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
system_prompt: Optional[str] = None
|
system_prompt: Optional[str] = None,
|
||||||
|
max_tokens: Optional[int] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
|
||||||
Generate a response from the LLM.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
messages: List of message dicts with 'role' and 'content'
|
|
||||||
system_prompt: Optional system prompt to prepend
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Generated text response
|
|
||||||
"""
|
|
||||||
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(messages)
|
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens)
|
||||||
else:
|
else:
|
||||||
return await self._call_ollama(messages)
|
return await self._call_ollama(messages, max_tokens=max_tokens)
|
||||||
|
|
||||||
async def _call_openrouter(self, messages: list[dict]) -> str:
|
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None) -> str:
|
||||||
"""Call OpenRouter API with retry"""
|
"""Try primary model, then fallback models. Always returns a response."""
|
||||||
for attempt in range(2): # Try twice
|
|
||||||
|
# Try primary model first
|
||||||
|
result = await self._call_openrouter_once(messages, self.openrouter_model, max_tokens=max_tokens)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Try fallback models
|
||||||
|
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=10.0, max_tokens=max_tokens)
|
||||||
|
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 = 15.0, max_tokens: Optional[int] = None) -> str | None:
|
||||||
|
"""Single attempt to call OpenRouter. Returns None on failure (not a fallback string)."""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
response = await self.client.post(
|
||||||
response = await client.post(
|
|
||||||
"https://openrouter.ai/api/v1/chat/completions",
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
json={
|
json={
|
||||||
"model": self.openrouter_model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"max_tokens": 100,
|
"max_tokens": max_tokens or 300,
|
||||||
|
"temperature": 0.8,
|
||||||
|
"top_p": 0.92,
|
||||||
|
"frequency_penalty": 0.5,
|
||||||
|
"presence_penalty": 0.3,
|
||||||
},
|
},
|
||||||
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
return data["choices"][0]["message"]["content"]
|
content = data["choices"][0]["message"]["content"]
|
||||||
except (httpx.TimeoutException, httpx.ReadTimeout):
|
if content and content.strip():
|
||||||
print(f"OpenRouter timeout (attempt {attempt + 1})")
|
return content
|
||||||
if attempt == 0:
|
print(f"[LLM] {model} returned empty response")
|
||||||
continue # Retry once
|
return None
|
||||||
return "Uh, sorry, I lost you there for a second. What was that?"
|
except httpx.TimeoutException:
|
||||||
|
print(f"[LLM] {model} timed out ({timeout}s)")
|
||||||
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"OpenRouter error: {e}")
|
print(f"[LLM] {model} error: {e}")
|
||||||
return "Yeah... I don't know, man."
|
return None
|
||||||
return "Uh, hold on a sec..."
|
|
||||||
|
|
||||||
async def _call_ollama(self, messages: list[dict]) -> str:
|
async def _call_ollama(self, messages: list[dict], max_tokens: Optional[int] = None) -> str:
|
||||||
"""Call Ollama API"""
|
"""Call Ollama API"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
@@ -151,11 +188,11 @@ class LLMService:
|
|||||||
"messages": messages,
|
"messages": messages,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
"options": {
|
"options": {
|
||||||
"num_predict": 100, # Allow complete thoughts
|
"num_predict": max_tokens or 100,
|
||||||
"temperature": 0.8, # Balanced creativity/coherence
|
"temperature": 0.8,
|
||||||
"top_p": 0.9, # Focused word choices
|
"top_p": 0.9,
|
||||||
"repeat_penalty": 1.3, # Avoid repetition
|
"repeat_penalty": 1.3,
|
||||||
"top_k": 50, # Reasonable token variety
|
"top_k": 50,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
timeout=30.0
|
timeout=30.0
|
||||||
@@ -165,10 +202,10 @@ class LLMService:
|
|||||||
return data["message"]["content"]
|
return data["message"]["content"]
|
||||||
except httpx.TimeoutException:
|
except httpx.TimeoutException:
|
||||||
print("Ollama timeout")
|
print("Ollama timeout")
|
||||||
return "Uh, sorry, I lost you there for a second. What was that?"
|
return "Sorry, I totally blanked out for a second. What were you saying?"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ollama error: {e}")
|
print(f"Ollama error: {e}")
|
||||||
return "Yeah... I don't know, man."
|
return "Sorry, I totally blanked out for a second. What were you saying?"
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
"""News service for current events awareness in AI callers"""
|
"""News service using local SearXNG for current events awareness in AI callers"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from urllib.parse import quote_plus
|
|
||||||
from xml.etree import ElementTree
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
SEARXNG_URL = "http://localhost:8888"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NewsItem:
|
class NewsItem:
|
||||||
title: str
|
title: str
|
||||||
source: str
|
source: str
|
||||||
published: str
|
published: str
|
||||||
|
content: str = ""
|
||||||
|
|
||||||
|
|
||||||
class NewsService:
|
class NewsService:
|
||||||
@@ -22,38 +23,37 @@ class NewsService:
|
|||||||
self._client: httpx.AsyncClient | None = None
|
self._client: httpx.AsyncClient | None = None
|
||||||
self._headlines_cache: list[NewsItem] = []
|
self._headlines_cache: list[NewsItem] = []
|
||||||
self._headlines_ts: float = 0
|
self._headlines_ts: float = 0
|
||||||
self._headlines_lock = asyncio.Lock()
|
|
||||||
self._search_cache: dict[str, tuple[float, list[NewsItem]]] = {}
|
self._search_cache: dict[str, tuple[float, list[NewsItem]]] = {}
|
||||||
self._search_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
@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=10.0)
|
self._client = httpx.AsyncClient(timeout=5.0)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
async def get_headlines(self) -> list[NewsItem]:
|
async def get_headlines(self) -> list[NewsItem]:
|
||||||
async with self._headlines_lock:
|
# Cache for 30min
|
||||||
if self._headlines_cache and time.time() - self._headlines_ts < 1800:
|
if self._headlines_cache and time.time() - self._headlines_ts < 1800:
|
||||||
return self._headlines_cache
|
return self._headlines_cache
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = await self.client.get("https://news.google.com/rss")
|
resp = await self.client.get(
|
||||||
|
f"{SEARXNG_URL}/search",
|
||||||
|
params={"q": "news", "format": "json", "categories": "news"},
|
||||||
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
items = self._parse_rss(resp.text, max_items=10)
|
items = self._parse_searxng(resp.json(), max_items=10)
|
||||||
self._headlines_cache = items
|
self._headlines_cache = items
|
||||||
self._headlines_ts = time.time()
|
self._headlines_ts = time.time()
|
||||||
return items
|
return items
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[News] Headlines fetch failed: {e}")
|
print(f"[News] Headlines fetch failed: {e}")
|
||||||
if self._headlines_cache:
|
self._headlines_ts = time.time()
|
||||||
return self._headlines_cache
|
return self._headlines_cache
|
||||||
return []
|
|
||||||
|
|
||||||
async def search_topic(self, query: str) -> list[NewsItem]:
|
async def search_topic(self, query: str) -> list[NewsItem]:
|
||||||
cache_key = query.lower()
|
cache_key = query.lower()
|
||||||
|
|
||||||
async with self._search_lock:
|
|
||||||
if cache_key in self._search_cache:
|
if cache_key in self._search_cache:
|
||||||
ts, items = self._search_cache[cache_key]
|
ts, items = self._search_cache[cache_key]
|
||||||
if time.time() - ts < 600:
|
if time.time() - ts < 600:
|
||||||
@@ -65,38 +65,32 @@ class NewsService:
|
|||||||
del self._search_cache[oldest_key]
|
del self._search_cache[oldest_key]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
encoded = quote_plus(query)
|
resp = await self.client.get(
|
||||||
url = f"https://news.google.com/rss/search?q={encoded}&hl=en-US&gl=US&ceid=US:en"
|
f"{SEARXNG_URL}/search",
|
||||||
resp = await self.client.get(url)
|
params={"q": query, "format": "json", "categories": "news"},
|
||||||
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
items = self._parse_rss(resp.text, max_items=5)
|
items = self._parse_searxng(resp.json(), max_items=5)
|
||||||
|
|
||||||
async with self._search_lock:
|
|
||||||
self._search_cache[cache_key] = (time.time(), items)
|
self._search_cache[cache_key] = (time.time(), items)
|
||||||
|
|
||||||
return items
|
return items
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[News] Search failed for '{query}': {e}")
|
print(f"[News] Search failed for '{query}': {e}")
|
||||||
async with self._search_lock:
|
|
||||||
if cache_key in self._search_cache:
|
if cache_key in self._search_cache:
|
||||||
return self._search_cache[cache_key][1]
|
return self._search_cache[cache_key][1]
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _parse_rss(self, xml_text: str, max_items: int = 10) -> list[NewsItem]:
|
def _parse_searxng(self, data: dict, max_items: int = 10) -> list[NewsItem]:
|
||||||
items = []
|
items = []
|
||||||
try:
|
for result in data.get("results", [])[:max_items]:
|
||||||
root = ElementTree.fromstring(xml_text)
|
title = result.get("title", "").strip()
|
||||||
for item_el in root.iter("item"):
|
if not title:
|
||||||
if len(items) >= max_items:
|
continue
|
||||||
break
|
# Extract source from engines list or metadata
|
||||||
title = item_el.findtext("title", "").strip()
|
engines = result.get("engines", [])
|
||||||
source_el = item_el.find("source")
|
source = engines[0] if engines else ""
|
||||||
source = source_el.text.strip() if source_el is not None and source_el.text else ""
|
published = result.get("publishedDate", "")
|
||||||
published = item_el.findtext("pubDate", "").strip()
|
content = result.get("content", "").strip()
|
||||||
if title:
|
items.append(NewsItem(title=title, source=source, published=published, content=content))
|
||||||
items.append(NewsItem(title=title, source=source, published=published))
|
|
||||||
except ElementTree.ParseError as e:
|
|
||||||
print(f"[News] RSS parse error: {e}")
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
def format_headlines_for_prompt(self, items: list[NewsItem]) -> str:
|
def format_headlines_for_prompt(self, items: list[NewsItem]) -> str:
|
||||||
@@ -143,31 +137,51 @@ STOP_WORDS = {
|
|||||||
"first", "last", "back", "down", "ever", "away", "cant", "dont",
|
"first", "last", "back", "down", "ever", "away", "cant", "dont",
|
||||||
"didnt", "doesnt", "isnt", "wasnt", "wont", "wouldnt", "couldnt",
|
"didnt", "doesnt", "isnt", "wasnt", "wont", "wouldnt", "couldnt",
|
||||||
"shouldnt", "aint", "stop", "start", "started", "help",
|
"shouldnt", "aint", "stop", "start", "started", "help",
|
||||||
|
# Radio show filler
|
||||||
|
"welcome", "thanks", "thank", "show", "roost", "luke", "whats",
|
||||||
|
"youre", "thats", "heres", "theyre", "ive", "youve", "weve",
|
||||||
|
"sounds", "listen", "hear", "heard", "happen", "happened",
|
||||||
|
"happening", "absolutely", "definitely", "exactly", "totally",
|
||||||
|
"pretty", "little", "whole", "every", "point", "sense", "real",
|
||||||
|
"great", "cool", "awesome", "amazing", "crazy", "weird", "funny",
|
||||||
|
"tough", "hard", "wrong", "true", "trying", "tried", "works",
|
||||||
|
"working", "anymore", "already", "enough", "though", "whatever",
|
||||||
|
"theres", "making", "saying", "keeping", "possible", "instead",
|
||||||
|
"front", "behind", "course", "talks", "happens", "watch",
|
||||||
|
"everybodys", "pants", "husband", "client",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def extract_keywords(text: str, max_keywords: int = 3) -> list[str]:
|
def extract_keywords(text: str, max_keywords: int = 3) -> list[str]:
|
||||||
words = text.split()
|
words = text.split()
|
||||||
|
if len(words) < 8:
|
||||||
|
return [] # Too short to extract meaningful topics
|
||||||
|
|
||||||
keywords = []
|
keywords = []
|
||||||
|
|
||||||
# Pass 1: capitalized words (proper nouns) not at sentence start
|
# Only look for proper nouns that are likely real topics (not caller names)
|
||||||
|
proper_nouns = []
|
||||||
for i, word in enumerate(words):
|
for i, word in enumerate(words):
|
||||||
clean = re.sub(r'[^\w]', '', word)
|
clean = re.sub(r'[^\w]', '', word)
|
||||||
if not clean:
|
if not clean or len(clean) < 3:
|
||||||
continue
|
continue
|
||||||
is_sentence_start = i == 0 or (i > 0 and words[i - 1].rstrip()[-1:] in '.!?')
|
is_sentence_start = i == 0 or (i > 0 and words[i - 1].rstrip()[-1:] in '.!?')
|
||||||
if clean[0].isupper() and not is_sentence_start and clean.lower() not in STOP_WORDS:
|
if clean[0].isupper() and not is_sentence_start and clean.lower() not in STOP_WORDS:
|
||||||
if clean not in keywords:
|
proper_nouns.append(clean)
|
||||||
keywords.append(clean)
|
|
||||||
|
# Only use proper nouns if we found 2+ (single one is probably a name)
|
||||||
|
if len(proper_nouns) >= 2:
|
||||||
|
for noun in proper_nouns[:max_keywords]:
|
||||||
|
if noun not in keywords:
|
||||||
|
keywords.append(noun)
|
||||||
if len(keywords) >= max_keywords:
|
if len(keywords) >= max_keywords:
|
||||||
return keywords
|
return keywords
|
||||||
|
|
||||||
# Pass 2: uncommon words (>4 chars, not in stop words)
|
# Pass 2: uncommon words (>5 chars, not in stop words)
|
||||||
for word in words:
|
for word in words:
|
||||||
clean = re.sub(r'[^\w]', '', word).lower()
|
clean = re.sub(r'[^\w]', '', word).lower()
|
||||||
if len(clean) > 4 and clean not in STOP_WORDS:
|
if len(clean) > 5 and clean not in STOP_WORDS:
|
||||||
title_clean = clean.capitalize()
|
if clean not in [k.lower() for k in keywords]:
|
||||||
if title_clean not in keywords and clean not in [k.lower() for k in keywords]:
|
|
||||||
keywords.append(clean)
|
keywords.append(clean)
|
||||||
if len(keywords) >= max_keywords:
|
if len(keywords) >= max_keywords:
|
||||||
return keywords
|
return keywords
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Returning caller persistence service"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "regulars.json"
|
||||||
|
MAX_REGULARS = 12
|
||||||
|
|
||||||
|
|
||||||
|
class RegularCallerService:
|
||||||
|
"""Manages persistent 'regular' callers who return across sessions"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._regulars: list[dict] = []
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if DATA_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(DATA_FILE) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self._regulars = data.get("regulars", [])
|
||||||
|
print(f"[Regulars] Loaded {len(self._regulars)} regular callers")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Regulars] Failed to load: {e}")
|
||||||
|
self._regulars = []
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
try:
|
||||||
|
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(DATA_FILE, "w") as f:
|
||||||
|
json.dump({"regulars": self._regulars}, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Regulars] Failed to save: {e}")
|
||||||
|
|
||||||
|
def get_regulars(self) -> list[dict]:
|
||||||
|
return list(self._regulars)
|
||||||
|
|
||||||
|
def get_returning_callers(self, count: int = 2) -> list[dict]:
|
||||||
|
"""Get up to `count` regulars for returning caller slots"""
|
||||||
|
import random
|
||||||
|
if not self._regulars:
|
||||||
|
return []
|
||||||
|
available = [r for r in self._regulars if len(r.get("call_history", [])) > 0]
|
||||||
|
if not available:
|
||||||
|
return []
|
||||||
|
return random.sample(available, min(count, len(available)))
|
||||||
|
|
||||||
|
def add_regular(self, name: str, gender: str, age: int, job: str,
|
||||||
|
location: str, personality_traits: list[str],
|
||||||
|
first_call_summary: str, voice: str = None) -> dict:
|
||||||
|
"""Promote a first-time caller to regular"""
|
||||||
|
# Retire oldest if at cap
|
||||||
|
if len(self._regulars) >= MAX_REGULARS:
|
||||||
|
self._regulars.sort(key=lambda r: r.get("last_call", 0))
|
||||||
|
retired = self._regulars.pop(0)
|
||||||
|
print(f"[Regulars] Retired {retired['name']} to make room")
|
||||||
|
|
||||||
|
regular = {
|
||||||
|
"id": str(uuid.uuid4())[:8],
|
||||||
|
"name": name,
|
||||||
|
"gender": gender,
|
||||||
|
"age": age,
|
||||||
|
"job": job,
|
||||||
|
"location": location,
|
||||||
|
"personality_traits": personality_traits,
|
||||||
|
"voice": voice,
|
||||||
|
"call_history": [
|
||||||
|
{"summary": first_call_summary, "timestamp": time.time()}
|
||||||
|
],
|
||||||
|
"last_call": time.time(),
|
||||||
|
"created_at": time.time(),
|
||||||
|
}
|
||||||
|
self._regulars.append(regular)
|
||||||
|
self._save()
|
||||||
|
print(f"[Regulars] Promoted {name} to regular (total: {len(self._regulars)})")
|
||||||
|
return regular
|
||||||
|
|
||||||
|
def update_after_call(self, regular_id: str, call_summary: str):
|
||||||
|
"""Update a regular's history after a returning call"""
|
||||||
|
for regular in self._regulars:
|
||||||
|
if regular["id"] == regular_id:
|
||||||
|
regular.setdefault("call_history", []).append(
|
||||||
|
{"summary": call_summary, "timestamp": time.time()}
|
||||||
|
)
|
||||||
|
regular["last_call"] = time.time()
|
||||||
|
self._save()
|
||||||
|
print(f"[Regulars] Updated {regular['name']} call history ({len(regular['call_history'])} calls)")
|
||||||
|
return
|
||||||
|
print(f"[Regulars] Regular {regular_id} not found for update")
|
||||||
|
|
||||||
|
|
||||||
|
regular_caller_service = RegularCallerService()
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Records separate audio stems during a live show for post-production"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from pathlib import Path
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
|
||||||
|
|
||||||
|
|
||||||
|
class StemRecorder:
|
||||||
|
def __init__(self, output_dir: str | Path, sample_rate: int = 48000):
|
||||||
|
self.output_dir = Path(output_dir)
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.sample_rate = sample_rate
|
||||||
|
self._running = False
|
||||||
|
self._queues: dict[str, deque] = {}
|
||||||
|
self._writer_thread: threading.Thread | None = None
|
||||||
|
self._start_time: float = 0.0
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._start_time = time.time()
|
||||||
|
self._running = True
|
||||||
|
for name in STEM_NAMES:
|
||||||
|
self._queues[name] = deque()
|
||||||
|
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=True)
|
||||||
|
self._writer_thread.start()
|
||||||
|
print(f"[StemRecorder] Recording started -> {self.output_dir}")
|
||||||
|
|
||||||
|
def write(self, stem_name: str, audio_data: np.ndarray, source_sr: int):
|
||||||
|
"""Non-blocking write for continuous streams (host mic, music, ads).
|
||||||
|
Safe to call from audio callbacks."""
|
||||||
|
if not self._running or stem_name not in self._queues:
|
||||||
|
return
|
||||||
|
self._queues[stem_name].append(("audio", audio_data.copy(), source_sr))
|
||||||
|
|
||||||
|
def write_sporadic(self, stem_name: str, audio_data: np.ndarray, source_sr: int):
|
||||||
|
"""Write for burst sources (caller TTS, SFX). Pads silence to current time."""
|
||||||
|
if not self._running or stem_name not in self._queues:
|
||||||
|
return
|
||||||
|
self._queues[stem_name].append(("sporadic", audio_data.copy(), source_sr))
|
||||||
|
|
||||||
|
def _resample(self, audio_data: np.ndarray, source_sr: int) -> np.ndarray:
|
||||||
|
if source_sr == self.sample_rate:
|
||||||
|
return audio_data.astype(np.float32)
|
||||||
|
ratio = self.sample_rate / source_sr
|
||||||
|
num_samples = int(len(audio_data) * ratio)
|
||||||
|
if num_samples <= 0:
|
||||||
|
return np.array([], dtype=np.float32)
|
||||||
|
indices = (np.arange(num_samples) / ratio).astype(int)
|
||||||
|
indices = np.clip(indices, 0, len(audio_data) - 1)
|
||||||
|
return audio_data[indices].astype(np.float32)
|
||||||
|
|
||||||
|
def _writer_loop(self):
|
||||||
|
"""Background thread that drains queues and writes to WAV files."""
|
||||||
|
files: dict[str, sf.SoundFile] = {}
|
||||||
|
positions: dict[str, int] = {}
|
||||||
|
|
||||||
|
for name in STEM_NAMES:
|
||||||
|
path = self.output_dir / f"{name}.wav"
|
||||||
|
files[name] = sf.SoundFile(
|
||||||
|
str(path), mode="w",
|
||||||
|
samplerate=self.sample_rate,
|
||||||
|
channels=1, subtype="FLOAT",
|
||||||
|
)
|
||||||
|
positions[name] = 0
|
||||||
|
|
||||||
|
while self._running or any(len(q) > 0 for q in self._queues.values()):
|
||||||
|
did_work = False
|
||||||
|
for name in STEM_NAMES:
|
||||||
|
q = self._queues[name]
|
||||||
|
while q:
|
||||||
|
did_work = True
|
||||||
|
msg_type, audio_data, source_sr = q.popleft()
|
||||||
|
resampled = self._resample(audio_data, source_sr)
|
||||||
|
if len(resampled) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if msg_type == "sporadic":
|
||||||
|
elapsed = time.time() - self._start_time
|
||||||
|
expected_pos = int(elapsed * self.sample_rate)
|
||||||
|
if expected_pos > positions[name]:
|
||||||
|
gap = expected_pos - positions[name]
|
||||||
|
files[name].write(np.zeros(gap, dtype=np.float32))
|
||||||
|
positions[name] = expected_pos
|
||||||
|
|
||||||
|
files[name].write(resampled)
|
||||||
|
positions[name] += len(resampled)
|
||||||
|
|
||||||
|
if not did_work:
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
# Pad all stems to same length
|
||||||
|
max_pos = max(positions.values()) if positions else 0
|
||||||
|
for name in STEM_NAMES:
|
||||||
|
if positions[name] < max_pos:
|
||||||
|
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
|
||||||
|
files[name].close()
|
||||||
|
|
||||||
|
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s)")
|
||||||
|
|
||||||
|
def stop(self) -> dict[str, str]:
|
||||||
|
if not self._running:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
if self._writer_thread:
|
||||||
|
self._writer_thread.join(timeout=10.0)
|
||||||
|
self._writer_thread = None
|
||||||
|
|
||||||
|
paths = {}
|
||||||
|
for name in STEM_NAMES:
|
||||||
|
paths[name] = str(self.output_dir / f"{name}.wav")
|
||||||
|
|
||||||
|
self._queues.clear()
|
||||||
|
return paths
|
||||||
@@ -86,18 +86,28 @@ DEFAULT_VITS_SPEAKER = "p225"
|
|||||||
# Dennis, Dominus, Edward, Elizabeth, Hades, Hana, Julia, Luna, Mark, Olivia,
|
# Dennis, Dominus, Edward, Elizabeth, Hades, Hana, Julia, Luna, Mark, Olivia,
|
||||||
# Pixie, Priya, Ronald, Sarah, Shaun, Theodore, Timothy, Wendy
|
# Pixie, Priya, Ronald, Sarah, Shaun, Theodore, Timothy, Wendy
|
||||||
INWORLD_VOICES = {
|
INWORLD_VOICES = {
|
||||||
# Male voices - each caller gets a unique voice matching their personality
|
# Original voice IDs
|
||||||
"VR6AewLTigWG4xSOukaG": "Edward", # Tony - fast-talking, emphatic, streetwise
|
"VR6AewLTigWG4xSOukaG": "Edward", # Tony - fast-talking, emphatic, streetwise
|
||||||
"TxGEqnHWrfWFTfGW9XjX": "Shaun", # Rick - friendly, dynamic, conversational
|
"TxGEqnHWrfWFTfGW9XjX": "Shaun", # Rick - friendly, dynamic, conversational
|
||||||
"pNInz6obpgDQGcFmaJgB": "Alex", # Dennis - energetic, expressive, mildly nasal
|
"pNInz6obpgDQGcFmaJgB": "Alex", # Dennis - energetic, expressive, mildly nasal
|
||||||
"ODq5zmih8GrVes37Dizd": "Craig", # Earl - older British, refined, articulate
|
"ODq5zmih8GrVes37Dizd": "Craig", # Earl - older British, refined, articulate
|
||||||
"IKne3meq5aSn9XLyUdCD": "Timothy", # Marcus - lively, upbeat American
|
"IKne3meq5aSn9XLyUdCD": "Timothy", # Marcus/Jerome - lively, upbeat American
|
||||||
# Female voices - each caller gets a unique voice matching their personality
|
|
||||||
"jBpfuIE2acCO8z3wKNLl": "Hana", # Jasmine - bright, expressive young female
|
"jBpfuIE2acCO8z3wKNLl": "Hana", # Jasmine - bright, expressive young female
|
||||||
"EXAVITQu4vr4xnSDxMaL": "Ashley", # Megan - warm, natural female
|
"EXAVITQu4vr4xnSDxMaL": "Ashley", # Megan - warm, natural female
|
||||||
"21m00Tcm4TlvDq8ikWAM": "Wendy", # Tanya - posh, middle-aged British
|
"21m00Tcm4TlvDq8ikWAM": "Wendy", # Tanya - posh, middle-aged British
|
||||||
"XB0fDUnXU5powFXDhCwa": "Sarah", # Carla - fast-talking, questioning tone
|
"XB0fDUnXU5powFXDhCwa": "Sarah", # Carla - fast-talking, questioning tone
|
||||||
"pFZP5JQG7iQjIQuC4Bku": "Deborah", # Brenda - gentle, elegant
|
"pFZP5JQG7iQjIQuC4Bku": "Deborah", # Brenda (original) - gentle, elegant
|
||||||
|
# Regular caller voice IDs (backfilled)
|
||||||
|
"onwK4e9ZLuTAKqWW03F9": "Ronald", # Bobby - repo man
|
||||||
|
"FGY2WhTYpPnrIDTdsKH5": "Julia", # Carla (regular) - Jersey mom
|
||||||
|
"CwhRBWXzGAHq8TQ4Fs17": "Mark", # Leon - male caller
|
||||||
|
"SOYHLrjzK2X1ezoPC6cr": "Carter", # Carl - male caller
|
||||||
|
"N2lVS1w4EtoT3dr4eOWO": "Clive", # Reggie - male caller
|
||||||
|
"hpp4J3VqNfWAUOO0d1Us": "Olivia", # Brenda (regular) - ambulance driver
|
||||||
|
"nPczCjzI2devNBz1zQrb": "Theodore", # Keith - male caller
|
||||||
|
"JBFqnCBsd6RMkjVDRZzb": "Blake", # Andre - male caller
|
||||||
|
"TX3LPaxmHKxFdv7VOQHJ": "Dennis", # Rick (regular) - male caller
|
||||||
|
"cgSgspJ2msm6clMCkdW9": "Priya", # Megan (regular) - female caller
|
||||||
}
|
}
|
||||||
DEFAULT_INWORLD_VOICE = "Dennis"
|
DEFAULT_INWORLD_VOICE = "Dennis"
|
||||||
|
|
||||||
@@ -577,7 +587,12 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
|||||||
import base64
|
import base64
|
||||||
import librosa
|
import librosa
|
||||||
|
|
||||||
voice = INWORLD_VOICES.get(voice_id, DEFAULT_INWORLD_VOICE)
|
# voice_id is now the Inworld voice name directly (e.g. "Edward")
|
||||||
|
# Fall back to legacy mapping if it's an ElevenLabs ID
|
||||||
|
if voice_id in INWORLD_VOICES:
|
||||||
|
voice = INWORLD_VOICES[voice_id]
|
||||||
|
else:
|
||||||
|
voice = voice_id
|
||||||
|
|
||||||
api_key = settings.inworld_api_key
|
api_key = settings.inworld_api_key
|
||||||
if not api_key:
|
if not api_key:
|
||||||
@@ -593,14 +608,14 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
|||||||
payload = {
|
payload = {
|
||||||
"text": text,
|
"text": text,
|
||||||
"voice_id": voice,
|
"voice_id": voice,
|
||||||
"model_id": "inworld-tts-1.5-mini",
|
"model_id": "inworld-tts-1.5-max",
|
||||||
"audio_config": {
|
"audio_config": {
|
||||||
"encoding": "LINEAR16",
|
"encoding": "LINEAR16",
|
||||||
"sample_rate_hertz": 48000,
|
"sample_rate_hertz": 48000,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
async with httpx.AsyncClient(timeout=25.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()
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
{
|
||||||
|
"regulars": [
|
||||||
|
{
|
||||||
|
"id": "dc4916a7",
|
||||||
|
"name": "Leon",
|
||||||
|
"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",
|
||||||
|
"age": 44,
|
||||||
|
"job": "a 41-year-old ambulance driver, is fed up with the tipping culture",
|
||||||
|
"location": "unknown",
|
||||||
|
"personality_traits": [],
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Brenda called in to vent about being frustrated with automatic tipping at a diner, where a 20% tip was already added to her bill but the card reader prompted her to add an additional 25-35% while the waitress watched. She expressed feeling pressured and annoyed as an ambulance driver with two kids, struggling with whether to look cheap by reducing the tip, before playing a quick real-or-fake news game with the host.",
|
||||||
|
"timestamp": 1770770008.684104
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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.",
|
||||||
|
"timestamp": 1771120062.169228
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1771120062.169229,
|
||||||
|
"created_at": 1770770008.684105,
|
||||||
|
"voice": "hpp4J3VqNfWAUOO0d1Us"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "add59d4a",
|
||||||
|
"name": "Rick",
|
||||||
|
"gender": "male",
|
||||||
|
"age": 65,
|
||||||
|
"job": "south of Silver City",
|
||||||
|
"location": "unknown",
|
||||||
|
"personality_traits": [],
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Rick called in to play \"real news or fake news\" and correctly identified a headline about a geothermal plant sale. He then shared that he's troubled about an elderly bank customer who withdrew $8,000 cash while appearing scared and mentioning his daughter's boyfriend was pressuring him about finances\u2014Rick processed the withdrawal but later learned he should have flagged it as potential elder exploitation, and he's feeling guilty about not intervening.",
|
||||||
|
"timestamp": 1770771655.536344
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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.",
|
||||||
|
"timestamp": 1771126337.585641
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1771126337.585642,
|
||||||
|
"created_at": 1770771655.536344,
|
||||||
|
"voice": "TX3LPaxmHKxFdv7VOQHJ"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "13ff1736",
|
||||||
|
"name": "Jasmine",
|
||||||
|
"gender": "female",
|
||||||
|
"age": 36,
|
||||||
|
"job": "a 61-year-old woman who runs a small bakery in the rural Southwest, finds herself at a crossroads",
|
||||||
|
"location": "unknown",
|
||||||
|
"personality_traits": [],
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Jasmine called in to defend an earlier caller (Rick) whom she felt the host was too hard on, explaining she's been feeling guilty herself lately. She emotionally revealed that she chose her 1972 Ford Bronco restoration project over her marriage when given an ultimatum, and now regrets sleeping in the guest room with Valentine's Day approaching.",
|
||||||
|
"timestamp": 1770772286.1733272
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"age": 26,
|
||||||
|
"job": "is a vet tech",
|
||||||
|
"location": "unknown",
|
||||||
|
"personality_traits": [],
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Carla, separated from her husband but not yet divorced, vented about her intrusive in-laws who relentlessly call and dictate her life\u2014from finances and household matters to her clothing choices\u2014while her spineless spouse relays their demands, making her feel trapped in a one-sided war. With her own parents unavailable (father deceased, mother distant), she leans on her bickering but honest sister for support, underscoring her deep frustration and sense of isolation.",
|
||||||
|
"timestamp": 1770522530.8554251
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Carla dismissed celebrity science theories like Terrence Howard's after watching Neil deGrasse Tyson's critique, then marveled at JWST's exoplanet discoveries before sharing her relief at finally cutting off her toxic in-laws amid her ongoing divorce. She expressed deep heartbreak over actor James Ransone's suicide at 46, reflecting on life's fragility, her late father's death, and the need to eliminate family drama, leaving her contemplative and planning a solo desert drive for clarity.",
|
||||||
|
"timestamp": 1770526316.004708
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "In this call, Carla discovered some explicit photos of her ex-husband and his old girlfriend in a box of his old ham radio equipment. She is feeling very uncomfortable about the situation and is seeking advice from the radio host, Luke, on how to best handle and dispose of the photos.",
|
||||||
|
"timestamp": 1770602323.234795
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
"created_at": 1770522530.855426,
|
||||||
|
"voice": "FGY2WhTYpPnrIDTdsKH5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7be7317c",
|
||||||
|
"name": "Jerome",
|
||||||
|
"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",
|
||||||
|
"age": 34,
|
||||||
|
"job": "which got her thinking about her sister Crystal up in Flagstaff who hasn't seen a truly dark sky",
|
||||||
|
"location": "unknown",
|
||||||
|
"personality_traits": [],
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Megan, a kindergarten teacher from the bootheel, called in after one of her students asked if stars know we're looking at them, which led her to reflect on how her sister Crystal in Flagstaff has stopped appreciating the night sky despite having access to it. The conversation took an unexpected turn when Luke challenged her to admit a gross habit, and after some prodding, she confessed to picking dry skin off her feet while watching TV and flicking it on the floor.",
|
||||||
|
"timestamp": 1770870641.723117
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
"created_at": 1770870641.723117,
|
||||||
|
"voice": "cgSgspJ2msm6clMCkdW9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "49147bd5",
|
||||||
|
"name": "Keith",
|
||||||
|
"gender": "male",
|
||||||
|
"age": 61,
|
||||||
|
"job": "south of Silver City",
|
||||||
|
"location": "in unknown",
|
||||||
|
"personality_traits": [],
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "The caller, Luke, kicked off by sharing a humorous clip of Terrence Howard's Tree of Life Theory being critiqued by Neil deGrasse Tyson, which left Howard visibly hurt, before pivoting to economic woes, blaming overspending and Federal Reserve money printing for devaluing the currency and harming everyday people. He advocated abolishing the Fed, echoing Ron Paul's ideas, to let markets stabilize money, potentially boosting innovation and new industries in rural spots like Silver City despite uncertain local impacts.",
|
||||||
|
"timestamp": 1770524506.3390348
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Here is a 1-2 sentence summary of the call:\n\nThe caller, who works at a bank, has been reflecting on his tendency to blame the government and economic system for his problems, rather than taking responsibility for his own role. He had an epiphany while eating leftover enchiladas in his minivan, realizing he needs to be more proactive instead of just complaining.",
|
||||||
|
"timestamp": 1770574890.1296651
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Keith called in with an update about a widow who has been showing up weekly at the cemetery where he works nights, but she sits by the maintenance shed rather than visiting her husband's grave, and recently started asking Keith's neighbor personal questions about him. Luke dismissively suggested Keith just talk to the woman and called him a coward for being concerned, leading to some tension before they moved on to playing the real or fake news game.",
|
||||||
|
"timestamp": 1770770394.0436218
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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,
|
||||||
|
"created_at": 1770524506.339036,
|
||||||
|
"voice": "nPczCjzI2devNBz1zQrb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0d244eeb",
|
||||||
|
"name": "Gus",
|
||||||
|
"gender": "male",
|
||||||
|
"age": 33,
|
||||||
|
"job": "",
|
||||||
|
"location": "in unknown",
|
||||||
|
"personality_traits": [],
|
||||||
|
"voice": "Alex",
|
||||||
|
"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.",
|
||||||
|
"timestamp": 1770951226.534601
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1770951226.534601,
|
||||||
|
"created_at": 1770951226.534601
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy podcast_stats.py to NAS as a long-running Docker container that updates hourly.
|
||||||
|
#
|
||||||
|
# Usage: ./deploy_stats_cron.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
NAS_HOST="mmgnas-10g"
|
||||||
|
NAS_USER="luke"
|
||||||
|
NAS_PORT="8001"
|
||||||
|
DOCKER_BIN="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||||
|
DEPLOY_DIR="/share/CACHEDEV1_DATA/podcast-stats"
|
||||||
|
CONTAINER_NAME="podcast-stats"
|
||||||
|
|
||||||
|
echo "Deploying podcast stats to NAS..."
|
||||||
|
|
||||||
|
# Create deploy dir and copy files
|
||||||
|
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" "mkdir -p $DEPLOY_DIR"
|
||||||
|
scp -P "$NAS_PORT" podcast_stats.py "$NAS_USER@$NAS_HOST:$DEPLOY_DIR/podcast_stats.py"
|
||||||
|
|
||||||
|
# Create Dockerfile locally, then copy it over (NAS /tmp is tiny)
|
||||||
|
TMPFILE=$(mktemp)
|
||||||
|
cat > "$TMPFILE" << 'DOCKERFILE'
|
||||||
|
FROM python:3.11-slim
|
||||||
|
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 \
|
||||||
|
&& apt-get purge -y curl && apt-get autoremove -y
|
||||||
|
RUN pip install --no-cache-dir requests yt-dlp
|
||||||
|
COPY podcast_stats.py /app/podcast_stats.py
|
||||||
|
COPY run_loop.sh /app/run_loop.sh
|
||||||
|
RUN chmod +x /app/run_loop.sh
|
||||||
|
WORKDIR /app
|
||||||
|
CMD ["/app/run_loop.sh"]
|
||||||
|
DOCKERFILE
|
||||||
|
scp -P "$NAS_PORT" "$TMPFILE" "$NAS_USER@$NAS_HOST:$DEPLOY_DIR/Dockerfile"
|
||||||
|
rm "$TMPFILE"
|
||||||
|
|
||||||
|
# Create the loop script
|
||||||
|
TMPFILE=$(mktemp)
|
||||||
|
cat > "$TMPFILE" << 'LOOPSCRIPT'
|
||||||
|
#!/bin/sh
|
||||||
|
echo "podcast-stats: starting hourly loop"
|
||||||
|
while true; do
|
||||||
|
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"
|
||||||
|
echo "Sleeping 1 hour..."
|
||||||
|
sleep 3600
|
||||||
|
done
|
||||||
|
LOOPSCRIPT
|
||||||
|
scp -P "$NAS_PORT" "$TMPFILE" "$NAS_USER@$NAS_HOST:$DEPLOY_DIR/run_loop.sh"
|
||||||
|
rm "$TMPFILE"
|
||||||
|
|
||||||
|
echo "Building Docker image on NAS..."
|
||||||
|
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
|
||||||
|
"TMPDIR=$DEPLOY_DIR $DOCKER_BIN build -t $CONTAINER_NAME $DEPLOY_DIR"
|
||||||
|
|
||||||
|
# Stop old container if running
|
||||||
|
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
|
||||||
|
"$DOCKER_BIN rm -f $CONTAINER_NAME 2>/dev/null || true"
|
||||||
|
|
||||||
|
# Run as a daemon with auto-restart (survives reboots)
|
||||||
|
echo "Starting container..."
|
||||||
|
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
|
||||||
|
"$DOCKER_BIN run -d --name $CONTAINER_NAME --restart unless-stopped --network host -v /var/run/docker.sock:/var/run/docker.sock $CONTAINER_NAME"
|
||||||
|
|
||||||
|
echo "Verifying..."
|
||||||
|
sleep 3
|
||||||
|
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
|
||||||
|
"$DOCKER_BIN logs $CONTAINER_NAME 2>&1 | tail -5"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! Container runs hourly in a loop with --restart unless-stopped."
|
||||||
|
echo " Logs: ssh -p $NAS_PORT $NAS_USER@$NAS_HOST '$DOCKER_BIN logs -f $CONTAINER_NAME'"
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
# Luke at the Roost — Architecture
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ BROWSER (Control Panel) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────┐ ┌───────────────┐ │
|
||||||
|
│ │ Caller │ │ Chat │ │ Music/ │ │Settings│ │ Server Log │ │
|
||||||
|
│ │ Buttons │ │ Window │ │ Ads/SFX │ │ Modal │ │ (live tail) │ │
|
||||||
|
│ │ (0-9) │ │ │ │ │ │ │ │ │ │
|
||||||
|
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───┬────┘ └───────┬───────┘ │
|
||||||
|
│ │ │ │ │ │ │
|
||||||
|
│ ┌────┴────────────┴────────────┴────────────┴──────────────┴───────┐ │
|
||||||
|
│ │ frontend/js/app.js │ │
|
||||||
|
│ │ Polling: queue (3s), chat updates (real-time), logs (1s) │ │
|
||||||
|
│ │ Push-to-talk: record/stop → transcribe → chat → TTS → play │ │
|
||||||
|
│ └──────────────────────────┬───────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────┼───────────────────────────────────────────┘
|
||||||
|
│ REST API + WebSocket
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FastAPI Backend (main.py) │
|
||||||
|
│ uvicorn :8000 │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caller Generation Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Session Reset / First Access to Caller Slot
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
_randomize_callers()
|
||||||
|
│ Assigns unique names (from 24M/24F pool) and voices (Inworld: 14M/11F, ElevenLabs: 14M/8F) to 10 slots
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
generate_caller_background(base)
|
||||||
|
│
|
||||||
|
├─ Demographics: age (from range), job (gendered pool), location
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────┘
|
||||||
|
│ ▼
|
||||||
|
│ pick_location()
|
||||||
|
│ 80% LOCATIONS_LOCAL (weighted: Animas, Lordsburg)
|
||||||
|
│ 20% LOCATIONS_OUT_OF_STATE
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ _get_town_from_location()
|
||||||
|
│ └─ TOWN_KNOWLEDGE[town]
|
||||||
|
│ 32 towns with real facts
|
||||||
|
│ "Only reference real places..."
|
||||||
|
│
|
||||||
|
├─ 70% → PROBLEMS (100+ templates)
|
||||||
|
│ Fill {affair_person}, {fantasy_subject}, etc. from PROBLEM_FILLS
|
||||||
|
│
|
||||||
|
├─ 30% → TOPIC_CALLIN (61 entries)
|
||||||
|
│ Prestige TV, science, poker, photography, physics, US news
|
||||||
|
│
|
||||||
|
├─ 2x random INTERESTS (86 entries: TV shows, science, tech, poker, etc.)
|
||||||
|
│
|
||||||
|
└─ 2x random QUIRKS (conversational style traits)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Result: "43, works IT for the city in Lordsburg. Just finished Severance
|
||||||
|
season 2... Follows JWST discoveries... Deflects with humor...
|
||||||
|
ABOUT WHERE THEY LIVE (Lordsburg): Small town on I-10, about 2,500
|
||||||
|
people... Only reference real places..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### News Enrichment (at pickup time)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/call/{key}
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
enrich_caller_background(background) ← 5s timeout, fails silently
|
||||||
|
│
|
||||||
|
├─ _extract_search_query(background)
|
||||||
|
│ ├─ Check _TOPIC_SEARCH_MAP (50+ keyword→query mappings)
|
||||||
|
│ │ "severance" → "Severance TV show"
|
||||||
|
│ │ "quantum" → "quantum physics research"
|
||||||
|
│ │ "poker" → "poker tournament"
|
||||||
|
│ │
|
||||||
|
│ └─ Fallback: extract keywords from problem sentence
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SearXNG (localhost:8888)
|
||||||
|
│ /search?q=...&format=json&categories=news
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
LLM summarizes headline+snippet → natural one-liner
|
||||||
|
│ "Recently read about how Severance ties up the Lumon mystery"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Appended to background: "..., and it's been on their mind."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Caller Conversation Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Host speaks (push-to-talk or type)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/record/start → record from input device
|
||||||
|
POST /api/record/stop → transcribe (Whisper @ 16kHz)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/chat { text }
|
||||||
|
│
|
||||||
|
├─ session.add_message("user", text)
|
||||||
|
│
|
||||||
|
├─ Build system prompt: get_caller_prompt()
|
||||||
|
│ ├─ Caller identity + background + town knowledge
|
||||||
|
│ ├─ Show history (summaries of previous callers)
|
||||||
|
│ ├─ Conversation summary (last 6 messages)
|
||||||
|
│ └─ HOW TO TALK rules (varied length, no rehashing, etc.)
|
||||||
|
│
|
||||||
|
├─ Last 10 messages → _normalize_messages_for_llm()
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
LLMService.generate(messages, system_prompt)
|
||||||
|
│
|
||||||
|
├─ OpenRouter: primary model (15s timeout)
|
||||||
|
├─ Fallback 1: gemini-flash-1.5 (10s)
|
||||||
|
├─ Fallback 2: gpt-4o-mini (10s)
|
||||||
|
├─ Fallback 3: llama-3.1-8b (10s)
|
||||||
|
└─ Last resort: "Sorry, I totally blanked out..."
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
clean_for_tts() → strip (actions), *gestures*, fix phonetics
|
||||||
|
ensure_complete_thought() → trim to last complete sentence
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Response returned to frontend
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/tts { text, voice_id }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
generate_speech(text, voice_id)
|
||||||
|
│
|
||||||
|
├─ Inworld (default cloud) ─┐
|
||||||
|
├─ ElevenLabs (cloud) │
|
||||||
|
├─ F5-TTS (local, cloned) ├─→ PCM audio bytes (24kHz)
|
||||||
|
├─ Kokoro MLX (local, fast) │
|
||||||
|
├─ ChatTTS / StyleTTS2 / etc. ─┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AudioService.play_caller_audio(bytes, 24000)
|
||||||
|
│
|
||||||
|
└─→ Output Device Channel 1 (caller TTS)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real Caller (Phone) Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Caller dials 208-439-LUKE
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
SignalWire routes to webhook
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/signalwire/voice
|
||||||
|
│
|
||||||
|
├─ If OFF AIR → play message + hangup
|
||||||
|
│
|
||||||
|
└─ If ON AIR → return BXML:
|
||||||
|
<Stream url="wss://.../api/signalwire/stream" codec="L16@16000h">
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
WebSocket /api/signalwire/stream connects
|
||||||
|
│
|
||||||
|
├─ "start" event → add to queue, play ring SFX
|
||||||
|
│ broadcast_event("caller_queued")
|
||||||
|
│
|
||||||
|
│ [Caller waits in queue until host takes them]
|
||||||
|
│
|
||||||
|
├─ Host clicks "Take Call" in UI
|
||||||
|
│ POST /api/queue/take/{caller_id}
|
||||||
|
│ └─ CallerService.take_call() → allocate channel
|
||||||
|
│ └─ Start host mic streaming → _host_audio_sender()
|
||||||
|
│
|
||||||
|
├─ "media" events (continuous) ← caller's voice
|
||||||
|
│ │
|
||||||
|
│ ├─ route_real_caller_audio(pcm) → Ch 9 (host monitoring)
|
||||||
|
│ │
|
||||||
|
│ └─ Buffer 3s chunks → transcribe (Whisper)
|
||||||
|
│ │
|
||||||
|
│ └─ broadcast_chat() → appears in chat window
|
||||||
|
│
|
||||||
|
│ Host mic audio → _host_audio_sync_callback()
|
||||||
|
│ │
|
||||||
|
│ └─ _host_audio_sender() → CallerService.send_audio_to_caller()
|
||||||
|
│ └─ base64 encode → WebSocket → SignalWire → caller's phone
|
||||||
|
│
|
||||||
|
│ If AI caller also active (auto-respond mode):
|
||||||
|
│ │
|
||||||
|
│ └─ _debounced_auto_respond() (4s silence)
|
||||||
|
│ └─ LLM → TTS → play on Ch 1 + stream to real caller
|
||||||
|
│
|
||||||
|
├─ Host hangs up
|
||||||
|
│ POST /api/hangup/real
|
||||||
|
│ └─ _signalwire_end_call(call_sid) → end phone call
|
||||||
|
│ └─ _summarize_real_call() → LLM summary → call_history
|
||||||
|
│ └─ Optional: _auto_followup() → pick AI caller to continue
|
||||||
|
│
|
||||||
|
└─ "stop" event or disconnect → cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audio Routing (Multi-Channel Output)
|
||||||
|
|
||||||
|
```
|
||||||
|
All audio goes to ONE physical output device (Loopback/interface)
|
||||||
|
Each content type on a separate channel for mixing in DAW/OBS
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Output Device (e.g. Loopback 16ch) │
|
||||||
|
│ │
|
||||||
|
│ Ch 1 ◄── Caller TTS (AI voices) play_caller_audio()
|
||||||
|
│ Ch 2 ◄── Music (loops) play_music()
|
||||||
|
│ Ch 3 ◄── Sound Effects (one-shots) play_sfx()
|
||||||
|
│ Ch 9 ◄── Live Caller Audio (monitoring) route_real_caller_audio()
|
||||||
|
│ Ch 11 ◄── Ads (one-shots, no loop) play_ad()
|
||||||
|
│ │
|
||||||
|
│ All channels configurable via Settings panel │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Input Device (mic/interface) │
|
||||||
|
│ │
|
||||||
|
│ Ch N ──► Host mic recording (push-to-talk) │
|
||||||
|
│ ──► Host mic streaming (to real callers via WS) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External Services
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ SignalWire │ │ OpenRouter │ │ SearXNG │
|
||||||
|
│ │ │ │ │ (local) │
|
||||||
|
│ Phone calls │ │ LLM API │ │ News search │
|
||||||
|
│ REST + WS │ │ Claude,GPT │ │ :8888 │
|
||||||
|
│ Bidirectional│ │ Gemini,Llama│ │ │
|
||||||
|
│ audio stream│ │ Fallback │ │ │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────┘
|
||||||
|
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||||
|
│ Inworld │ │ ElevenLabs │ │ Local TTS │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ TTS (cloud) │ │ TTS (cloud) │ │ Kokoro MLX │
|
||||||
|
│ Default │ │ Premium │ │ F5-TTS │
|
||||||
|
│ provider │ │ │ │ ChatTTS │
|
||||||
|
│ │ │ │ │ + others │
|
||||||
|
└──────────────┘ └──────────────┘ └──────────────┘
|
||||||
|
|
||||||
|
┌──────────────┐
|
||||||
|
│ Castopod │
|
||||||
|
│ │
|
||||||
|
│ Podcast │
|
||||||
|
│ publishing │
|
||||||
|
│ (NAS) │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
New Session (reset)
|
||||||
|
│
|
||||||
|
├─ Randomize all 10 caller names + voices
|
||||||
|
├─ Clear conversation, call history, research
|
||||||
|
├─ New session ID
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Show goes ON AIR (toggle)
|
||||||
|
│
|
||||||
|
├─ SignalWire starts accepting calls
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Caller interactions (loop)
|
||||||
|
│
|
||||||
|
├─ Pick AI caller (click button 0-9)
|
||||||
|
│ ├─ Generate background (if first time this session)
|
||||||
|
│ ├─ Enrich with news (SearXNG → LLM summary)
|
||||||
|
│ ├─ Conversation loop (chat/respond/auto-respond)
|
||||||
|
│ └─ Hangup → summarize → add to call_history
|
||||||
|
│
|
||||||
|
├─ Take real caller from queue
|
||||||
|
│ ├─ Route audio both directions
|
||||||
|
│ ├─ Transcribe caller speech in real-time
|
||||||
|
│ ├─ Optional: AI caller auto-responds to real caller
|
||||||
|
│ └─ Hangup → summarize → add to call_history
|
||||||
|
│
|
||||||
|
├─ Play music / ads / SFX between calls
|
||||||
|
│
|
||||||
|
└─ Each new caller sees show_history (summaries of all previous calls)
|
||||||
|
"EARLIER IN THE SHOW: Tony talked about... Carmen discussed..."
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Show goes OFF AIR
|
||||||
|
│
|
||||||
|
└─ Incoming calls get off-air message + hangup
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Patterns
|
||||||
|
|
||||||
|
| Pattern | Where | Why |
|
||||||
|
|---------|-------|-----|
|
||||||
|
| **Epoch-based staleness** | `_session_epoch` in main.py | Prevents stale LLM/TTS responses from playing after hangup |
|
||||||
|
| **Fallback chain** | LLMService | Guarantees a response even if primary model times out |
|
||||||
|
| **Debounced auto-respond** | `_debounced_auto_respond()` | Waits 4s for real caller to stop talking before AI jumps in |
|
||||||
|
| **Silent failure** | News enrichment | If search/LLM fails, caller just doesn't have news context |
|
||||||
|
| **Threading for audio** | `play_caller_audio()` | Audio playback can't block the async event loop |
|
||||||
|
| **Ring buffer** | `route_real_caller_audio()` | Absorbs jitter in real caller audio stream |
|
||||||
|
| **Lock contention guard** | `_ai_response_lock` | Only one AI response generates at a time |
|
||||||
|
| **Town knowledge injection** | `TOWN_KNOWLEDGE` dict | Prevents LLM from inventing fake local businesses |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
```
|
||||||
|
ai-podcast/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py ← FastAPI app, all endpoints, caller generation, session
|
||||||
|
│ ├── config.py ← Settings (env vars, paths)
|
||||||
|
│ └── services/
|
||||||
|
│ ├── audio.py ← Multi-channel audio I/O (sounddevice)
|
||||||
|
│ ├── caller_service.py← Phone queue, WebSocket registry, audio routing
|
||||||
|
│ ├── llm.py ← OpenRouter/Ollama with fallback chain
|
||||||
|
│ ├── news.py ← SearXNG search + caching
|
||||||
|
│ ├── tts.py ← 8 TTS providers (cloud + local)
|
||||||
|
│ └── transcription.py ← Whisper speech-to-text
|
||||||
|
├── frontend/
|
||||||
|
│ ├── index.html ← Control panel layout
|
||||||
|
│ ├── js/app.js ← UI logic, polling, event handlers
|
||||||
|
│ └── css/style.css ← Dark theme styling
|
||||||
|
├── sounds/ ← SFX files (ring, hangup, busy, etc.)
|
||||||
|
├── music/ ← Background music tracks
|
||||||
|
├── ads/ ← Ad audio files
|
||||||
|
├── website/ ← Landing page (lukeattheroost.com)
|
||||||
|
├── publish_episode.py ← Castopod episode publisher
|
||||||
|
└── run.sh ← Server launcher with restart support
|
||||||
|
```
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# Real Callers + AI Follow-Up Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add real phone callers to the AI Radio Show via Twilio, alongside existing AI callers. Real callers dial a phone number, wait in a hold queue, and get taken on air by the host. Three-way conversations between host, real caller, and AI caller are supported. AI follow-up callers automatically reference what real callers said.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Real callers connect via Twilio phone number
|
||||||
|
- Full-duplex audio — host and caller talk simultaneously, talk over each other
|
||||||
|
- Each real caller gets their own dedicated audio channel for recording
|
||||||
|
- Three-way calls: host + real caller + AI caller all live at once
|
||||||
|
- AI caller can respond manually (host-triggered) or automatically (listens and decides when to jump in)
|
||||||
|
- AI follow-up callers reference real caller conversations via show history
|
||||||
|
- Auto follow-up mode: system picks an AI caller and connects them after a real call
|
||||||
|
- Simple hold queue — callers wait with hold music, host sees list and picks who goes on air
|
||||||
|
- Twilio webhooks exposed via Cloudflare tunnel
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Audio Routing (Loopback Channels)
|
||||||
|
|
||||||
|
```
|
||||||
|
Ch 1: Host mic (existing)
|
||||||
|
Ch 2: AI callers / TTS (existing)
|
||||||
|
Ch 3+: Real callers (dynamically assigned per call)
|
||||||
|
Ch N-1: Music (existing)
|
||||||
|
Ch N: SFX (existing)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Call Flow — Real Caller
|
||||||
|
|
||||||
|
```
|
||||||
|
Caller dials Twilio number
|
||||||
|
→ Twilio POST /api/twilio/voice
|
||||||
|
→ TwiML response: greeting + enqueue with hold music
|
||||||
|
→ Caller waits in hold queue
|
||||||
|
→ Host sees caller in dashboard queue panel
|
||||||
|
→ Host clicks "Take Call"
|
||||||
|
→ POST /api/queue/take/{call_sid}
|
||||||
|
→ Twilio opens WebSocket to /api/twilio/stream
|
||||||
|
→ Bidirectional audio:
|
||||||
|
Caller audio → decode mulaw → dedicated Loopback channel
|
||||||
|
Host audio + AI TTS → encode mulaw → Twilio → caller hears both
|
||||||
|
→ Real-time Whisper transcription of caller audio
|
||||||
|
→ Host hangs up → call summarized → stored in show history
|
||||||
|
```
|
||||||
|
|
||||||
|
### Three-Way Call Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Host mic ──────→ Ch 1 (recording)
|
||||||
|
→ Twilio outbound (real caller hears you)
|
||||||
|
→ Whisper transcription (AI gets your words)
|
||||||
|
|
||||||
|
Real caller ──→ Ch 3+ (recording, dedicated channel)
|
||||||
|
→ Whisper transcription (AI gets their words)
|
||||||
|
→ Host headphones
|
||||||
|
|
||||||
|
AI TTS ───────→ Ch 2 (recording)
|
||||||
|
→ Twilio outbound (real caller hears AI)
|
||||||
|
→ Host headphones (already works)
|
||||||
|
```
|
||||||
|
|
||||||
|
Conversation history becomes three-party with role labels: `host`, `real_caller`, `ai_caller`.
|
||||||
|
|
||||||
|
### AI Auto-Respond Mode
|
||||||
|
|
||||||
|
When toggled on, after each real caller transcription chunk:
|
||||||
|
|
||||||
|
1. Lightweight LLM call ("should I respond?" — use fast model like Haiku)
|
||||||
|
2. If YES → full response generated → TTS → plays on AI channel + streams to Twilio
|
||||||
|
3. Cooldown (~10s) prevents rapid-fire
|
||||||
|
4. Host can override with mute button
|
||||||
|
|
||||||
|
### AI Follow-Up System
|
||||||
|
|
||||||
|
After a real caller hangs up:
|
||||||
|
|
||||||
|
1. Full transcript (host + real caller + any AI) summarized by LLM
|
||||||
|
2. Summary stored in `session.call_history`
|
||||||
|
3. Next AI caller's system prompt includes show history:
|
||||||
|
```
|
||||||
|
EARLIER IN THE SHOW:
|
||||||
|
- Dave (real caller) called about his wife leaving after 12 years.
|
||||||
|
He got emotional about his kids.
|
||||||
|
- Jasmine called about her boss hitting on her at work.
|
||||||
|
You can reference these if it feels natural. Don't force it.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Host-triggered (default):** Click any AI caller as normal. They already have show context.
|
||||||
|
|
||||||
|
**Auto mode:** After real caller hangs up, system waits ~5-10s, picks a fitting AI caller via short LLM call, biases their background generation toward the topic, auto-connects.
|
||||||
|
|
||||||
|
## Backend Changes
|
||||||
|
|
||||||
|
### New Module: `backend/services/twilio_service.py`
|
||||||
|
|
||||||
|
Manages Twilio integration:
|
||||||
|
- WebSocket handler for Media Streams (decode/encode mulaw 8kHz ↔ PCM)
|
||||||
|
- Call queue state (waiting callers, SIDs, timestamps, assigned channels)
|
||||||
|
- Channel pool management (allocate/release Loopback channels for real callers)
|
||||||
|
- Outbound audio mixing (host + AI TTS → mulaw → Twilio)
|
||||||
|
- Methods: `take_call()`, `hangup_real_caller()`, `get_queue()`, `send_audio_to_caller()`
|
||||||
|
|
||||||
|
### New Endpoints
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Twilio webhooks
|
||||||
|
POST /api/twilio/voice # Incoming call → TwiML (greet + enqueue)
|
||||||
|
POST /api/twilio/hold-music # Hold music TwiML for waiting callers
|
||||||
|
WS /api/twilio/stream # Media Streams WebSocket (bidirectional audio)
|
||||||
|
|
||||||
|
# Host controls
|
||||||
|
GET /api/queue # List waiting callers (number, wait time)
|
||||||
|
POST /api/queue/take/{call_sid} # Dequeue caller → start media stream
|
||||||
|
POST /api/queue/drop/{call_sid} # Drop caller from queue
|
||||||
|
|
||||||
|
# AI follow-up
|
||||||
|
POST /api/followup/generate # Summarize last real call, trigger AI follow-up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Model Changes
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CallRecord:
|
||||||
|
caller_type: str # "ai" or "real"
|
||||||
|
caller_name: str # "Tony" or "Caller #3"
|
||||||
|
summary: str # LLM-generated summary after hangup
|
||||||
|
transcript: list[dict] # Full conversation [{role, content}]
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
# Existing fields...
|
||||||
|
call_history: list[CallRecord] # All calls this episode
|
||||||
|
active_real_caller: dict | None # {call_sid, phone, channel, name}
|
||||||
|
active_ai_caller: str | None # Caller key
|
||||||
|
ai_respond_mode: str # "manual" or "auto"
|
||||||
|
auto_followup: bool # Auto-generate AI follow-up after real calls
|
||||||
|
```
|
||||||
|
|
||||||
|
Three-party conversation history uses roles: `host`, `real_caller:{name}`, `ai_caller:{name}`.
|
||||||
|
|
||||||
|
### AI Caller Prompt Changes
|
||||||
|
|
||||||
|
`get_caller_prompt()` extended to include:
|
||||||
|
- Show history from `session.call_history`
|
||||||
|
- Current real caller context (if three-way call active)
|
||||||
|
- Instructions for referencing real callers naturally
|
||||||
|
|
||||||
|
## Frontend Changes
|
||||||
|
|
||||||
|
### New: Call Queue Panel
|
||||||
|
|
||||||
|
Between callers section and chat. Shows waiting real callers with phone number and wait time. "Take Call" and "Drop" buttons per caller. Polls `/api/queue` every few seconds.
|
||||||
|
|
||||||
|
### Modified: Active Call Indicator
|
||||||
|
|
||||||
|
Shows real caller and AI caller simultaneously when both active:
|
||||||
|
- Real caller: name, channel number, call duration, hang up button
|
||||||
|
- AI caller: name, Manual/Auto toggle, "Let [name] respond" button (manual mode)
|
||||||
|
- Auto Follow-Up checkbox
|
||||||
|
|
||||||
|
### Modified: Chat Log
|
||||||
|
|
||||||
|
Three-party with visual distinction:
|
||||||
|
- Host messages: existing style
|
||||||
|
- Real caller: labeled "Dave (caller)", distinct color
|
||||||
|
- AI caller: labeled "Tony (AI)", distinct color
|
||||||
|
|
||||||
|
### Modified: Caller Grid
|
||||||
|
|
||||||
|
When real caller is active, clicking an AI caller adds them as third party instead of starting fresh call. Indicator shows which AI callers have been on the show this session.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `twilio` Python package (for TwiML generation, REST API)
|
||||||
|
- Twilio account with phone number (~$1.15/mo + per-minute)
|
||||||
|
- Cloudflare tunnel for exposing webhook endpoints
|
||||||
|
- `audioop` or equivalent for mulaw encode/decode (stdlib in Python 3.11)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
New env vars in `.env`:
|
||||||
|
```
|
||||||
|
TWILIO_ACCOUNT_SID=...
|
||||||
|
TWILIO_AUTH_TOKEN=...
|
||||||
|
TWILIO_PHONE_NUMBER=+1...
|
||||||
|
TWILIO_WEBHOOK_BASE_URL=https://your-tunnel.cloudflare.com
|
||||||
|
```
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# SignalWire Phone Call-In Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace browser-based WebSocket call-in with real phone calls via SignalWire. Callers dial 208-439-5853 and enter the show queue.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
SignalWire handles PSTN connectivity. When a call comes in, SignalWire hits our webhook, we return XML telling it to open a bidirectional WebSocket stream with L16@16kHz audio. The audio flows through our existing pipeline — same queue, channel allocation, transcription, host mic streaming, and TTS streaming.
|
||||||
|
|
||||||
|
## Call Flow
|
||||||
|
|
||||||
|
1. Caller dials 208-439-5853
|
||||||
|
2. SignalWire hits `POST /api/signalwire/voice` (via Cloudflare tunnel)
|
||||||
|
3. We return `<Connect><Stream codec="L16@16000h">` XML
|
||||||
|
4. SignalWire opens WebSocket to `/api/signalwire/stream`
|
||||||
|
5. Caller enters queue — host sees phone number on dashboard
|
||||||
|
6. Host takes call — audio flows bidirectionally
|
||||||
|
7. Host hangs up — we call SignalWire REST API to end the phone call
|
||||||
|
|
||||||
|
## Audio Path
|
||||||
|
|
||||||
|
```
|
||||||
|
Phone → PSTN → SignalWire → WebSocket (base64 L16 JSON) → Our server
|
||||||
|
Our server → WebSocket (base64 L16 JSON) → SignalWire → PSTN → Phone
|
||||||
|
```
|
||||||
|
|
||||||
|
## SignalWire WebSocket Protocol
|
||||||
|
|
||||||
|
Incoming: `{"event": "media", "media": {"payload": "<base64 L16 PCM 16kHz>"}}`
|
||||||
|
Outgoing: `{"event": "media", "media": {"payload": "<base64 L16 PCM 16kHz>"}}`
|
||||||
|
Start: `{"event": "start", "start": {"streamSid": "...", "callSid": "..."}}`
|
||||||
|
Stop: `{"event": "stop"}`
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Remove: browser call-in page, browser WebSocket handler
|
||||||
|
- Add: SignalWire webhook + WebSocket handler, hangup via REST API
|
||||||
|
- Modify: CallerService (name→phone, base64 JSON encoding for send), dashboard (show phone number)
|
||||||
|
- Unchanged: AudioService, queue logic, transcription, TTS streaming, three-way calls
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
```
|
||||||
|
SIGNALWIRE_PROJECT_ID=8eb54732-ade3-4487-8b40-ecd2cd680df7
|
||||||
|
SIGNALWIRE_SPACE=macneil-media-group-llc.signalwire.com
|
||||||
|
SIGNALWIRE_TOKEN=PT9c9b61f44ee49914c614fed32aa5c3d7b9372b5199d81dec
|
||||||
|
SIGNALWIRE_PHONE=+12084395853
|
||||||
|
```
|
||||||
|
|
||||||
|
Webhook URL: `https://radioshow.macneilmediagroup.com/api/signalwire/voice`
|
||||||
|
No SDK needed — httpx for the one REST call (hangup).
|
||||||
@@ -0,0 +1,855 @@
|
|||||||
|
# SignalWire Phone Call-In Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Replace browser-based WebSocket call-in with real phone calls via SignalWire (208-439-5853).
|
||||||
|
|
||||||
|
**Architecture:** SignalWire hits our webhook on inbound calls, we return XML to open a bidirectional WebSocket stream with L16@16kHz audio. The existing queue, channel allocation, transcription, host mic streaming, and TTS streaming are reused — only the WebSocket message format changes (base64 JSON instead of raw binary).
|
||||||
|
|
||||||
|
**Tech Stack:** Python/FastAPI, SignalWire Compatibility API (LaML XML + WebSocket), httpx for REST calls, existing audio pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add SignalWire Config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/config.py`
|
||||||
|
- Modify: `.env`
|
||||||
|
|
||||||
|
**Step 1: Add SignalWire settings to config.py**
|
||||||
|
|
||||||
|
In `backend/config.py`, add these fields to the `Settings` class after the existing API keys block (after line 16):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# SignalWire
|
||||||
|
signalwire_project_id: str = os.getenv("SIGNALWIRE_PROJECT_ID", "")
|
||||||
|
signalwire_space: str = os.getenv("SIGNALWIRE_SPACE", "")
|
||||||
|
signalwire_token: str = os.getenv("SIGNALWIRE_TOKEN", "")
|
||||||
|
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add SignalWire vars to .env**
|
||||||
|
|
||||||
|
Append to `.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
# SignalWire
|
||||||
|
SIGNALWIRE_PROJECT_ID=8eb54732-ade3-4487-8b40-ecd2cd680df7
|
||||||
|
SIGNALWIRE_SPACE=macneil-media-group-llc.signalwire.com
|
||||||
|
SIGNALWIRE_TOKEN=PT9c9b61f44ee49914c614fed32aa5c3d7b9372b5199d81dec
|
||||||
|
SIGNALWIRE_PHONE=+12084395853
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify config loads**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/lukemacneil/ai-podcast && python -c "from backend.config import settings; print(settings.signalwire_space)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `macneil-media-group-llc.signalwire.com`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/config.py .env
|
||||||
|
git commit -m "Add SignalWire configuration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Update CallerService for SignalWire Protocol
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/services/caller_service.py`
|
||||||
|
|
||||||
|
The CallerService currently sends raw binary PCM frames. SignalWire needs base64-encoded L16 PCM wrapped in JSON. Also swap `name` field to `phone` since callers now have phone numbers.
|
||||||
|
|
||||||
|
**Step 1: Update queue to use `phone` instead of `name`**
|
||||||
|
|
||||||
|
In `caller_service.py`, make these changes:
|
||||||
|
|
||||||
|
1. Update docstring (line 1): `"""Phone caller queue and audio stream service"""`
|
||||||
|
|
||||||
|
2. In `add_to_queue` (line 24): Change parameter `name` to `phone`, and update the dict:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def add_to_queue(self, caller_id: str, phone: str):
|
||||||
|
with self._lock:
|
||||||
|
self._queue.append({
|
||||||
|
"caller_id": caller_id,
|
||||||
|
"phone": phone,
|
||||||
|
"queued_at": time.time(),
|
||||||
|
})
|
||||||
|
print(f"[Caller] {phone} added to queue (ID: {caller_id})")
|
||||||
|
```
|
||||||
|
|
||||||
|
3. In `get_queue` (line 38): Return `phone` instead of `name`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_queue(self) -> list[dict]:
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"caller_id": c["caller_id"],
|
||||||
|
"phone": c["phone"],
|
||||||
|
"wait_time": int(now - c["queued_at"]),
|
||||||
|
}
|
||||||
|
for c in self._queue
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
4. In `take_call` (line 62): Use `phone` instead of `name`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def take_call(self, caller_id: str) -> dict:
|
||||||
|
caller = None
|
||||||
|
with self._lock:
|
||||||
|
for c in self._queue:
|
||||||
|
if c["caller_id"] == caller_id:
|
||||||
|
caller = c
|
||||||
|
break
|
||||||
|
if caller:
|
||||||
|
self._queue = [c for c in self._queue if c["caller_id"] != caller_id]
|
||||||
|
|
||||||
|
if not caller:
|
||||||
|
raise ValueError(f"Caller {caller_id} not in queue")
|
||||||
|
|
||||||
|
channel = self.allocate_channel()
|
||||||
|
self._caller_counter += 1
|
||||||
|
phone = caller["phone"]
|
||||||
|
|
||||||
|
call_info = {
|
||||||
|
"caller_id": caller_id,
|
||||||
|
"phone": phone,
|
||||||
|
"channel": channel,
|
||||||
|
"started_at": time.time(),
|
||||||
|
}
|
||||||
|
self.active_calls[caller_id] = call_info
|
||||||
|
print(f"[Caller] {phone} taken on air — channel {channel}")
|
||||||
|
return call_info
|
||||||
|
```
|
||||||
|
|
||||||
|
5. In `hangup` (line 89): Use `phone` instead of `name`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def hangup(self, caller_id: str):
|
||||||
|
call_info = self.active_calls.pop(caller_id, None)
|
||||||
|
if call_info:
|
||||||
|
self.release_channel(call_info["channel"])
|
||||||
|
print(f"[Caller] {call_info['phone']} hung up — channel {call_info['channel']} released")
|
||||||
|
self._websockets.pop(caller_id, None)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update `send_audio_to_caller` for SignalWire JSON format**
|
||||||
|
|
||||||
|
Replace the existing `send_audio_to_caller` method with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def send_audio_to_caller(self, caller_id: str, pcm_data: bytes, sample_rate: int):
|
||||||
|
"""Send small audio chunk to caller via SignalWire WebSocket.
|
||||||
|
Encodes L16 PCM as base64 JSON per SignalWire protocol.
|
||||||
|
"""
|
||||||
|
ws = self._websockets.get(caller_id)
|
||||||
|
if not ws:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
if sample_rate != 16000:
|
||||||
|
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
ratio = 16000 / sample_rate
|
||||||
|
out_len = int(len(audio) * ratio)
|
||||||
|
indices = (np.arange(out_len) / ratio).astype(int)
|
||||||
|
indices = np.clip(indices, 0, len(audio) - 1)
|
||||||
|
audio = audio[indices]
|
||||||
|
pcm_data = (audio * 32767).astype(np.int16).tobytes()
|
||||||
|
|
||||||
|
payload = base64.b64encode(pcm_data).decode('ascii')
|
||||||
|
import json
|
||||||
|
await ws.send_text(json.dumps({
|
||||||
|
"event": "media",
|
||||||
|
"media": {"payload": payload}
|
||||||
|
}))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Caller] Failed to send audio: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Update `stream_audio_to_caller` for SignalWire JSON format**
|
||||||
|
|
||||||
|
Replace the existing `stream_audio_to_caller` method with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def stream_audio_to_caller(self, caller_id: str, pcm_data: bytes, sample_rate: int):
|
||||||
|
"""Stream large audio (TTS) to caller in real-time chunks via SignalWire WebSocket."""
|
||||||
|
ws = self._websockets.get(caller_id)
|
||||||
|
if not ws:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.streaming_tts = True
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
audio = np.frombuffer(pcm_data, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
|
if sample_rate != 16000:
|
||||||
|
ratio = 16000 / sample_rate
|
||||||
|
out_len = int(len(audio) * ratio)
|
||||||
|
indices = (np.arange(out_len) / ratio).astype(int)
|
||||||
|
indices = np.clip(indices, 0, len(audio) - 1)
|
||||||
|
audio = audio[indices]
|
||||||
|
|
||||||
|
chunk_samples = 960
|
||||||
|
for i in range(0, len(audio), chunk_samples):
|
||||||
|
if caller_id not in self._websockets:
|
||||||
|
break
|
||||||
|
chunk = audio[i:i + chunk_samples]
|
||||||
|
pcm_chunk = (chunk * 32767).astype(np.int16).tobytes()
|
||||||
|
payload = base64.b64encode(pcm_chunk).decode('ascii')
|
||||||
|
await ws.send_text(json.dumps({
|
||||||
|
"event": "media",
|
||||||
|
"media": {"payload": payload}
|
||||||
|
}))
|
||||||
|
await asyncio.sleep(0.055)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Caller] Failed to stream audio: {e}")
|
||||||
|
finally:
|
||||||
|
self.streaming_tts = False
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Remove `notify_caller` and `disconnect_caller` methods**
|
||||||
|
|
||||||
|
These sent browser-specific JSON control messages. SignalWire callers are disconnected via REST API (handled in main.py). Delete methods `notify_caller` (line 168) and `disconnect_caller` (line 175). They will be replaced with a REST-based hangup in Task 4.
|
||||||
|
|
||||||
|
**Step 5: Add `call_sid` tracking for SignalWire call hangup**
|
||||||
|
|
||||||
|
Add a dict to track SignalWire call SIDs so we can end calls via REST:
|
||||||
|
|
||||||
|
In `__init__`, after `self._websockets` line, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._call_sids: dict[str, str] = {} # caller_id -> SignalWire callSid
|
||||||
|
```
|
||||||
|
|
||||||
|
Add methods:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def register_call_sid(self, caller_id: str, call_sid: str):
|
||||||
|
"""Track SignalWire callSid for a caller"""
|
||||||
|
self._call_sids[caller_id] = call_sid
|
||||||
|
|
||||||
|
def get_call_sid(self, caller_id: str) -> str | None:
|
||||||
|
"""Get SignalWire callSid for a caller"""
|
||||||
|
return self._call_sids.get(caller_id)
|
||||||
|
|
||||||
|
def unregister_call_sid(self, caller_id: str):
|
||||||
|
"""Remove callSid tracking"""
|
||||||
|
self._call_sids.pop(caller_id, None)
|
||||||
|
```
|
||||||
|
|
||||||
|
In `reset`, also clear `self._call_sids`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._call_sids.clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
In `hangup`, also clean up call_sid:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._call_sids.pop(caller_id, None)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Run existing tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/lukemacneil/ai-podcast && python -m pytest tests/test_caller_service.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests will likely need updates due to `name` → `phone` rename. Fix any failures.
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/services/caller_service.py
|
||||||
|
git commit -m "Update CallerService for SignalWire protocol"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Add SignalWire Voice Webhook
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py`
|
||||||
|
|
||||||
|
**Step 1: Add the voice webhook endpoint**
|
||||||
|
|
||||||
|
Add after the existing route definitions (after line 421), replacing the `/call-in` route:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# --- SignalWire Endpoints ---
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
|
||||||
|
@app.post("/api/signalwire/voice")
|
||||||
|
async def signalwire_voice_webhook(request: Request):
|
||||||
|
"""Handle inbound call from SignalWire — return XML to start bidirectional stream"""
|
||||||
|
form = await request.form()
|
||||||
|
caller_phone = form.get("From", "Unknown")
|
||||||
|
call_sid = form.get("CallSid", "")
|
||||||
|
print(f"[SignalWire] Inbound call from {caller_phone} (CallSid: {call_sid})")
|
||||||
|
|
||||||
|
# Build WebSocket URL from the request
|
||||||
|
ws_scheme = "wss"
|
||||||
|
host = request.headers.get("host", "radioshow.macneilmediagroup.com")
|
||||||
|
stream_url = f"{ws_scheme}://{host}/api/signalwire/stream"
|
||||||
|
|
||||||
|
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Response>
|
||||||
|
<Connect>
|
||||||
|
<Stream url="{stream_url}" codec="L16@16000h">
|
||||||
|
<Parameter name="caller_phone" value="{caller_phone}"/>
|
||||||
|
<Parameter name="call_sid" value="{call_sid}"/>
|
||||||
|
</Stream>
|
||||||
|
</Connect>
|
||||||
|
</Response>"""
|
||||||
|
|
||||||
|
return Response(content=xml, media_type="application/xml")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Remove the `/call-in` route**
|
||||||
|
|
||||||
|
Delete these lines (around line 419-421):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/call-in")
|
||||||
|
async def call_in_page():
|
||||||
|
return FileResponse(frontend_dir / "call-in.html")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify server starts**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/lukemacneil/ai-podcast && python -c "from backend.main import app; print('OK')"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Add SignalWire voice webhook, remove call-in route"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add SignalWire WebSocket Stream Handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py`
|
||||||
|
|
||||||
|
This replaces the browser caller WebSocket handler at `/api/caller/stream`.
|
||||||
|
|
||||||
|
**Step 1: Replace the browser WebSocket handler**
|
||||||
|
|
||||||
|
Delete the entire `caller_audio_stream` function (the `@app.websocket("/api/caller/stream")` handler, lines 807-887).
|
||||||
|
|
||||||
|
Add the new SignalWire WebSocket handler:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.websocket("/api/signalwire/stream")
|
||||||
|
async def signalwire_audio_stream(websocket: WebSocket):
|
||||||
|
"""Handle SignalWire bidirectional audio stream"""
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
caller_id = str(uuid.uuid4())[:8]
|
||||||
|
caller_phone = "Unknown"
|
||||||
|
call_sid = ""
|
||||||
|
audio_buffer = bytearray()
|
||||||
|
CHUNK_DURATION_S = 3
|
||||||
|
SAMPLE_RATE = 16000
|
||||||
|
chunk_samples = CHUNK_DURATION_S * SAMPLE_RATE
|
||||||
|
stream_started = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
raw = await websocket.receive_text()
|
||||||
|
msg = json.loads(raw)
|
||||||
|
event = msg.get("event")
|
||||||
|
|
||||||
|
if event == "start":
|
||||||
|
# Extract caller info from stream parameters
|
||||||
|
params = {}
|
||||||
|
for p in msg.get("start", {}).get("customParameters", {}):
|
||||||
|
pass
|
||||||
|
# customParameters comes as a dict
|
||||||
|
custom = msg.get("start", {}).get("customParameters", {})
|
||||||
|
caller_phone = custom.get("caller_phone", "Unknown")
|
||||||
|
call_sid = custom.get("call_sid", "")
|
||||||
|
|
||||||
|
stream_started = True
|
||||||
|
print(f"[SignalWire WS] Stream started: {caller_phone} (CallSid: {call_sid})")
|
||||||
|
|
||||||
|
# Add to queue and register
|
||||||
|
caller_service.add_to_queue(caller_id, caller_phone)
|
||||||
|
caller_service.register_websocket(caller_id, websocket)
|
||||||
|
if call_sid:
|
||||||
|
caller_service.register_call_sid(caller_id, call_sid)
|
||||||
|
|
||||||
|
elif event == "media" and stream_started:
|
||||||
|
# Decode base64 L16 PCM audio
|
||||||
|
import base64
|
||||||
|
payload = msg.get("media", {}).get("payload", "")
|
||||||
|
if not payload:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pcm_data = base64.b64decode(payload)
|
||||||
|
|
||||||
|
# Only process audio if caller is on air
|
||||||
|
call_info = caller_service.active_calls.get(caller_id)
|
||||||
|
if not call_info:
|
||||||
|
continue
|
||||||
|
|
||||||
|
audio_buffer.extend(pcm_data)
|
||||||
|
|
||||||
|
# Route to configured live caller Loopback channel
|
||||||
|
audio_service.route_real_caller_audio(pcm_data, SAMPLE_RATE)
|
||||||
|
|
||||||
|
# Transcribe when we have enough audio
|
||||||
|
if len(audio_buffer) >= chunk_samples * 2:
|
||||||
|
pcm_chunk = bytes(audio_buffer[:chunk_samples * 2])
|
||||||
|
audio_buffer = audio_buffer[chunk_samples * 2:]
|
||||||
|
asyncio.create_task(
|
||||||
|
_handle_real_caller_transcription(caller_id, pcm_chunk, SAMPLE_RATE)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event == "stop":
|
||||||
|
print(f"[SignalWire WS] Stream stopped: {caller_phone}")
|
||||||
|
break
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
print(f"[SignalWire WS] Disconnected: {caller_id} ({caller_phone})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SignalWire WS] Error: {e}")
|
||||||
|
finally:
|
||||||
|
caller_service.unregister_websocket(caller_id)
|
||||||
|
caller_service.unregister_call_sid(caller_id)
|
||||||
|
caller_service.remove_from_queue(caller_id)
|
||||||
|
if caller_id in caller_service.active_calls:
|
||||||
|
caller_service.hangup(caller_id)
|
||||||
|
if session.active_real_caller and session.active_real_caller.get("caller_id") == caller_id:
|
||||||
|
session.active_real_caller = None
|
||||||
|
if len(caller_service.active_calls) == 0:
|
||||||
|
audio_service.stop_host_stream()
|
||||||
|
if audio_buffer:
|
||||||
|
asyncio.create_task(
|
||||||
|
_handle_real_caller_transcription(caller_id, bytes(audio_buffer), SAMPLE_RATE)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Add SignalWire WebSocket stream handler, remove browser handler"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Update Hangup and Queue Endpoints for SignalWire
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py`
|
||||||
|
|
||||||
|
When the host hangs up or drops a caller, we need to end the actual phone call via SignalWire's REST API.
|
||||||
|
|
||||||
|
**Step 1: Add SignalWire hangup helper**
|
||||||
|
|
||||||
|
Add this function near the top of `main.py` (after imports):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _signalwire_end_call(call_sid: str):
|
||||||
|
"""End a phone call via SignalWire REST API"""
|
||||||
|
if not call_sid or not settings.signalwire_space:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
url = f"https://{settings.signalwire_space}/api/laml/2010-04-01/Accounts/{settings.signalwire_project_id}/Calls/{call_sid}"
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
data={"Status": "completed"},
|
||||||
|
auth=(settings.signalwire_project_id, settings.signalwire_token),
|
||||||
|
)
|
||||||
|
print(f"[SignalWire] End call {call_sid}: {response.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SignalWire] Failed to end call {call_sid}: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
Also add `import httpx` at the top of main.py if not already present.
|
||||||
|
|
||||||
|
**Step 2: Update `take_call_from_queue`**
|
||||||
|
|
||||||
|
In the `take_call_from_queue` endpoint, update `name` references to `phone`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/api/queue/take/{caller_id}")
|
||||||
|
async def take_call_from_queue(caller_id: str):
|
||||||
|
"""Take a caller off hold and put them on air"""
|
||||||
|
try:
|
||||||
|
call_info = caller_service.take_call(caller_id)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(404, str(e))
|
||||||
|
|
||||||
|
session.active_real_caller = {
|
||||||
|
"caller_id": call_info["caller_id"],
|
||||||
|
"channel": call_info["channel"],
|
||||||
|
"phone": call_info["phone"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start host mic streaming if this is the first real caller
|
||||||
|
if len(caller_service.active_calls) == 1:
|
||||||
|
_start_host_audio_sender()
|
||||||
|
audio_service.start_host_stream(_host_audio_sync_callback)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "on_air",
|
||||||
|
"caller": call_info,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The `notify_caller` call is removed — SignalWire callers don't need a JSON status message, they're already connected via the phone.
|
||||||
|
|
||||||
|
**Step 3: Update `drop_from_queue`**
|
||||||
|
|
||||||
|
End the phone call when dropping:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/api/queue/drop/{caller_id}")
|
||||||
|
async def drop_from_queue(caller_id: str):
|
||||||
|
"""Drop a caller from the queue"""
|
||||||
|
call_sid = caller_service.get_call_sid(caller_id)
|
||||||
|
caller_service.remove_from_queue(caller_id)
|
||||||
|
if call_sid:
|
||||||
|
await _signalwire_end_call(call_sid)
|
||||||
|
return {"status": "dropped"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Update `hangup_real_caller`**
|
||||||
|
|
||||||
|
End the phone call when hanging up:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/api/hangup/real")
|
||||||
|
async def hangup_real_caller():
|
||||||
|
"""Hang up on real caller — disconnect immediately, summarize in background"""
|
||||||
|
if not session.active_real_caller:
|
||||||
|
raise HTTPException(400, "No active real caller")
|
||||||
|
|
||||||
|
caller_id = session.active_real_caller["caller_id"]
|
||||||
|
caller_phone = session.active_real_caller["phone"]
|
||||||
|
conversation_snapshot = list(session.conversation)
|
||||||
|
auto_followup_enabled = session.auto_followup
|
||||||
|
|
||||||
|
# End the phone call via SignalWire
|
||||||
|
call_sid = caller_service.get_call_sid(caller_id)
|
||||||
|
caller_service.hangup(caller_id)
|
||||||
|
if call_sid:
|
||||||
|
asyncio.create_task(_signalwire_end_call(call_sid))
|
||||||
|
|
||||||
|
# Stop host streaming if no more active callers
|
||||||
|
if len(caller_service.active_calls) == 0:
|
||||||
|
audio_service.stop_host_stream()
|
||||||
|
|
||||||
|
session.active_real_caller = None
|
||||||
|
|
||||||
|
# Play hangup sound in background
|
||||||
|
import threading
|
||||||
|
hangup_sound = settings.sounds_dir / "hangup.wav"
|
||||||
|
if hangup_sound.exists():
|
||||||
|
threading.Thread(target=audio_service.play_sfx, args=(str(hangup_sound),), daemon=True).start()
|
||||||
|
|
||||||
|
# Summarize and store history in background
|
||||||
|
asyncio.create_task(
|
||||||
|
_summarize_real_call(caller_phone, conversation_snapshot, auto_followup_enabled)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "disconnected",
|
||||||
|
"caller": caller_phone,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Update `_handle_real_caller_transcription`**
|
||||||
|
|
||||||
|
Change `caller_name` to `caller_phone`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _handle_real_caller_transcription(caller_id: str, pcm_data: bytes, sample_rate: int):
|
||||||
|
"""Transcribe a chunk of real caller audio and add to conversation"""
|
||||||
|
call_info = caller_service.active_calls.get(caller_id)
|
||||||
|
if not call_info:
|
||||||
|
return
|
||||||
|
|
||||||
|
text = await transcribe_audio(pcm_data, source_sample_rate=sample_rate)
|
||||||
|
if not text or not text.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
caller_phone = call_info["phone"]
|
||||||
|
print(f"[Real Caller] {caller_phone}: {text}")
|
||||||
|
|
||||||
|
session.add_message(f"real_caller:{caller_phone}", text)
|
||||||
|
|
||||||
|
if session.ai_respond_mode == "auto" and session.current_caller_key:
|
||||||
|
asyncio.create_task(_check_ai_auto_respond(text, caller_phone))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Update `_summarize_real_call`**
|
||||||
|
|
||||||
|
Change `caller_name` parameter to `caller_phone`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _summarize_real_call(caller_phone: str, conversation: list, auto_followup_enabled: bool):
|
||||||
|
"""Background task: summarize call and store in history"""
|
||||||
|
summary = ""
|
||||||
|
if conversation:
|
||||||
|
transcript_text = "\n".join(
|
||||||
|
f"{msg['role']}: {msg['content']}" for msg in conversation
|
||||||
|
)
|
||||||
|
summary = await llm_service.generate(
|
||||||
|
messages=[{"role": "user", "content": f"Summarize this radio show call in 1-2 sentences:\n{transcript_text}"}],
|
||||||
|
system_prompt="You summarize radio show conversations concisely. Focus on what the caller talked about and any emotional moments.",
|
||||||
|
)
|
||||||
|
|
||||||
|
session.call_history.append(CallRecord(
|
||||||
|
caller_type="real",
|
||||||
|
caller_name=caller_phone,
|
||||||
|
summary=summary,
|
||||||
|
transcript=conversation,
|
||||||
|
))
|
||||||
|
print(f"[Real Caller] {caller_phone} call summarized: {summary[:80]}...")
|
||||||
|
|
||||||
|
if auto_followup_enabled:
|
||||||
|
await _auto_followup(summary)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 7: Update `_check_ai_auto_respond`**
|
||||||
|
|
||||||
|
Change parameter name from `real_caller_name` to `real_caller_phone`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _check_ai_auto_respond(real_caller_text: str, real_caller_phone: str):
|
||||||
|
```
|
||||||
|
|
||||||
|
(The body doesn't use the name/phone parameter in any way that needs changing.)
|
||||||
|
|
||||||
|
**Step 8: Update TTS streaming references**
|
||||||
|
|
||||||
|
In `text_to_speech` endpoint and `_check_ai_auto_respond`, the `session.active_real_caller` dict now uses `phone` instead of `name`. No code change needed for the TTS streaming since it only uses `caller_id`.
|
||||||
|
|
||||||
|
**Step 9: Verify server starts**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/lukemacneil/ai-podcast && python -c "from backend.main import app; print('OK')"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Update hangup and queue endpoints for SignalWire REST API"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Update Frontend for Phone Callers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/js/app.js`
|
||||||
|
- Modify: `frontend/index.html`
|
||||||
|
|
||||||
|
**Step 1: Update queue rendering in app.js**
|
||||||
|
|
||||||
|
In `renderQueue` function (around line 875), change `caller.name` to `caller.phone`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
el.innerHTML = queue.map(caller => {
|
||||||
|
const mins = Math.floor(caller.wait_time / 60);
|
||||||
|
const secs = caller.wait_time % 60;
|
||||||
|
const waitStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
||||||
|
return `
|
||||||
|
<div class="queue-item">
|
||||||
|
<span class="queue-name">${caller.phone}</span>
|
||||||
|
<span class="queue-wait">waiting ${waitStr}</span>
|
||||||
|
<button class="queue-take-btn" onclick="takeCall('${caller.caller_id}')">Take Call</button>
|
||||||
|
<button class="queue-drop-btn" onclick="dropCall('${caller.caller_id}')">Drop</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update `takeCall` log message**
|
||||||
|
|
||||||
|
In `takeCall` function (around line 896), change `data.caller.name` to `data.caller.phone`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (data.status === 'on_air') {
|
||||||
|
showRealCaller(data.caller);
|
||||||
|
log(`${data.caller.phone} is on air — Channel ${data.caller.channel}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Update `showRealCaller` to use phone**
|
||||||
|
|
||||||
|
In `showRealCaller` function (around line 939):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function showRealCaller(callerInfo) {
|
||||||
|
const nameEl = document.getElementById('real-caller-name');
|
||||||
|
const chEl = document.getElementById('real-caller-channel');
|
||||||
|
if (nameEl) nameEl.textContent = callerInfo.phone;
|
||||||
|
if (chEl) chEl.textContent = `Ch ${callerInfo.channel}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Update index.html queue section header**
|
||||||
|
|
||||||
|
In `frontend/index.html`, change the queue section header (line 56) — remove the call-in page link:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section class="queue-section">
|
||||||
|
<h2>Incoming Calls</h2>
|
||||||
|
<div id="call-queue" class="call-queue">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Bump cache version in index.html**
|
||||||
|
|
||||||
|
Find the app.js script tag and bump the version:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="/js/app.js?v=13"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/js/app.js frontend/index.html
|
||||||
|
git commit -m "Update frontend for phone caller display"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Remove Browser Call-In Files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Delete: `frontend/call-in.html`
|
||||||
|
- Delete: `frontend/js/call-in.js`
|
||||||
|
|
||||||
|
**Step 1: Delete files**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/lukemacneil/ai-podcast && rm frontend/call-in.html frontend/js/call-in.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/call-in.html frontend/js/call-in.js
|
||||||
|
git commit -m "Remove browser call-in page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Update Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/test_caller_service.py`
|
||||||
|
|
||||||
|
**Step 1: Update tests for `name` → `phone` rename**
|
||||||
|
|
||||||
|
Throughout `test_caller_service.py`, change:
|
||||||
|
- `add_to_queue(caller_id, "TestName")` → `add_to_queue(caller_id, "+15551234567")`
|
||||||
|
- `caller["name"]` → `caller["phone"]`
|
||||||
|
- `call_info["name"]` → `call_info["phone"]`
|
||||||
|
|
||||||
|
Also remove any tests for `notify_caller` or `disconnect_caller` if they exist, since those methods were removed.
|
||||||
|
|
||||||
|
**Step 2: Run all tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/lukemacneil/ai-podcast && python -m pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All pass.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/
|
||||||
|
git commit -m "Update tests for SignalWire phone caller format"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Configure SignalWire Webhook and End-to-End Test
|
||||||
|
|
||||||
|
**Step 1: Start the server**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/lukemacneil/ai-podcast && python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify webhook endpoint responds**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/signalwire/voice \
|
||||||
|
-d "From=+15551234567&CallSid=test123" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: XML response with `<Connect><Stream>` containing the WebSocket URL.
|
||||||
|
|
||||||
|
**Step 3: Verify Cloudflare tunnel is running**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://radioshow.macneilmediagroup.com/api/server/status
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: JSON response with `"status": "running"`.
|
||||||
|
|
||||||
|
**Step 4: Configure SignalWire webhook**
|
||||||
|
|
||||||
|
In the SignalWire dashboard:
|
||||||
|
1. Go to Phone Numbers → 208-439-5853
|
||||||
|
2. Set "When a call comes in" to: `https://radioshow.macneilmediagroup.com/api/signalwire/voice`
|
||||||
|
3. Method: POST
|
||||||
|
4. Handler type: LaML Webhooks
|
||||||
|
|
||||||
|
**Step 5: Test with a real call**
|
||||||
|
|
||||||
|
Call 208-439-5853 from a phone. Expected:
|
||||||
|
1. Call connects (no ringing/hold — goes straight to stream)
|
||||||
|
2. Caller appears in queue on host dashboard with phone number
|
||||||
|
3. Host clicks "Take Call" → audio flows bidirectionally
|
||||||
|
4. Host clicks "Hang Up" → phone call ends
|
||||||
|
|
||||||
|
**Step 6: Commit any fixes needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "Final SignalWire integration fixes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Task | What | Key Files |
|
||||||
|
|------|------|-----------|
|
||||||
|
| 1 | SignalWire config | `config.py`, `.env` |
|
||||||
|
| 2 | CallerService protocol update | `caller_service.py` |
|
||||||
|
| 3 | Voice webhook endpoint | `main.py` |
|
||||||
|
| 4 | WebSocket stream handler | `main.py` |
|
||||||
|
| 5 | Hangup/queue via REST API | `main.py` |
|
||||||
|
| 6 | Frontend phone display | `app.js`, `index.html` |
|
||||||
|
| 7 | Remove browser call-in | `call-in.html`, `call-in.js` |
|
||||||
|
| 8 | Update tests | `tests/` |
|
||||||
|
| 9 | Configure & test | SignalWire dashboard |
|
||||||
|
|
||||||
|
Tasks 1-5 are sequential backend. Task 6-7 are frontend (can parallel after task 5). Task 8 after task 2. Task 9 is final integration test.
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
/* AI Radio Show - Clean CSS */
|
/* AI Radio Show - Control Panel */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #1a1a2e;
|
--bg: #1a1209;
|
||||||
--bg-light: #252547;
|
--bg-light: #2a2015;
|
||||||
--accent: #e94560;
|
--bg-dark: #110c05;
|
||||||
--text: #fff;
|
--accent: #e8791d;
|
||||||
--text-muted: #888;
|
--accent-hover: #f59a4a;
|
||||||
--radius: 8px;
|
--accent-red: #cc2222;
|
||||||
|
--accent-green: #5a8a3c;
|
||||||
|
--text: #f5f0e5;
|
||||||
|
--text-muted: #9a8b78;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-sm: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -16,7 +21,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -38,6 +43,8 @@ header {
|
|||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-buttons {
|
.header-buttons {
|
||||||
@@ -48,14 +55,67 @@ header h1 {
|
|||||||
header button {
|
header button {
|
||||||
background: var(--bg-light);
|
background: var(--bg-light);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: none;
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
header button:hover {
|
||||||
|
background: #3a2e1f;
|
||||||
|
border-color: rgba(232, 121, 29, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.on-air-btn {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.on-air-btn.off {
|
||||||
|
background: #4a3d2e !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.on-air-btn.on {
|
||||||
|
background: var(--accent-red) !important;
|
||||||
|
border-color: var(--accent-red) !important;
|
||||||
|
animation: on-air-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes on-air-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-btn {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #4a3d2e !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-btn.recording {
|
||||||
|
background: var(--accent-red) !important;
|
||||||
|
color: var(--text) !important;
|
||||||
|
border-color: var(--accent-red) !important;
|
||||||
|
animation: on-air-pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-session-btn {
|
.new-session-btn {
|
||||||
background: var(--accent) !important;
|
background: var(--accent) !important;
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-session-btn:hover {
|
||||||
|
background: var(--accent-hover) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-id {
|
.session-id {
|
||||||
@@ -64,17 +124,29 @@ header button {
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.caller-background {
|
details.caller-background {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
padding: 10px;
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.caller-background.hidden {
|
details.caller-background summary {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.caller-background > div {
|
||||||
|
padding: 0 10px 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.caller-background.hidden {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +168,14 @@ section {
|
|||||||
background: var(--bg-light);
|
background: var(--bg-light);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
section h2 {
|
section h2 {
|
||||||
font-size: 1rem;
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
@@ -117,7 +193,7 @@ section h2 {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
padding: 10px 8px;
|
padding: 10px 8px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@@ -125,11 +201,13 @@ section h2 {
|
|||||||
|
|
||||||
.caller-btn:hover {
|
.caller-btn:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
background: #2a1e10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.caller-btn.active {
|
.caller-btn.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.call-status {
|
.call-status {
|
||||||
@@ -141,13 +219,18 @@ section h2 {
|
|||||||
|
|
||||||
.hangup-btn {
|
.hangup-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #c0392b;
|
background: var(--accent-red);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hangup-btn:hover {
|
||||||
|
background: #e03030;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hangup-btn:disabled {
|
.hangup-btn:disabled {
|
||||||
@@ -169,25 +252,26 @@ section h2 {
|
|||||||
.chat-log {
|
.chat-log {
|
||||||
height: 300px;
|
height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--bg);
|
background: var(--bg-dark);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.host {
|
.message.host {
|
||||||
background: #2c5282;
|
background: #3a2510;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.caller {
|
.message.caller {
|
||||||
background: #553c9a;
|
background: #2a1a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message strong {
|
.message strong {
|
||||||
@@ -208,7 +292,7 @@ section h2 {
|
|||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -216,11 +300,11 @@ section h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.talk-btn:hover {
|
.talk-btn:hover {
|
||||||
filter: brightness(1.1);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.talk-btn.recording {
|
.talk-btn.recording {
|
||||||
background: #c0392b;
|
background: var(--accent-red);
|
||||||
animation: pulse 1s infinite;
|
animation: pulse 1s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,10 +316,15 @@ section h2 {
|
|||||||
.type-btn {
|
.type-btn {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: none;
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
@@ -255,8 +344,8 @@ section h2 {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: none;
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,29 +358,46 @@ section h2 {
|
|||||||
.music-controls button {
|
.music-controls button {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: none;
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-controls button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: #2a1e10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-controls input[type="range"] {
|
.music-controls input[type="range"] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Soundboard */
|
/* Soundboard */
|
||||||
|
.sounds-section {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.sounds-section {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.soundboard {
|
.soundboard {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sound-btn {
|
.sound-btn {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: none;
|
border: 1px solid rgba(232, 121, 29, 0.1);
|
||||||
padding: 12px 8px;
|
padding: 12px 8px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
transition: all 0.1s;
|
transition: all 0.1s;
|
||||||
@@ -299,6 +405,8 @@ section h2 {
|
|||||||
|
|
||||||
.sound-btn:hover {
|
.sound-btn:hover {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sound-btn:active {
|
.sound-btn:active {
|
||||||
@@ -326,17 +434,19 @@ section h2 {
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content h2 {
|
.modal-content h2 {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content h3 {
|
.modal-content h3 {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin: 16px 0 8px 0;
|
margin: 16px 0 8px 0;
|
||||||
border-bottom: 1px solid var(--bg);
|
border-bottom: 1px solid rgba(232, 121, 29, 0.1);
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,11 +500,18 @@ section h2 {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: none;
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-content select:focus,
|
||||||
|
.modal-content input[type="text"]:focus,
|
||||||
|
.modal-content textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-buttons {
|
.modal-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -405,9 +522,10 @@ section h2 {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-buttons button:first-child {
|
.modal-buttons button:first-child {
|
||||||
@@ -415,25 +533,32 @@ section h2 {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-buttons button:first-child:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-buttons button:last-child {
|
.modal-buttons button:last-child {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.refresh-btn {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
border: 1px solid var(--bg-light);
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn:hover {
|
.refresh-btn:hover {
|
||||||
background: var(--bg-light);
|
background: var(--bg-light);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn:disabled {
|
.refresh-btn:disabled {
|
||||||
@@ -476,28 +601,29 @@ section h2 {
|
|||||||
.server-btn {
|
.server-btn {
|
||||||
border: none;
|
border: none;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-btn.restart {
|
.server-btn.restart {
|
||||||
background: #2196F3;
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-btn.restart:hover {
|
.server-btn.restart:hover {
|
||||||
background: #1976D2;
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-btn.stop {
|
.server-btn.stop {
|
||||||
background: #c0392b;
|
background: var(--accent-red);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-btn.stop:hover {
|
.server-btn.stop:hover {
|
||||||
background: #a93226;
|
background: #e03030;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auto-scroll-label {
|
.auto-scroll-label {
|
||||||
@@ -509,16 +635,21 @@ section h2 {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-scroll-label input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.server-log {
|
.server-log {
|
||||||
height: 200px;
|
height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #0d0d1a;
|
background: var(--bg-dark);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-sm);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #8f8;
|
color: #b8a88a;
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-log .log-line {
|
.server-log .log-line {
|
||||||
@@ -527,52 +658,70 @@ section h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.server-log .log-line.error {
|
.server-log .log-line.error {
|
||||||
color: #f88;
|
color: #e8604a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-log .log-line.warning {
|
.server-log .log-line.warning {
|
||||||
color: #ff8;
|
color: #e8b84a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-log .log-line.tts {
|
.server-log .log-line.tts {
|
||||||
color: #8ff;
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.server-log .log-line.chat {
|
.server-log .log-line.chat {
|
||||||
color: #f8f;
|
color: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Call Queue */
|
/* Call Queue */
|
||||||
.queue-section { margin: 1rem 0; }
|
.queue-section { margin: 1rem 0; }
|
||||||
.call-queue { border: 1px solid #333; border-radius: 4px; padding: 0.5rem; max-height: 150px; overflow-y: auto; }
|
.call-queue { border: 1px solid rgba(232, 121, 29, 0.15); border-radius: var(--radius-sm); padding: 0.5rem; max-height: 150px; overflow-y: auto; }
|
||||||
.queue-empty { color: #666; text-align: center; padding: 0.5rem; }
|
.queue-empty { color: var(--text-muted); text-align: center; padding: 0.5rem; }
|
||||||
.queue-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid #222; }
|
.queue-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.4rem 0.5rem; border-bottom: 1px solid rgba(232, 121, 29, 0.08); flex-wrap: wrap; }
|
||||||
.queue-item:last-child { border-bottom: none; }
|
.queue-item:last-child { border-bottom: none; }
|
||||||
.queue-phone { font-family: monospace; color: #4fc3f7; }
|
.queue-phone { font-family: monospace; color: var(--accent); }
|
||||||
.queue-wait { color: #999; font-size: 0.85rem; flex: 1; }
|
.queue-wait { color: var(--text-muted); font-size: 0.85rem; flex: 1; }
|
||||||
.queue-take-btn { background: #2e7d32; color: white; border: none; padding: 0.25rem 0.75rem; border-radius: 3px; cursor: pointer; }
|
.queue-take-btn { background: var(--accent-green); color: white; border: none; padding: 0.25rem 0.75rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s; }
|
||||||
.queue-take-btn:hover { background: #388e3c; }
|
.queue-take-btn:hover { background: #6a9a4c; }
|
||||||
.queue-drop-btn { background: #c62828; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 3px; cursor: pointer; }
|
.queue-drop-btn { background: var(--accent-red); color: white; border: none; padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s; }
|
||||||
.queue-drop-btn:hover { background: #d32f2f; }
|
.queue-drop-btn:hover { background: #e03030; }
|
||||||
|
|
||||||
/* Active Call Indicator */
|
/* Active Call Indicator */
|
||||||
.active-call { border: 1px solid #444; border-radius: 4px; padding: 0.75rem; margin: 0.5rem 0; background: #1a1a2e; }
|
.active-call { border: 1px solid rgba(232, 121, 29, 0.15); border-radius: var(--radius-sm); padding: 0.75rem; margin: 0.5rem 0; background: var(--bg); }
|
||||||
.caller-info { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
.caller-info { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||||
.caller-info:last-of-type { margin-bottom: 0; }
|
.caller-info:last-of-type { margin-bottom: 0; }
|
||||||
.caller-type { font-size: 0.7rem; font-weight: bold; padding: 0.15rem 0.4rem; border-radius: 3px; text-transform: uppercase; }
|
.caller-type { font-size: 0.7rem; font-weight: bold; padding: 0.15rem 0.4rem; border-radius: var(--radius-sm); text-transform: uppercase; }
|
||||||
.caller-type.real { background: #c62828; color: white; }
|
.caller-type.real { background: var(--accent-red); color: white; }
|
||||||
.caller-type.ai { background: #1565c0; color: white; }
|
.caller-type.ai { background: var(--accent); color: white; }
|
||||||
.channel-badge { font-size: 0.75rem; color: #999; background: #222; padding: 0.1rem 0.4rem; border-radius: 3px; }
|
.channel-badge { font-size: 0.75rem; color: var(--text-muted); background: var(--bg-light); padding: 0.1rem 0.4rem; border-radius: var(--radius-sm); }
|
||||||
.call-duration { font-family: monospace; color: #4fc3f7; }
|
.call-duration { font-family: monospace; color: var(--accent); }
|
||||||
.ai-controls { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
|
.ai-controls { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
|
||||||
.mode-toggle { display: flex; border: 1px solid #444; border-radius: 3px; overflow: hidden; }
|
.mode-toggle { display: flex; border: 1px solid rgba(232, 121, 29, 0.2); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
.mode-btn { background: #222; color: #999; border: none; padding: 0.2rem 0.5rem; font-size: 0.75rem; cursor: pointer; }
|
.mode-btn { background: var(--bg-light); color: var(--text-muted); border: none; padding: 0.2rem 0.5rem; font-size: 0.75rem; cursor: pointer; transition: all 0.2s; }
|
||||||
.mode-btn.active { background: #1565c0; color: white; }
|
.mode-btn.active { background: var(--accent); color: white; }
|
||||||
.respond-btn { background: #2e7d32; color: white; border: none; padding: 0.25rem 0.75rem; border-radius: 3px; font-size: 0.8rem; cursor: pointer; }
|
.respond-btn { background: var(--accent-green); color: white; border: none; padding: 0.25rem 0.75rem; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; transition: background 0.2s; }
|
||||||
|
.respond-btn:hover { background: #6a9a4c; }
|
||||||
.hangup-btn.small { font-size: 0.75rem; padding: 0.2rem 0.5rem; }
|
.hangup-btn.small { font-size: 0.75rem; padding: 0.2rem 0.5rem; }
|
||||||
.auto-followup-label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: #999; margin-top: 0.5rem; }
|
.auto-followup-label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: var(--text-muted); margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
/* Returning Caller */
|
||||||
|
.caller-btn.returning {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caller-btn.returning:hover {
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screening Badges */
|
||||||
|
.screening-badge { font-size: 0.7rem; padding: 0.1rem 0.4rem; border-radius: var(--radius-sm); font-weight: bold; }
|
||||||
|
.screening-badge.screening { background: var(--accent); color: white; animation: pulse 1.5s infinite; }
|
||||||
|
.screening-badge.screened { background: var(--accent-green); color: white; }
|
||||||
|
.screening-summary { font-size: 0.8rem; color: var(--text-muted); font-style: italic; flex-basis: 100%; margin-top: 0.2rem; }
|
||||||
|
|
||||||
/* Three-Party Chat */
|
/* Three-Party Chat */
|
||||||
.message.real-caller { border-left: 3px solid #c62828; padding-left: 0.5rem; }
|
.message.real-caller { border-left: 3px solid var(--accent-red); padding-left: 0.5rem; }
|
||||||
.message.ai-caller { border-left: 3px solid #1565c0; padding-left: 0.5rem; }
|
.message.ai-caller { border-left: 3px solid var(--accent); padding-left: 0.5rem; }
|
||||||
.message.host { border-left: 3px solid #2e7d32; padding-left: 0.5rem; }
|
.message.host { border-left: 3px solid var(--accent-green); padding-left: 0.5rem; }
|
||||||
|
|||||||
@@ -3,15 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<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>AI Radio Show</title>
|
<title>Luke at The Roost</title>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<header>
|
<header>
|
||||||
<h1>AI Radio Show</h1>
|
<h1>Luke at The Roost</h1>
|
||||||
<div class="header-buttons">
|
<div class="header-buttons">
|
||||||
|
<button id="on-air-btn" class="on-air-btn off">OFF AIR</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="settings-btn">Settings</button>
|
<button id="settings-btn">Settings</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -47,13 +50,16 @@
|
|||||||
</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>
|
||||||
<div id="caller-background" class="caller-background hidden"></div>
|
<details id="caller-background-details" class="caller-background hidden">
|
||||||
|
<summary>Caller Background</summary>
|
||||||
|
<div id="caller-background"></div>
|
||||||
|
</details>
|
||||||
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
|
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Call Queue -->
|
<!-- Call Queue -->
|
||||||
<section class="queue-section">
|
<section class="queue-section">
|
||||||
<h2>Incoming Calls</h2>
|
<h2>Incoming Calls <span style="font-size:0.6em;font-weight:normal;color:var(--text-muted);">(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>
|
||||||
@@ -80,6 +86,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Ads -->
|
||||||
|
<section class="music-section">
|
||||||
|
<h2>Ads</h2>
|
||||||
|
<select id="ad-select"></select>
|
||||||
|
<div class="music-controls">
|
||||||
|
<button id="ad-play-btn">Play Ad</button>
|
||||||
|
<button id="ad-stop-btn">Stop</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Sound Effects -->
|
<!-- Sound Effects -->
|
||||||
<section class="sounds-section">
|
<section class="sounds-section">
|
||||||
<h2>Sounds</h2>
|
<h2>Sounds</h2>
|
||||||
@@ -127,10 +143,11 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-row">
|
<div class="channel-row">
|
||||||
<label>Caller Ch <input type="number" id="caller-channel" value="1" min="1" max="16" class="channel-input"></label>
|
<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>
|
<label>Live Ch <input type="number" id="live-caller-channel" value="9" min="1" max="16" class="channel-input"></label>
|
||||||
<label>Music Ch <input type="number" id="music-channel" value="2" min="1" max="16" class="channel-input"></label>
|
<label>Music Ch <input type="number" id="music-channel" value="5" min="1" max="16" class="channel-input"></label>
|
||||||
<label>SFX Ch <input type="number" id="sfx-channel" value="3" min="1" max="16" class="channel-input"></label>
|
<label>SFX Ch <input type="number" id="sfx-channel" value="7" min="1" max="16" class="channel-input"></label>
|
||||||
|
<label>Ad Ch <input type="number" id="ad-channel" value="11" min="1" max="16" class="channel-input"></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -207,6 +224,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/app.js?v=13"></script>
|
<script src="/js/app.js?v=15"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -16,6 +16,30 @@ let tracks = [];
|
|||||||
let sounds = [];
|
let sounds = [];
|
||||||
|
|
||||||
|
|
||||||
|
// --- Safe JSON parsing ---
|
||||||
|
async function safeFetch(url, options = {}, timeoutMs = 30000) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { ...options, signal: controller.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
let detail = text;
|
||||||
|
try { detail = JSON.parse(text).detail || text; } catch {}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return {};
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err.name === 'AbortError') throw new Error('Request timed out');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Init ---
|
// --- Init ---
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
console.log('AI Radio Show initializing...');
|
console.log('AI Radio Show initializing...');
|
||||||
@@ -23,6 +47,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
await loadAudioDevices();
|
await loadAudioDevices();
|
||||||
await loadCallers();
|
await loadCallers();
|
||||||
await loadMusic();
|
await loadMusic();
|
||||||
|
await loadAds();
|
||||||
await loadSounds();
|
await loadSounds();
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
initEventListeners();
|
initEventListeners();
|
||||||
@@ -42,6 +67,53 @@ function initEventListeners() {
|
|||||||
// New Session
|
// New Session
|
||||||
document.getElementById('new-session-btn')?.addEventListener('click', newSession);
|
document.getElementById('new-session-btn')?.addEventListener('click', newSession);
|
||||||
|
|
||||||
|
// On-Air + Recording (linked — toggling one toggles both)
|
||||||
|
const onAirBtn = document.getElementById('on-air-btn');
|
||||||
|
const recBtn = document.getElementById('rec-btn');
|
||||||
|
let stemRecording = false;
|
||||||
|
|
||||||
|
function updateRecBtn(recording) {
|
||||||
|
stemRecording = recording;
|
||||||
|
if (recBtn) {
|
||||||
|
recBtn.classList.toggle('recording', recording);
|
||||||
|
recBtn.textContent = recording ? '⏺ REC' : 'REC';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onAirBtn) {
|
||||||
|
fetch('/api/on-air').then(r => r.json()).then(data => {
|
||||||
|
updateOnAirBtn(onAirBtn, data.on_air);
|
||||||
|
updateRecBtn(data.recording);
|
||||||
|
});
|
||||||
|
onAirBtn.addEventListener('click', async () => {
|
||||||
|
const isOn = onAirBtn.classList.contains('on');
|
||||||
|
const res = await safeFetch('/api/on-air', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ on_air: !isOn }),
|
||||||
|
});
|
||||||
|
updateOnAirBtn(onAirBtn, res.on_air);
|
||||||
|
updateRecBtn(res.recording);
|
||||||
|
log(res.on_air ? 'Show is ON AIR + Recording' : 'Show is OFF AIR + Recording stopped');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recBtn) {
|
||||||
|
recBtn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const res = await safeFetch('/api/recording/toggle', { method: 'POST' });
|
||||||
|
updateRecBtn(res.recording);
|
||||||
|
if (onAirBtn) updateOnAirBtn(onAirBtn, res.on_air);
|
||||||
|
log(res.recording ? 'Recording started + ON AIR' : 'Recording stopped + OFF AIR');
|
||||||
|
} catch (err) {
|
||||||
|
log('Recording error: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export session
|
||||||
|
document.getElementById('export-session-btn')?.addEventListener('click', exportSession);
|
||||||
|
|
||||||
// Server controls
|
// Server controls
|
||||||
document.getElementById('restart-server-btn')?.addEventListener('click', restartServer);
|
document.getElementById('restart-server-btn')?.addEventListener('click', restartServer);
|
||||||
document.getElementById('stop-server-btn')?.addEventListener('click', stopServer);
|
document.getElementById('stop-server-btn')?.addEventListener('click', stopServer);
|
||||||
@@ -86,6 +158,10 @@ function initEventListeners() {
|
|||||||
document.getElementById('stop-btn')?.addEventListener('click', stopMusic);
|
document.getElementById('stop-btn')?.addEventListener('click', stopMusic);
|
||||||
document.getElementById('volume')?.addEventListener('input', setMusicVolume);
|
document.getElementById('volume')?.addEventListener('input', setMusicVolume);
|
||||||
|
|
||||||
|
// Ads
|
||||||
|
document.getElementById('ad-play-btn')?.addEventListener('click', playAd);
|
||||||
|
document.getElementById('ad-stop-btn')?.addEventListener('click', stopAd);
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
document.getElementById('settings-btn')?.addEventListener('click', async () => {
|
document.getElementById('settings-btn')?.addEventListener('click', async () => {
|
||||||
document.getElementById('settings-modal')?.classList.remove('hidden');
|
document.getElementById('settings-modal')?.classList.remove('hidden');
|
||||||
@@ -108,6 +184,15 @@ function initEventListeners() {
|
|||||||
log('Real caller disconnected');
|
log('Real caller disconnected');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AI caller hangup (small button in AI caller panel)
|
||||||
|
document.getElementById('hangup-ai-btn')?.addEventListener('click', hangup);
|
||||||
|
|
||||||
|
// AI respond button (manual trigger)
|
||||||
|
document.getElementById('ai-respond-btn')?.addEventListener('click', triggerAiRespond);
|
||||||
|
|
||||||
|
// Start conversation update polling
|
||||||
|
startConversationPolling();
|
||||||
|
|
||||||
// AI respond mode toggle
|
// AI respond mode toggle
|
||||||
document.getElementById('mode-manual')?.addEventListener('click', () => {
|
document.getElementById('mode-manual')?.addEventListener('click', () => {
|
||||||
document.getElementById('mode-manual')?.classList.add('active');
|
document.getElementById('mode-manual')?.classList.add('active');
|
||||||
@@ -123,7 +208,6 @@ function initEventListeners() {
|
|||||||
document.getElementById('mode-auto')?.addEventListener('click', () => {
|
document.getElementById('mode-auto')?.addEventListener('click', () => {
|
||||||
document.getElementById('mode-auto')?.classList.add('active');
|
document.getElementById('mode-auto')?.classList.add('active');
|
||||||
document.getElementById('mode-manual')?.classList.remove('active');
|
document.getElementById('mode-manual')?.classList.remove('active');
|
||||||
document.getElementById('ai-respond-btn')?.classList.add('hidden');
|
|
||||||
fetch('/api/session/ai-mode', {
|
fetch('/api/session/ai-mode', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -226,12 +310,14 @@ async function loadAudioDevices() {
|
|||||||
const liveCallerCh = document.getElementById('live-caller-channel');
|
const liveCallerCh = document.getElementById('live-caller-channel');
|
||||||
const musicCh = document.getElementById('music-channel');
|
const musicCh = document.getElementById('music-channel');
|
||||||
const sfxCh = document.getElementById('sfx-channel');
|
const sfxCh = document.getElementById('sfx-channel');
|
||||||
|
const adCh = document.getElementById('ad-channel');
|
||||||
|
|
||||||
if (inputCh) inputCh.value = settings.input_channel || 1;
|
if (inputCh) inputCh.value = settings.input_channel || 1;
|
||||||
if (callerCh) callerCh.value = settings.caller_channel || 1;
|
if (callerCh) callerCh.value = settings.caller_channel || 1;
|
||||||
if (liveCallerCh) liveCallerCh.value = settings.live_caller_channel || 9;
|
if (liveCallerCh) liveCallerCh.value = settings.live_caller_channel || 9;
|
||||||
if (musicCh) musicCh.value = settings.music_channel || 2;
|
if (musicCh) musicCh.value = settings.music_channel || 2;
|
||||||
if (sfxCh) sfxCh.value = settings.sfx_channel || 3;
|
if (sfxCh) sfxCh.value = settings.sfx_channel || 3;
|
||||||
|
if (adCh) adCh.value = settings.ad_channel || 11;
|
||||||
|
|
||||||
// Phone filter setting
|
// Phone filter setting
|
||||||
const phoneFilterEl = document.getElementById('phone-filter');
|
const phoneFilterEl = document.getElementById('phone-filter');
|
||||||
@@ -255,6 +341,7 @@ async function saveAudioDevices() {
|
|||||||
const liveCallerChannel = document.getElementById('live-caller-channel')?.value;
|
const liveCallerChannel = document.getElementById('live-caller-channel')?.value;
|
||||||
const musicChannel = document.getElementById('music-channel')?.value;
|
const musicChannel = document.getElementById('music-channel')?.value;
|
||||||
const sfxChannel = document.getElementById('sfx-channel')?.value;
|
const sfxChannel = document.getElementById('sfx-channel')?.value;
|
||||||
|
const adChannel = document.getElementById('ad-channel')?.value;
|
||||||
const phoneFilterChecked = document.getElementById('phone-filter')?.checked ?? false;
|
const phoneFilterChecked = document.getElementById('phone-filter')?.checked ?? false;
|
||||||
|
|
||||||
await fetch('/api/audio/settings', {
|
await fetch('/api/audio/settings', {
|
||||||
@@ -268,6 +355,7 @@ async function saveAudioDevices() {
|
|||||||
live_caller_channel: liveCallerChannel ? parseInt(liveCallerChannel) : 9,
|
live_caller_channel: liveCallerChannel ? parseInt(liveCallerChannel) : 9,
|
||||||
music_channel: musicChannel ? parseInt(musicChannel) : 2,
|
music_channel: musicChannel ? parseInt(musicChannel) : 2,
|
||||||
sfx_channel: sfxChannel ? parseInt(sfxChannel) : 3,
|
sfx_channel: sfxChannel ? parseInt(sfxChannel) : 3,
|
||||||
|
ad_channel: adChannel ? parseInt(adChannel) : 11,
|
||||||
phone_filter: phoneFilterChecked
|
phone_filter: phoneFilterChecked
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -292,7 +380,8 @@ async function loadCallers() {
|
|||||||
data.callers.forEach(caller => {
|
data.callers.forEach(caller => {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'caller-btn';
|
btn.className = 'caller-btn';
|
||||||
btn.textContent = caller.name;
|
if (caller.returning) btn.classList.add('returning');
|
||||||
|
btn.textContent = caller.returning ? `\u2605 ${caller.name}` : caller.name;
|
||||||
btn.dataset.key = caller.key;
|
btn.dataset.key = caller.key;
|
||||||
btn.addEventListener('click', () => startCall(caller.key, caller.name));
|
btn.addEventListener('click', () => startCall(caller.key, caller.name));
|
||||||
grid.appendChild(btn);
|
grid.appendChild(btn);
|
||||||
@@ -337,11 +426,12 @@ async function startCall(key, name) {
|
|||||||
if (aiInfo) aiInfo.classList.remove('hidden');
|
if (aiInfo) aiInfo.classList.remove('hidden');
|
||||||
if (aiName) aiName.textContent = name;
|
if (aiName) aiName.textContent = name;
|
||||||
|
|
||||||
// Show caller background
|
// Show caller background in disclosure triangle
|
||||||
|
const bgDetails = document.getElementById('caller-background-details');
|
||||||
const bgEl = document.getElementById('caller-background');
|
const bgEl = document.getElementById('caller-background');
|
||||||
if (bgEl && data.background) {
|
if (bgDetails && bgEl && data.background) {
|
||||||
bgEl.textContent = data.background;
|
bgEl.textContent = data.background;
|
||||||
bgEl.classList.remove('hidden');
|
bgDetails.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll('.caller-btn').forEach(btn => {
|
document.querySelectorAll('.caller-btn').forEach(btn => {
|
||||||
@@ -362,10 +452,11 @@ async function newSession() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await fetch('/api/session/reset', { method: 'POST' });
|
await fetch('/api/session/reset', { method: 'POST' });
|
||||||
|
conversationSince = 0;
|
||||||
|
|
||||||
// Hide caller background
|
// Hide caller background
|
||||||
const bgEl = document.getElementById('caller-background');
|
const bgDetails = document.getElementById('caller-background-details');
|
||||||
if (bgEl) bgEl.classList.add('hidden');
|
if (bgDetails) bgDetails.classList.add('hidden');
|
||||||
|
|
||||||
// Reload callers to get new session ID
|
// Reload callers to get new session ID
|
||||||
await loadCallers();
|
await loadCallers();
|
||||||
@@ -391,8 +482,8 @@ async function hangup() {
|
|||||||
document.querySelectorAll('.caller-btn').forEach(btn => btn.classList.remove('active'));
|
document.querySelectorAll('.caller-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
|
||||||
// Hide caller background
|
// Hide caller background
|
||||||
const bgEl = document.getElementById('caller-background');
|
const bgDetails2 = document.getElementById('caller-background-details');
|
||||||
if (bgEl) bgEl.classList.add('hidden');
|
if (bgDetails2) bgDetails2.classList.add('hidden');
|
||||||
|
|
||||||
// Hide AI caller indicator
|
// Hide AI caller indicator
|
||||||
document.getElementById('ai-caller-info')?.classList.add('hidden');
|
document.getElementById('ai-caller-info')?.classList.add('hidden');
|
||||||
@@ -434,8 +525,7 @@ async function stopRecording() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Stop recording and get transcription
|
// Stop recording and get transcription
|
||||||
const res = await fetch('/api/record/stop', { method: 'POST' });
|
const data = await safeFetch('/api/record/stop', { method: 'POST' });
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!data.text) {
|
if (!data.text) {
|
||||||
log('(No speech detected)');
|
log('(No speech detected)');
|
||||||
@@ -449,12 +539,11 @@ async function stopRecording() {
|
|||||||
// Chat
|
// Chat
|
||||||
showStatus(`${currentCaller.name} is thinking...`);
|
showStatus(`${currentCaller.name} is thinking...`);
|
||||||
|
|
||||||
const chatRes = await fetch('/api/chat', {
|
const chatData = await safeFetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text: data.text })
|
body: JSON.stringify({ text: data.text })
|
||||||
});
|
});
|
||||||
const chatData = await chatRes.json();
|
|
||||||
|
|
||||||
addMessage(chatData.caller, chatData.text);
|
addMessage(chatData.caller, chatData.text);
|
||||||
|
|
||||||
@@ -462,7 +551,7 @@ async function stopRecording() {
|
|||||||
if (chatData.text && chatData.text.trim()) {
|
if (chatData.text && chatData.text.trim()) {
|
||||||
showStatus(`${currentCaller.name} is speaking...`);
|
showStatus(`${currentCaller.name} is speaking...`);
|
||||||
|
|
||||||
await fetch('/api/tts', {
|
await safeFetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -496,12 +585,11 @@ async function sendTypedMessage() {
|
|||||||
try {
|
try {
|
||||||
showStatus(`${currentCaller.name} is thinking...`);
|
showStatus(`${currentCaller.name} is thinking...`);
|
||||||
|
|
||||||
const chatRes = await fetch('/api/chat', {
|
const chatData = await safeFetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text })
|
body: JSON.stringify({ text })
|
||||||
});
|
});
|
||||||
const chatData = await chatRes.json();
|
|
||||||
|
|
||||||
addMessage(chatData.caller, chatData.text);
|
addMessage(chatData.caller, chatData.text);
|
||||||
|
|
||||||
@@ -509,7 +597,7 @@ async function sendTypedMessage() {
|
|||||||
if (chatData.text && chatData.text.trim()) {
|
if (chatData.text && chatData.text.trim()) {
|
||||||
showStatus(`${currentCaller.name} is speaking...`);
|
showStatus(`${currentCaller.name} is speaking...`);
|
||||||
|
|
||||||
await fetch('/api/tts', {
|
await safeFetch('/api/tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -538,6 +626,8 @@ async function loadMusic() {
|
|||||||
|
|
||||||
const select = document.getElementById('track-select');
|
const select = document.getElementById('track-select');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
|
|
||||||
|
const previousValue = select.value;
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
|
|
||||||
tracks.forEach((track, i) => {
|
tracks.forEach((track, i) => {
|
||||||
@@ -546,6 +636,12 @@ async function loadMusic() {
|
|||||||
option.textContent = track.name;
|
option.textContent = track.name;
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restore previous selection if it still exists
|
||||||
|
if (previousValue && [...select.options].some(o => o.value === previousValue)) {
|
||||||
|
select.value = previousValue;
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Loaded', tracks.length, 'tracks');
|
console.log('Loaded', tracks.length, 'tracks');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('loadMusic error:', err);
|
console.error('loadMusic error:', err);
|
||||||
@@ -554,6 +650,7 @@ async function loadMusic() {
|
|||||||
|
|
||||||
|
|
||||||
async function playMusic() {
|
async function playMusic() {
|
||||||
|
await loadMusic();
|
||||||
const select = document.getElementById('track-select');
|
const select = document.getElementById('track-select');
|
||||||
const track = select?.value;
|
const track = select?.value;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
@@ -581,6 +678,54 @@ async function setMusicVolume(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function loadAds() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ads');
|
||||||
|
const data = await res.json();
|
||||||
|
const ads = data.ads || [];
|
||||||
|
|
||||||
|
const select = document.getElementById('ad-select');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
const previousValue = select.value;
|
||||||
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
ads.forEach(ad => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = ad.file;
|
||||||
|
option.textContent = ad.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previousValue && [...select.options].some(o => o.value === previousValue)) {
|
||||||
|
select.value = previousValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Loaded', ads.length, 'ads');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('loadAds error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function playAd() {
|
||||||
|
await loadAds();
|
||||||
|
const select = document.getElementById('ad-select');
|
||||||
|
const track = select?.value;
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
await fetch('/api/ads/play', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ track, action: 'play' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopAd() {
|
||||||
|
await fetch('/api/ads/stop', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Sound Effects (Server-Side) ---
|
// --- Sound Effects (Server-Side) ---
|
||||||
async function loadSounds() {
|
async function loadSounds() {
|
||||||
try {
|
try {
|
||||||
@@ -742,6 +887,12 @@ function log(text) {
|
|||||||
addMessage('System', text);
|
addMessage('System', text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateOnAirBtn(btn, isOn) {
|
||||||
|
btn.classList.toggle('on', isOn);
|
||||||
|
btn.classList.toggle('off', !isOn);
|
||||||
|
btn.textContent = isOn ? 'ON AIR' : 'OFF AIR';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function showStatus(text) {
|
function showStatus(text) {
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
@@ -876,10 +1027,21 @@ function renderQueue(queue) {
|
|||||||
const mins = Math.floor(caller.wait_time / 60);
|
const mins = Math.floor(caller.wait_time / 60);
|
||||||
const secs = caller.wait_time % 60;
|
const secs = caller.wait_time % 60;
|
||||||
const waitStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
const waitStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
||||||
|
const displayName = caller.caller_name || caller.phone;
|
||||||
|
const screenBadge = caller.screening_status === 'complete'
|
||||||
|
? '<span class="screening-badge screened">Screened</span>'
|
||||||
|
: caller.screening_status === 'screening'
|
||||||
|
? '<span class="screening-badge screening">Screening...</span>'
|
||||||
|
: '';
|
||||||
|
const summary = caller.screening_summary
|
||||||
|
? `<div class="screening-summary">${caller.screening_summary}</div>`
|
||||||
|
: '';
|
||||||
return `
|
return `
|
||||||
<div class="queue-item">
|
<div class="queue-item">
|
||||||
<span class="queue-name">${caller.phone}</span>
|
<span class="queue-name">${displayName}</span>
|
||||||
|
${screenBadge}
|
||||||
<span class="queue-wait">waiting ${waitStr}</span>
|
<span class="queue-wait">waiting ${waitStr}</span>
|
||||||
|
${summary}
|
||||||
<button class="queue-take-btn" onclick="takeCall('${caller.caller_id}')">Take Call</button>
|
<button class="queue-take-btn" onclick="takeCall('${caller.caller_id}')">Take Call</button>
|
||||||
<button class="queue-drop-btn" onclick="dropCall('${caller.caller_id}')">Drop</button>
|
<button class="queue-drop-btn" onclick="dropCall('${caller.caller_id}')">Drop</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -894,6 +1056,19 @@ async function takeCall(callerId) {
|
|||||||
if (data.status === 'on_air') {
|
if (data.status === 'on_air') {
|
||||||
showRealCaller(data.caller);
|
showRealCaller(data.caller);
|
||||||
log(`${data.caller.phone} is on air — Channel ${data.caller.channel}`);
|
log(`${data.caller.phone} is on air — Channel ${data.caller.channel}`);
|
||||||
|
|
||||||
|
// Auto-select an AI caller if none is active
|
||||||
|
if (!currentCaller) {
|
||||||
|
const callerBtns = document.querySelectorAll('.caller-btn');
|
||||||
|
if (callerBtns.length > 0) {
|
||||||
|
const randomIdx = Math.floor(Math.random() * callerBtns.length);
|
||||||
|
const btn = callerBtns[randomIdx];
|
||||||
|
const key = btn.dataset.key;
|
||||||
|
const name = btn.textContent;
|
||||||
|
log(`Auto-selecting ${name} as AI caller`);
|
||||||
|
await startCall(key, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('Failed to take call: ' + err.message);
|
log('Failed to take call: ' + err.message);
|
||||||
@@ -962,6 +1137,83 @@ function hideRealCaller() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- AI Respond (manual trigger) ---
|
||||||
|
async function triggerAiRespond() {
|
||||||
|
if (!currentCaller) {
|
||||||
|
log('No AI caller active — click a caller first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('ai-respond-btn');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Thinking...'; }
|
||||||
|
showStatus(`${currentCaller.name} is thinking...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await safeFetch('/api/ai-respond', { method: 'POST' });
|
||||||
|
if (data.text) {
|
||||||
|
addMessage(data.caller, data.text);
|
||||||
|
showStatus(`${data.caller} is speaking...`);
|
||||||
|
const duration = data.text.length * 60;
|
||||||
|
setTimeout(hideStatus, Math.min(duration, 15000));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log('AI respond error: ' + err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Let them respond'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Conversation Update Polling ---
|
||||||
|
let conversationSince = 0;
|
||||||
|
|
||||||
|
function startConversationPolling() {
|
||||||
|
setInterval(fetchConversationUpdates, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConversationUpdates() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/conversation/updates?since=${conversationSince}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.messages && data.messages.length > 0) {
|
||||||
|
for (const msg of data.messages) {
|
||||||
|
conversationSince = msg.id + 1;
|
||||||
|
if (msg.type === 'caller_disconnected') {
|
||||||
|
hideRealCaller();
|
||||||
|
log(`${msg.phone} disconnected (${msg.reason})`);
|
||||||
|
} else if (msg.type === 'chat') {
|
||||||
|
addMessage(msg.sender, msg.text);
|
||||||
|
} else if (msg.type === 'ai_status') {
|
||||||
|
showStatus(msg.text);
|
||||||
|
} else if (msg.type === 'ai_done') {
|
||||||
|
hideStatus();
|
||||||
|
} else if (msg.type === 'caller_queued') {
|
||||||
|
// Queue poll will pick this up, just ensure it refreshes
|
||||||
|
fetchQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function exportSession() {
|
||||||
|
try {
|
||||||
|
const res = await safeFetch('/api/session/export');
|
||||||
|
const blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `session-${res.session_id}.json`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
log(`Exported session: ${res.call_count} calls`);
|
||||||
|
} catch (err) {
|
||||||
|
log('Export error: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function stopServer() {
|
async function stopServer() {
|
||||||
if (!confirm('Stop the server? You will need to restart it manually.')) return;
|
if (!confirm('Stop the server? You will need to restart it manually.')) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export async function onRequest() {
|
||||||
|
const feedUrl = 'https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml';
|
||||||
|
const res = await fetch(feedUrl);
|
||||||
|
const xml = await res.text();
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/xml',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Cache-Control': 'public, max-age=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Podcast Stats — Aggregate reviews, comments, likes, and analytics from all platforms.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python podcast_stats.py # All platforms
|
||||||
|
python podcast_stats.py --youtube # YouTube only
|
||||||
|
python podcast_stats.py --apple # Apple Podcasts only
|
||||||
|
python podcast_stats.py --spotify # Spotify only
|
||||||
|
python podcast_stats.py --castopod # Castopod downloads only
|
||||||
|
python podcast_stats.py --comments # Include full YouTube comments
|
||||||
|
python podcast_stats.py --json # Output as JSON
|
||||||
|
python podcast_stats.py --json --upload # Output JSON and upload to BunnyCDN
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
YOUTUBE_PLAYLIST = "PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-"
|
||||||
|
APPLE_PODCAST_ID = "1875205848"
|
||||||
|
APPLE_STOREFRONTS = ["us", "gb", "ca", "au"]
|
||||||
|
SPOTIFY_SHOW_ID = "0ZrpMigG1fo0CCN7F4YmuF"
|
||||||
|
NAS_SSH = "luke@mmgnas-10g"
|
||||||
|
NAS_SSH_PORT = "8001"
|
||||||
|
DOCKER_BIN = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||||
|
CASTOPOD_DB_CONTAINER = "castopod-mariadb-1"
|
||||||
|
|
||||||
|
BUNNY_STORAGE_ZONE = "lukeattheroost"
|
||||||
|
BUNNY_STORAGE_KEY = "92749cd3-85df-4cff-938fe35eb994-30f8-4cf2"
|
||||||
|
BUNNY_STORAGE_REGION = "la"
|
||||||
|
BUNNY_ACCOUNT_KEY = "2865f279-297b-431a-ad18-0ccf1f8e4fa8cf636cea-3222-415a-84ed-56ee195c0530"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_ytdlp():
|
||||||
|
"""Find yt-dlp: check local venv first, then fall back to PATH."""
|
||||||
|
import shutil
|
||||||
|
venv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "venv", "bin", "yt-dlp")
|
||||||
|
if os.path.exists(venv_path):
|
||||||
|
return venv_path
|
||||||
|
path_bin = shutil.which("yt-dlp")
|
||||||
|
if path_bin:
|
||||||
|
return path_bin
|
||||||
|
return "yt-dlp"
|
||||||
|
|
||||||
|
|
||||||
|
def gather_apple_reviews():
|
||||||
|
all_reviews = []
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
for storefront in APPLE_STOREFRONTS:
|
||||||
|
url = f"https://itunes.apple.com/{storefront}/rss/customerreviews/id={APPLE_PODCAST_ID}/sortby=mostrecent/json"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=15)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
feed = data.get("feed", {})
|
||||||
|
entries = feed.get("entry", [])
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if "im:name" in entry and "im:rating" not in entry:
|
||||||
|
continue
|
||||||
|
|
||||||
|
review_id = entry.get("id", {}).get("label", "")
|
||||||
|
if review_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(review_id)
|
||||||
|
|
||||||
|
author = entry.get("author", {}).get("name", {}).get("label", "Unknown")
|
||||||
|
title = entry.get("title", {}).get("label", "")
|
||||||
|
content = entry.get("content", {}).get("label", "")
|
||||||
|
rating = int(entry.get("im:rating", {}).get("label", "0"))
|
||||||
|
updated = entry.get("updated", {}).get("label", "")
|
||||||
|
date_str = updated[:10] if updated else ""
|
||||||
|
|
||||||
|
all_reviews.append({
|
||||||
|
"author": author,
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"rating": rating,
|
||||||
|
"date": date_str,
|
||||||
|
"storefront": storefront.upper(),
|
||||||
|
})
|
||||||
|
|
||||||
|
avg_rating = round(sum(r["rating"] for r in all_reviews) / len(all_reviews), 1) if all_reviews else None
|
||||||
|
return {
|
||||||
|
"avg_rating": avg_rating,
|
||||||
|
"review_count": len(all_reviews),
|
||||||
|
"reviews": all_reviews[:10],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def gather_spotify():
|
||||||
|
result = {"show_title": None, "rating": None, "url": f"https://open.spotify.com/show/{SPOTIFY_SHOW_ID}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
oembed_url = f"https://open.spotify.com/oembed?url=https://open.spotify.com/show/{SPOTIFY_SHOW_ID}"
|
||||||
|
resp = requests.get(oembed_url, timeout=15)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
result["show_title"] = data.get("title")
|
||||||
|
|
||||||
|
show_url = f"https://open.spotify.com/show/{SPOTIFY_SHOW_ID}"
|
||||||
|
resp = requests.get(show_url, timeout=15, headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
|
||||||
|
})
|
||||||
|
|
||||||
|
rating_match = re.search(r'"ratingValue"\s*:\s*"?([\d.]+)"?', resp.text)
|
||||||
|
if rating_match:
|
||||||
|
result["rating"] = float(rating_match.group(1))
|
||||||
|
else:
|
||||||
|
rating_match2 = re.search(r'rating["\s:]*(\d+\.?\d*)\s*/\s*5', resp.text, re.IGNORECASE)
|
||||||
|
if rating_match2:
|
||||||
|
result["rating"] = float(rating_match2.group(1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def gather_youtube(include_comments=False):
|
||||||
|
result = {
|
||||||
|
"total_views": 0,
|
||||||
|
"total_likes": 0,
|
||||||
|
"total_comments": 0,
|
||||||
|
"subscribers": None,
|
||||||
|
"videos": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[_find_ytdlp(), "--dump-json", "--flat-playlist",
|
||||||
|
f"https://www.youtube.com/playlist?list={YOUTUBE_PLAYLIST}"],
|
||||||
|
capture_output=True, text=True, timeout=60
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return result
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return result
|
||||||
|
|
||||||
|
video_ids = []
|
||||||
|
for line in proc.stdout.strip().split("\n"):
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entry = json.loads(line)
|
||||||
|
vid = entry.get("id") or entry.get("url", "").split("=")[-1]
|
||||||
|
if vid:
|
||||||
|
video_ids.append(vid)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not video_ids:
|
||||||
|
return result
|
||||||
|
|
||||||
|
total_views = 0
|
||||||
|
total_likes = 0
|
||||||
|
total_comments = 0
|
||||||
|
videos = []
|
||||||
|
|
||||||
|
for vid in video_ids:
|
||||||
|
try:
|
||||||
|
cmd = [_find_ytdlp(), "--dump-json", "--no-download", f"https://www.youtube.com/watch?v={vid}"]
|
||||||
|
if include_comments:
|
||||||
|
cmd.insert(2, "--write-comments")
|
||||||
|
vr = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
||||||
|
if vr.returncode != 0:
|
||||||
|
continue
|
||||||
|
vdata = json.loads(vr.stdout)
|
||||||
|
|
||||||
|
title = vdata.get("title", "Unknown")
|
||||||
|
views = vdata.get("view_count", 0) or 0
|
||||||
|
likes = vdata.get("like_count", 0) or 0
|
||||||
|
comment_count = vdata.get("comment_count", 0) or 0
|
||||||
|
upload_date = vdata.get("upload_date", "")
|
||||||
|
if upload_date:
|
||||||
|
upload_date = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:]}"
|
||||||
|
|
||||||
|
comments_list = []
|
||||||
|
if include_comments:
|
||||||
|
for c in (vdata.get("comments") or [])[:5]:
|
||||||
|
comments_list.append({
|
||||||
|
"author": c.get("author", "Unknown"),
|
||||||
|
"text": c.get("text", "")[:200],
|
||||||
|
"time": c.get("time_text", ""),
|
||||||
|
"likes": c.get("like_count", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
total_views += views
|
||||||
|
total_likes += likes
|
||||||
|
total_comments += comment_count
|
||||||
|
|
||||||
|
videos.append({
|
||||||
|
"title": title,
|
||||||
|
"views": views,
|
||||||
|
"likes": likes,
|
||||||
|
"comments": comment_count,
|
||||||
|
"date": upload_date,
|
||||||
|
})
|
||||||
|
except (subprocess.TimeoutExpired, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get subscriber count
|
||||||
|
if videos:
|
||||||
|
try:
|
||||||
|
vr = subprocess.run(
|
||||||
|
[_find_ytdlp(), "--dump-json", "--no-download", "--playlist-items", "1",
|
||||||
|
f"https://www.youtube.com/playlist?list={YOUTUBE_PLAYLIST}"],
|
||||||
|
capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
if vr.returncode == 0:
|
||||||
|
ch_data = json.loads(vr.stdout)
|
||||||
|
sub = ch_data.get("channel_follower_count")
|
||||||
|
if sub is not None:
|
||||||
|
result["subscribers"] = sub
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result["total_views"] = total_views
|
||||||
|
result["total_likes"] = total_likes
|
||||||
|
result["total_comments"] = total_comments
|
||||||
|
result["videos"] = videos
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _run_db_query(sql):
|
||||||
|
# If running on NAS (docker socket available), exec directly
|
||||||
|
docker_bin = None
|
||||||
|
for path in [DOCKER_BIN, "/usr/bin/docker", "/usr/local/bin/docker"]:
|
||||||
|
if os.path.exists(path):
|
||||||
|
docker_bin = path
|
||||||
|
break
|
||||||
|
|
||||||
|
if docker_bin:
|
||||||
|
cmd = [docker_bin, "exec", "-i", CASTOPOD_DB_CONTAINER,
|
||||||
|
"mysql", "-u", "castopod", "-pBYtbFfk3ndeVabb26xb0UyKU", "castopod", "-N"]
|
||||||
|
else:
|
||||||
|
cmd = [
|
||||||
|
"ssh", "-p", NAS_SSH_PORT, NAS_SSH,
|
||||||
|
f"{DOCKER_BIN} exec -i {CASTOPOD_DB_CONTAINER} mysql -u castopod -pBYtbFfk3ndeVabb26xb0UyKU castopod -N"
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, input=sql, capture_output=True, text=True, timeout=30)
|
||||||
|
stderr = proc.stderr.strip()
|
||||||
|
stdout = proc.stdout.strip()
|
||||||
|
if proc.returncode != 0 and not stdout:
|
||||||
|
return None, stderr
|
||||||
|
return stdout, None
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None, "timeout"
|
||||||
|
except Exception as e:
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def gather_castopod():
|
||||||
|
result = {"total_downloads": 0, "unique_listeners": 0, "episodes": []}
|
||||||
|
|
||||||
|
query = (
|
||||||
|
"SELECT p.title, "
|
||||||
|
"(SELECT SUM(hits) FROM cp_analytics_podcasts WHERE podcast_id = p.id), "
|
||||||
|
"(SELECT SUM(unique_listeners) FROM cp_analytics_podcasts WHERE podcast_id = p.id) "
|
||||||
|
"FROM cp_podcasts p WHERE p.handle = 'LukeAtTheRoost' LIMIT 1;"
|
||||||
|
)
|
||||||
|
episode_query = (
|
||||||
|
"SELECT e.title, e.slug, COALESCE(SUM(ae.hits), 0), e.published_at "
|
||||||
|
"FROM cp_episodes e LEFT JOIN cp_analytics_podcasts_by_episode ae ON ae.episode_id = e.id "
|
||||||
|
"WHERE e.podcast_id = (SELECT id FROM cp_podcasts WHERE handle = 'LukeAtTheRoost') "
|
||||||
|
"GROUP BY e.id ORDER BY e.published_at DESC;"
|
||||||
|
)
|
||||||
|
|
||||||
|
out, err = _run_db_query(query)
|
||||||
|
if err or not out:
|
||||||
|
return result
|
||||||
|
|
||||||
|
parts = out.split("\t")
|
||||||
|
if len(parts) >= 3:
|
||||||
|
result["total_downloads"] = int(parts[1]) if parts[1] and parts[1] != "NULL" else 0
|
||||||
|
result["unique_listeners"] = int(parts[2]) if parts[2] and parts[2] != "NULL" else 0
|
||||||
|
elif len(parts) >= 2:
|
||||||
|
result["total_downloads"] = int(parts[1]) if parts[1] and parts[1] != "NULL" else 0
|
||||||
|
|
||||||
|
out, err = _run_db_query(episode_query)
|
||||||
|
if err or not out:
|
||||||
|
return result
|
||||||
|
|
||||||
|
for line in out.strip().split("\n"):
|
||||||
|
cols = line.split("\t")
|
||||||
|
if len(cols) >= 4:
|
||||||
|
result["episodes"].append({
|
||||||
|
"title": cols[0],
|
||||||
|
"downloads": int(cols[2]) if cols[2] else 0,
|
||||||
|
"date": cols[3][:10] if cols[3] else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def print_apple(data):
|
||||||
|
print("\n⭐ APPLE PODCASTS")
|
||||||
|
print("─" * 40)
|
||||||
|
if data["reviews"]:
|
||||||
|
print(f" Rating: {data['avg_rating']}/5 ({data['review_count']} reviews)")
|
||||||
|
print()
|
||||||
|
for r in data["reviews"]:
|
||||||
|
stars = "★" * r["rating"] + "☆" * (5 - r["rating"])
|
||||||
|
print(f" {stars} \"{r['title']}\" — {r['author']} ({r['date']}, {r['storefront']})")
|
||||||
|
if r["content"] and r["content"] != r["title"]:
|
||||||
|
content_preview = r["content"][:120]
|
||||||
|
if len(r["content"]) > 120:
|
||||||
|
content_preview += "..."
|
||||||
|
print(f" {content_preview}")
|
||||||
|
else:
|
||||||
|
print(" No reviews found")
|
||||||
|
|
||||||
|
|
||||||
|
def print_spotify(data):
|
||||||
|
print("\n🎵 SPOTIFY")
|
||||||
|
print("─" * 40)
|
||||||
|
if data["show_title"]:
|
||||||
|
print(f" Show: {data['show_title']}")
|
||||||
|
if data["rating"]:
|
||||||
|
print(f" Rating: {data['rating']}/5")
|
||||||
|
else:
|
||||||
|
print(" Rating: Not publicly available (Spotify hides ratings from web)")
|
||||||
|
print(f" Link: {data['url']}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_youtube(data):
|
||||||
|
print("\n📺 YOUTUBE")
|
||||||
|
print("─" * 40)
|
||||||
|
sub_str = f" | Subscribers: {data['subscribers']:,}" if data["subscribers"] else ""
|
||||||
|
print(f" Total views: {data['total_views']:,} | Likes: {data['total_likes']:,} | Comments: {data['total_comments']:,}{sub_str}")
|
||||||
|
print()
|
||||||
|
for v in data["videos"]:
|
||||||
|
print(f" {v['title']}")
|
||||||
|
print(f" {v['views']:,} views, {v['likes']:,} likes, {v['comments']:,} comments — {v['date']}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_castopod(data):
|
||||||
|
print("\n📊 DOWNLOADS (Castopod)")
|
||||||
|
print("─" * 40)
|
||||||
|
print(f" Total downloads: {data['total_downloads']:,} | Unique listeners: {data['unique_listeners']:,}")
|
||||||
|
if data["episodes"]:
|
||||||
|
print()
|
||||||
|
for ep in data["episodes"]:
|
||||||
|
print(f" {ep['title']} — {ep['downloads']:,} downloads ({ep['date']})")
|
||||||
|
|
||||||
|
|
||||||
|
def upload_to_bunnycdn(json_data):
|
||||||
|
storage_url = f"https://{BUNNY_STORAGE_REGION}.storage.bunnycdn.com/{BUNNY_STORAGE_ZONE}/stats.json"
|
||||||
|
resp = requests.put(
|
||||||
|
storage_url,
|
||||||
|
data=json_data,
|
||||||
|
headers={
|
||||||
|
"AccessKey": BUNNY_STORAGE_KEY,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
purge_url = "https://api.bunny.net/purge"
|
||||||
|
requests.post(
|
||||||
|
purge_url,
|
||||||
|
params={"url": "https://cdn.lukeattheroost.com/stats.json"},
|
||||||
|
headers={"AccessKey": BUNNY_ACCOUNT_KEY},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
print("Uploaded stats.json to BunnyCDN and purged cache", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Podcast analytics aggregator")
|
||||||
|
parser.add_argument("--youtube", action="store_true", help="YouTube only")
|
||||||
|
parser.add_argument("--apple", action="store_true", help="Apple Podcasts only")
|
||||||
|
parser.add_argument("--spotify", action="store_true", help="Spotify only")
|
||||||
|
parser.add_argument("--castopod", action="store_true", help="Castopod only")
|
||||||
|
parser.add_argument("--comments", action="store_true", help="Include YouTube comments")
|
||||||
|
parser.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON")
|
||||||
|
parser.add_argument("--upload", action="store_true", help="Upload JSON to BunnyCDN (requires --json)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.upload and not args.json_output:
|
||||||
|
print("Error: --upload requires --json", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
run_all = not (args.youtube or args.apple or args.spotify or args.castopod)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
if run_all or args.castopod:
|
||||||
|
results["castopod"] = gather_castopod()
|
||||||
|
if run_all or args.apple:
|
||||||
|
results["apple"] = gather_apple_reviews()
|
||||||
|
if run_all or args.spotify:
|
||||||
|
results["spotify"] = gather_spotify()
|
||||||
|
if run_all or args.youtube:
|
||||||
|
results["youtube"] = gather_youtube(include_comments=args.comments or args.youtube)
|
||||||
|
|
||||||
|
if args.json_output:
|
||||||
|
output = {
|
||||||
|
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
**results,
|
||||||
|
}
|
||||||
|
json_str = json.dumps(output, indent=2, ensure_ascii=False)
|
||||||
|
print(json_str)
|
||||||
|
if args.upload:
|
||||||
|
upload_to_bunnycdn(json_str)
|
||||||
|
else:
|
||||||
|
print("=" * 45)
|
||||||
|
print(" PODCAST STATS: Luke at the Roost")
|
||||||
|
print("=" * 45)
|
||||||
|
if "castopod" in results:
|
||||||
|
print_castopod(results["castopod"])
|
||||||
|
if "apple" in results:
|
||||||
|
print_apple(results["apple"])
|
||||||
|
if "spotify" in results:
|
||||||
|
print_spotify(results["spotify"])
|
||||||
|
if "youtube" in results:
|
||||||
|
print_youtube(results["youtube"])
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,920 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Post-production pipeline for AI podcast stems.
|
||||||
|
|
||||||
|
Usage: python postprod.py recordings/2026-02-07_213000/ -o episode.mp3
|
||||||
|
|
||||||
|
Processes 5 aligned WAV stems (host, caller, music, sfx, ads) into a
|
||||||
|
broadcast-ready MP3 with gap removal, voice compression, music ducking,
|
||||||
|
and loudness normalization.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
|
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
|
||||||
|
|
||||||
|
|
||||||
|
def load_stems(stems_dir: Path) -> tuple[dict[str, np.ndarray], int]:
|
||||||
|
stems = {}
|
||||||
|
sample_rate = None
|
||||||
|
for name in STEM_NAMES:
|
||||||
|
path = stems_dir / f"{name}.wav"
|
||||||
|
if not path.exists():
|
||||||
|
print(f" {name}.wav not found, creating empty stem")
|
||||||
|
stems[name] = None
|
||||||
|
continue
|
||||||
|
data, sr = sf.read(str(path), dtype="float32")
|
||||||
|
if sample_rate is None:
|
||||||
|
sample_rate = sr
|
||||||
|
elif sr != sample_rate:
|
||||||
|
print(f" WARNING: {name}.wav has sample rate {sr}, expected {sample_rate}")
|
||||||
|
stems[name] = data
|
||||||
|
print(f" {name}: {len(data)} samples ({len(data)/sr:.1f}s)")
|
||||||
|
|
||||||
|
if sample_rate is None:
|
||||||
|
print("ERROR: No valid stems found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Pad all stems to same length
|
||||||
|
max_len = max(len(s) for s in stems.values() if s is not None)
|
||||||
|
for name in STEM_NAMES:
|
||||||
|
if stems[name] is None:
|
||||||
|
stems[name] = np.zeros(max_len, dtype=np.float32)
|
||||||
|
elif len(stems[name]) < max_len:
|
||||||
|
stems[name] = np.pad(stems[name], (0, max_len - len(stems[name])))
|
||||||
|
|
||||||
|
return stems, sample_rate
|
||||||
|
|
||||||
|
|
||||||
|
def compute_rms(audio: np.ndarray, window_samples: int) -> np.ndarray:
|
||||||
|
n_windows = len(audio) // window_samples
|
||||||
|
if n_windows == 0:
|
||||||
|
return np.array([0.0])
|
||||||
|
trimmed = audio[:n_windows * window_samples].reshape(n_windows, window_samples)
|
||||||
|
return np.sqrt(np.mean(trimmed ** 2, axis=1))
|
||||||
|
|
||||||
|
|
||||||
|
def remove_gaps(stems: dict[str, np.ndarray], sr: int,
|
||||||
|
threshold_s: float = 2.0, max_gap_s: float = 15.0,
|
||||||
|
crossfade_ms: float = 30, pad_s: float = 0.5) -> dict[str, np.ndarray]:
|
||||||
|
window_ms = 50
|
||||||
|
window_samples = int(sr * window_ms / 1000)
|
||||||
|
crossfade_samples = int(sr * crossfade_ms / 1000)
|
||||||
|
|
||||||
|
# Detect gaps in everything except music (which always plays).
|
||||||
|
# This catches TTS latency gaps while protecting ad breaks and SFX transitions.
|
||||||
|
content = stems["host"] + stems["caller"] + stems["sfx"] + stems["ads"]
|
||||||
|
rms = compute_rms(content, window_samples)
|
||||||
|
|
||||||
|
# Threshold: percentile-based to sit above the mic noise floor
|
||||||
|
nonzero_rms = rms[rms > 0]
|
||||||
|
if len(nonzero_rms) == 0:
|
||||||
|
print(" No audio detected")
|
||||||
|
return stems
|
||||||
|
noise_floor = np.percentile(nonzero_rms, 20)
|
||||||
|
silence_thresh = noise_floor * 3
|
||||||
|
|
||||||
|
is_silent = rms < silence_thresh
|
||||||
|
min_silent_windows = int(threshold_s / (window_ms / 1000))
|
||||||
|
max_silent_windows = int(max_gap_s / (window_ms / 1000))
|
||||||
|
|
||||||
|
# Only cut gaps between threshold-8s — targets TTS latency, not long breaks
|
||||||
|
cuts = []
|
||||||
|
i = 0
|
||||||
|
while i < len(is_silent):
|
||||||
|
if is_silent[i]:
|
||||||
|
start = i
|
||||||
|
while i < len(is_silent) and is_silent[i]:
|
||||||
|
i += 1
|
||||||
|
length = i - start
|
||||||
|
if min_silent_windows <= length <= max_silent_windows:
|
||||||
|
# Leave pad_s of silence so the edit sounds natural
|
||||||
|
pad_samples = int(pad_s * sr)
|
||||||
|
cut_start = (start + 1) * window_samples + pad_samples
|
||||||
|
cut_end = (i - 1) * window_samples - pad_samples
|
||||||
|
if cut_end > cut_start + crossfade_samples * 2:
|
||||||
|
cuts.append((cut_start, cut_end))
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not cuts:
|
||||||
|
print(" No gaps to remove")
|
||||||
|
return stems
|
||||||
|
|
||||||
|
total_cut = sum(end - start for start, end in cuts) / sr
|
||||||
|
print(f" Removing {len(cuts)} gaps ({total_cut:.1f}s total)")
|
||||||
|
|
||||||
|
# Cut dialog/sfx/ads at gap points. Leave music uncut — just trim to fit.
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for name in STEM_NAMES:
|
||||||
|
if name == "music":
|
||||||
|
continue # handled below
|
||||||
|
audio = stems[name]
|
||||||
|
pieces = []
|
||||||
|
prev_end = 0
|
||||||
|
for cut_start, cut_end in cuts:
|
||||||
|
if prev_end < cut_start:
|
||||||
|
piece = audio[prev_end:cut_start].copy()
|
||||||
|
if pieces and len(piece) > crossfade_samples:
|
||||||
|
fade_in = np.linspace(0, 1, crossfade_samples, dtype=np.float32)
|
||||||
|
piece[:crossfade_samples] *= fade_in
|
||||||
|
if len(pieces) > 0 and len(pieces[-1]) > crossfade_samples:
|
||||||
|
fade_out = np.linspace(1, 0, crossfade_samples, dtype=np.float32)
|
||||||
|
pieces[-1][-crossfade_samples:] *= fade_out
|
||||||
|
pieces.append(piece)
|
||||||
|
prev_end = cut_end
|
||||||
|
|
||||||
|
if prev_end < len(audio):
|
||||||
|
piece = audio[prev_end:].copy()
|
||||||
|
if pieces and len(piece) > crossfade_samples:
|
||||||
|
fade_in = np.linspace(0, 1, crossfade_samples, dtype=np.float32)
|
||||||
|
piece[:crossfade_samples] *= fade_in
|
||||||
|
if len(pieces) > 0 and len(pieces[-1]) > crossfade_samples:
|
||||||
|
fade_out = np.linspace(1, 0, crossfade_samples, dtype=np.float32)
|
||||||
|
pieces[-1][-crossfade_samples:] *= fade_out
|
||||||
|
pieces.append(piece)
|
||||||
|
|
||||||
|
result[name] = np.concatenate(pieces) if pieces else np.array([], dtype=np.float32)
|
||||||
|
|
||||||
|
# Music: leave uncut, just trim to match new duration with fade-out
|
||||||
|
new_len = len(result["host"])
|
||||||
|
music = stems["music"]
|
||||||
|
if len(music) >= new_len:
|
||||||
|
music = music[:new_len].copy()
|
||||||
|
else:
|
||||||
|
music = np.pad(music, (0, new_len - len(music)))
|
||||||
|
fade_samples = int(sr * 3)
|
||||||
|
if len(music) > fade_samples:
|
||||||
|
music[-fade_samples:] *= np.linspace(1, 0, fade_samples, dtype=np.float32)
|
||||||
|
result["music"] = music
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def denoise(audio: np.ndarray, sr: int, tmp_dir: Path) -> np.ndarray:
|
||||||
|
"""HPF to cut rumble below 80Hz (plosives, HVAC, handling noise)."""
|
||||||
|
in_path = tmp_dir / "host_pre_denoise.wav"
|
||||||
|
out_path = tmp_dir / "host_post_denoise.wav"
|
||||||
|
sf.write(str(in_path), audio, sr)
|
||||||
|
|
||||||
|
af = "highpass=f=80:poles=2"
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", str(in_path), "-af", af, str(out_path)]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" WARNING: denoise failed: {result.stderr[:200]}")
|
||||||
|
return audio
|
||||||
|
|
||||||
|
denoised, _ = sf.read(str(out_path), dtype="float32")
|
||||||
|
return denoised
|
||||||
|
|
||||||
|
|
||||||
|
def deess(audio: np.ndarray, sr: int, tmp_dir: Path) -> np.ndarray:
|
||||||
|
"""Reduce sibilance (harsh s/sh/ch sounds) in voice audio."""
|
||||||
|
in_path = tmp_dir / "host_pre_deess.wav"
|
||||||
|
out_path = tmp_dir / "host_post_deess.wav"
|
||||||
|
sf.write(str(in_path), audio, sr)
|
||||||
|
|
||||||
|
# Gentle high-shelf reduction at 5kHz (-4dB) to tame sibilance
|
||||||
|
# Single-pass, no phase issues unlike split-band approaches
|
||||||
|
af = "equalizer=f=5500:t=h:w=2000:g=-4"
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", str(in_path), "-af", af, str(out_path)]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" WARNING: de-essing failed: {result.stderr[:200]}")
|
||||||
|
return audio
|
||||||
|
|
||||||
|
deessed, _ = sf.read(str(out_path), dtype="float32")
|
||||||
|
return deessed
|
||||||
|
|
||||||
|
|
||||||
|
def reduce_breaths(audio: np.ndarray, sr: int, reduction_db: float = -12) -> np.ndarray:
|
||||||
|
"""Reduce loud breaths between speech phrases."""
|
||||||
|
window_ms = 30
|
||||||
|
window_samples = int(sr * window_ms / 1000)
|
||||||
|
rms = compute_rms(audio, window_samples)
|
||||||
|
|
||||||
|
if not np.any(rms > 0):
|
||||||
|
return audio
|
||||||
|
|
||||||
|
# Speech threshold: breaths are quieter than speech but louder than silence
|
||||||
|
nonzero = rms[rms > 0]
|
||||||
|
speech_level = np.percentile(nonzero, 70)
|
||||||
|
silence_level = np.percentile(nonzero, 10)
|
||||||
|
breath_upper = speech_level * 0.3 # below 30% of speech level
|
||||||
|
breath_lower = silence_level * 2 # above 2x silence
|
||||||
|
|
||||||
|
if breath_upper <= breath_lower:
|
||||||
|
return audio
|
||||||
|
|
||||||
|
# Detect breath-length bursts (0.15-0.8s) in the breath amplitude range
|
||||||
|
min_windows = int(150 / window_ms)
|
||||||
|
max_windows = int(800 / window_ms)
|
||||||
|
|
||||||
|
breath_gain = 10 ** (reduction_db / 20)
|
||||||
|
gain_envelope = np.ones(len(rms), dtype=np.float32)
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
breath_count = 0
|
||||||
|
while i < len(rms):
|
||||||
|
if breath_lower < rms[i] < breath_upper:
|
||||||
|
start = i
|
||||||
|
while i < len(rms) and breath_lower < rms[i] < breath_upper:
|
||||||
|
i += 1
|
||||||
|
length = i - start
|
||||||
|
if min_windows <= length <= max_windows:
|
||||||
|
gain_envelope[start:i] = breath_gain
|
||||||
|
breath_count += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if breath_count == 0:
|
||||||
|
return audio
|
||||||
|
|
||||||
|
print(f" Reduced {breath_count} breaths by {reduction_db}dB")
|
||||||
|
|
||||||
|
# Smooth transitions (10ms ramp)
|
||||||
|
ramp = max(1, int(10 / window_ms))
|
||||||
|
smoothed = gain_envelope.copy()
|
||||||
|
for i in range(1, len(smoothed)):
|
||||||
|
if smoothed[i] < smoothed[i - 1]:
|
||||||
|
smoothed[i] = smoothed[i - 1] + (smoothed[i] - smoothed[i - 1]) / ramp
|
||||||
|
elif smoothed[i] > smoothed[i - 1]:
|
||||||
|
smoothed[i] = smoothed[i - 1] + (smoothed[i] - smoothed[i - 1]) / ramp
|
||||||
|
|
||||||
|
# Expand to sample level
|
||||||
|
gain_samples = np.repeat(smoothed, window_samples)[:len(audio)]
|
||||||
|
if len(gain_samples) < len(audio):
|
||||||
|
gain_samples = np.pad(gain_samples, (0, len(audio) - len(gain_samples)), constant_values=1.0)
|
||||||
|
|
||||||
|
return (audio * gain_samples).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def limit_stem(audio: np.ndarray, sr: int, tmp_dir: Path,
|
||||||
|
stem_name: str) -> np.ndarray:
|
||||||
|
"""Hard-limit a stem to -1dB true peak to prevent clipping."""
|
||||||
|
peak = np.max(np.abs(audio))
|
||||||
|
if peak <= 0.89: # already below -1dB
|
||||||
|
return audio
|
||||||
|
in_path = tmp_dir / f"{stem_name}_pre_limit.wav"
|
||||||
|
out_path = tmp_dir / f"{stem_name}_post_limit.wav"
|
||||||
|
sf.write(str(in_path), audio, sr)
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y", "-i", str(in_path),
|
||||||
|
"-af", "alimiter=limit=-1dB:level=false:attack=0.1:release=50",
|
||||||
|
str(out_path),
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" WARNING: limiting failed for {stem_name}: {result.stderr[:200]}")
|
||||||
|
return audio
|
||||||
|
limited, _ = sf.read(str(out_path), dtype="float32")
|
||||||
|
peak_db = 20 * np.log10(peak)
|
||||||
|
print(f" {stem_name}: peak was {peak_db:+.1f}dB, limited to -1dB")
|
||||||
|
return limited
|
||||||
|
|
||||||
|
|
||||||
|
def compress_voice(audio: np.ndarray, sr: int, tmp_dir: Path,
|
||||||
|
stem_name: str) -> np.ndarray:
|
||||||
|
in_path = tmp_dir / f"{stem_name}_pre_comp.wav"
|
||||||
|
out_path = tmp_dir / f"{stem_name}_post_comp.wav"
|
||||||
|
|
||||||
|
sf.write(str(in_path), audio, sr)
|
||||||
|
|
||||||
|
if stem_name == "host":
|
||||||
|
# Spoken word compression: lower threshold, higher ratio, more makeup
|
||||||
|
af = "acompressor=threshold=-28dB:ratio=4:attack=5:release=600:makeup=8dB"
|
||||||
|
else:
|
||||||
|
af = "acompressor=threshold=-24dB:ratio=2.5:attack=10:release=800:makeup=6dB"
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y", "-i", str(in_path),
|
||||||
|
"-af", af,
|
||||||
|
str(out_path),
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" WARNING: compression failed for {stem_name}: {result.stderr[:200]}")
|
||||||
|
return audio
|
||||||
|
|
||||||
|
compressed, _ = sf.read(str(out_path), dtype="float32")
|
||||||
|
return compressed
|
||||||
|
|
||||||
|
|
||||||
|
def phone_eq(audio: np.ndarray, sr: int, tmp_dir: Path) -> np.ndarray:
|
||||||
|
"""Apply telephone EQ to make caller sound like a phone call."""
|
||||||
|
in_path = tmp_dir / "caller_pre_phone.wav"
|
||||||
|
out_path = tmp_dir / "caller_post_phone.wav"
|
||||||
|
sf.write(str(in_path), audio, sr)
|
||||||
|
|
||||||
|
# Bandpass 300-3400Hz (telephone bandwidth) + slight mid boost for presence
|
||||||
|
af = (
|
||||||
|
"highpass=f=300:poles=2,"
|
||||||
|
"lowpass=f=3400:poles=2,"
|
||||||
|
"equalizer=f=1000:t=q:w=0.8:g=4"
|
||||||
|
)
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", str(in_path), "-af", af, str(out_path)]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" WARNING: phone EQ failed: {result.stderr[:200]}")
|
||||||
|
return audio
|
||||||
|
|
||||||
|
filtered, _ = sf.read(str(out_path), dtype="float32")
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def apply_ducking(music: np.ndarray, dialog: np.ndarray, sr: int,
|
||||||
|
duck_db: float = -20, attack_ms: float = 200,
|
||||||
|
release_ms: float = 3000,
|
||||||
|
mute_signal: np.ndarray | None = None) -> np.ndarray:
|
||||||
|
window_ms = 50
|
||||||
|
window_samples = int(sr * window_ms / 1000)
|
||||||
|
rms = compute_rms(dialog, window_samples)
|
||||||
|
|
||||||
|
# Speech detection threshold
|
||||||
|
mean_rms = np.mean(rms[rms > 0]) if np.any(rms > 0) else 1e-4
|
||||||
|
speech_thresh = mean_rms * 0.1
|
||||||
|
|
||||||
|
# Build gain envelope (per window)
|
||||||
|
duck_gain = 10 ** (duck_db / 20)
|
||||||
|
is_speech = rms > speech_thresh
|
||||||
|
target_gain = np.where(is_speech, duck_gain, 1.0).astype(np.float32)
|
||||||
|
|
||||||
|
# Mute music completely during ads with lookahead and tail
|
||||||
|
if mute_signal is not None:
|
||||||
|
mute_rms = compute_rms(mute_signal, window_samples)
|
||||||
|
mute_thresh = np.mean(mute_rms[mute_rms > 0]) * 0.1 if np.any(mute_rms > 0) else 1e-4
|
||||||
|
is_ads = mute_rms > mute_thresh
|
||||||
|
# Expand ad regions: 2s before (fade out music before ad) and 2s after (don't resume immediately)
|
||||||
|
lookahead_windows = int(2000 / window_ms)
|
||||||
|
tail_windows = int(2000 / window_ms)
|
||||||
|
expanded_ads = is_ads.copy()
|
||||||
|
for i in range(len(is_ads)):
|
||||||
|
if is_ads[i]:
|
||||||
|
start = max(0, i - lookahead_windows)
|
||||||
|
end = min(len(expanded_ads), i + tail_windows + 1)
|
||||||
|
expanded_ads[start:end] = True
|
||||||
|
target_gain[expanded_ads] = 0.0
|
||||||
|
|
||||||
|
# Smooth the envelope
|
||||||
|
attack_windows = max(1, int(attack_ms / window_ms))
|
||||||
|
release_windows = max(1, int(release_ms / window_ms))
|
||||||
|
smoothed = np.ones_like(target_gain)
|
||||||
|
for i in range(1, len(target_gain)):
|
||||||
|
if target_gain[i] < smoothed[i - 1]:
|
||||||
|
alpha = 1.0 / attack_windows
|
||||||
|
smoothed[i] = smoothed[i - 1] + alpha * (target_gain[i] - smoothed[i - 1])
|
||||||
|
else:
|
||||||
|
alpha = 1.0 / release_windows
|
||||||
|
smoothed[i] = smoothed[i - 1] + alpha * (target_gain[i] - smoothed[i - 1])
|
||||||
|
|
||||||
|
# Expand envelope to sample level
|
||||||
|
gain_samples = np.repeat(smoothed, window_samples)
|
||||||
|
if len(gain_samples) < len(music):
|
||||||
|
gain_samples = np.pad(gain_samples, (0, len(music) - len(gain_samples)), constant_values=1.0)
|
||||||
|
else:
|
||||||
|
gain_samples = gain_samples[:len(music)]
|
||||||
|
|
||||||
|
return music * gain_samples
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
for name in ["host", "caller", "ads"]:
|
||||||
|
audio = stems[name]
|
||||||
|
# Only measure non-silent portions
|
||||||
|
active = audio[np.abs(audio) > 0.001]
|
||||||
|
if len(active) == 0:
|
||||||
|
continue
|
||||||
|
current_rms = np.sqrt(np.mean(active ** 2))
|
||||||
|
if current_rms < 1e-6:
|
||||||
|
continue
|
||||||
|
gain = target_rms / current_rms
|
||||||
|
# Clamp gain to avoid extreme boosts on very quiet stems
|
||||||
|
gain = min(gain, 10.0)
|
||||||
|
stems[name] = np.clip(audio * gain, -1.0, 1.0).astype(np.float32)
|
||||||
|
db_change = 20 * np.log10(gain) if gain > 0 else 0
|
||||||
|
print(f" {name}: RMS {current_rms:.4f} -> {target_rms:.4f} ({db_change:+.1f}dB)")
|
||||||
|
return stems
|
||||||
|
|
||||||
|
|
||||||
|
def mix_stems(stems: dict[str, np.ndarray],
|
||||||
|
levels: dict[str, float] | None = None,
|
||||||
|
stereo_imaging: bool = True) -> np.ndarray:
|
||||||
|
if levels is None:
|
||||||
|
levels = {"host": 0, "caller": 0, "music": -6, "sfx": -10, "ads": 0}
|
||||||
|
|
||||||
|
gains = {name: 10 ** (db / 20) for name, db in levels.items()}
|
||||||
|
|
||||||
|
max_len = max(len(s) for s in stems.values())
|
||||||
|
|
||||||
|
if stereo_imaging:
|
||||||
|
# Pan positions: -1.0 = full left, 0.0 = center, 1.0 = full right
|
||||||
|
# Using constant-power panning law
|
||||||
|
pans = {"host": 0.0, "caller": 0.15, "music": 0.0, "sfx": 0.0, "ads": 0.0}
|
||||||
|
# Music gets stereo width via slight L/R decorrelation
|
||||||
|
music_width = 0.3
|
||||||
|
|
||||||
|
left = np.zeros(max_len, dtype=np.float64)
|
||||||
|
right = np.zeros(max_len, dtype=np.float64)
|
||||||
|
|
||||||
|
for name in STEM_NAMES:
|
||||||
|
audio = stems[name]
|
||||||
|
if len(audio) < max_len:
|
||||||
|
audio = np.pad(audio, (0, max_len - len(audio)))
|
||||||
|
signal = audio.astype(np.float64) * gains.get(name, 1.0)
|
||||||
|
|
||||||
|
if name == "music" and music_width > 0:
|
||||||
|
# Widen music: delay right channel by ~0.5ms for Haas effect
|
||||||
|
delay_samples = int(0.0005 * 44100) # ~22 samples at 44.1kHz
|
||||||
|
left += signal * (1 + music_width * 0.5)
|
||||||
|
right_delayed = np.zeros_like(signal)
|
||||||
|
right_delayed[delay_samples:] = signal[:-delay_samples] if delay_samples > 0 else signal
|
||||||
|
right += right_delayed * (1 + music_width * 0.5)
|
||||||
|
else:
|
||||||
|
pan = pans.get(name, 0.0)
|
||||||
|
# Constant-power pan: L = cos(angle), R = sin(angle)
|
||||||
|
angle = (pan + 1) * np.pi / 4 # 0 to pi/2
|
||||||
|
l_gain = np.cos(angle)
|
||||||
|
r_gain = np.sin(angle)
|
||||||
|
left += signal * l_gain
|
||||||
|
right += signal * r_gain
|
||||||
|
|
||||||
|
left = np.clip(left, -1.0, 1.0).astype(np.float32)
|
||||||
|
right = np.clip(right, -1.0, 1.0).astype(np.float32)
|
||||||
|
stereo = np.column_stack([left, right])
|
||||||
|
else:
|
||||||
|
mix = np.zeros(max_len, dtype=np.float64)
|
||||||
|
for name in STEM_NAMES:
|
||||||
|
audio = stems[name]
|
||||||
|
if len(audio) < max_len:
|
||||||
|
audio = np.pad(audio, (0, max_len - len(audio)))
|
||||||
|
mix += audio.astype(np.float64) * gains.get(name, 1.0)
|
||||||
|
mix = np.clip(mix, -1.0, 1.0).astype(np.float32)
|
||||||
|
stereo = np.column_stack([mix, mix])
|
||||||
|
|
||||||
|
return stereo
|
||||||
|
|
||||||
|
|
||||||
|
def bus_compress(audio: np.ndarray, sr: int, tmp_dir: Path) -> np.ndarray:
|
||||||
|
"""Gentle bus compression on the final stereo mix to glue everything together."""
|
||||||
|
in_path = tmp_dir / "bus_pre.wav"
|
||||||
|
out_path = tmp_dir / "bus_post.wav"
|
||||||
|
sf.write(str(in_path), audio, sr)
|
||||||
|
|
||||||
|
# Gentle glue compressor: slow attack lets transients through,
|
||||||
|
# low ratio just levels out the overall dynamics
|
||||||
|
af = "acompressor=threshold=-20dB:ratio=2:attack=20:release=300:makeup=2dB"
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", str(in_path), "-af", af, str(out_path)]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" WARNING: bus compression failed: {result.stderr[:200]}")
|
||||||
|
return audio
|
||||||
|
|
||||||
|
compressed, _ = sf.read(str(out_path), dtype="float32")
|
||||||
|
return compressed
|
||||||
|
|
||||||
|
|
||||||
|
def trim_silence(audio: np.ndarray, sr: int, pad_s: float = 0.5,
|
||||||
|
threshold_db: float = -50) -> np.ndarray:
|
||||||
|
"""Trim leading and trailing silence from stereo audio."""
|
||||||
|
threshold = 10 ** (threshold_db / 20)
|
||||||
|
# Use the louder channel for detection
|
||||||
|
mono = np.max(np.abs(audio), axis=1) if audio.ndim > 1 else np.abs(audio)
|
||||||
|
|
||||||
|
# Smoothed envelope for more reliable detection
|
||||||
|
window = int(sr * 0.05) # 50ms window
|
||||||
|
if len(mono) > window:
|
||||||
|
kernel = np.ones(window) / window
|
||||||
|
envelope = np.convolve(mono, kernel, mode='same')
|
||||||
|
else:
|
||||||
|
envelope = mono
|
||||||
|
|
||||||
|
above = np.where(envelope > threshold)[0]
|
||||||
|
if len(above) == 0:
|
||||||
|
return audio
|
||||||
|
|
||||||
|
pad_samples = int(pad_s * sr)
|
||||||
|
start = max(0, above[0] - pad_samples)
|
||||||
|
end = min(len(audio), above[-1] + pad_samples)
|
||||||
|
|
||||||
|
trimmed_start = start / sr
|
||||||
|
trimmed_end = (len(audio) - end) / sr
|
||||||
|
if trimmed_start > 0.1 or trimmed_end > 0.1:
|
||||||
|
print(f" Trimmed {trimmed_start:.1f}s from start, {trimmed_end:.1f}s from end")
|
||||||
|
else:
|
||||||
|
print(" No significant silence to trim")
|
||||||
|
|
||||||
|
return audio[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_fades(audio: np.ndarray, sr: int,
|
||||||
|
fade_in_s: float = 1.5, fade_out_s: float = 3.0) -> np.ndarray:
|
||||||
|
"""Apply fade in/out to stereo audio using equal-power curve."""
|
||||||
|
audio = audio.copy()
|
||||||
|
|
||||||
|
# Fade in
|
||||||
|
fade_in_samples = int(fade_in_s * sr)
|
||||||
|
if fade_in_samples > 0 and fade_in_samples < len(audio):
|
||||||
|
# Equal-power: sine curve for smooth perceived volume change
|
||||||
|
curve = np.sin(np.linspace(0, np.pi / 2, fade_in_samples)).astype(np.float32)
|
||||||
|
if audio.ndim > 1:
|
||||||
|
audio[:fade_in_samples] *= curve[:, np.newaxis]
|
||||||
|
else:
|
||||||
|
audio[:fade_in_samples] *= curve
|
||||||
|
|
||||||
|
# Fade out
|
||||||
|
fade_out_samples = int(fade_out_s * sr)
|
||||||
|
if fade_out_samples > 0 and fade_out_samples < len(audio):
|
||||||
|
curve = np.sin(np.linspace(np.pi / 2, 0, fade_out_samples)).astype(np.float32)
|
||||||
|
if audio.ndim > 1:
|
||||||
|
audio[-fade_out_samples:] *= curve[:, np.newaxis]
|
||||||
|
else:
|
||||||
|
audio[-fade_out_samples:] *= curve
|
||||||
|
|
||||||
|
print(f" Fade in: {fade_in_s}s, fade out: {fade_out_s}s")
|
||||||
|
return audio
|
||||||
|
|
||||||
|
|
||||||
|
def detect_chapters(stems: dict[str, np.ndarray], sr: int) -> list[dict]:
|
||||||
|
"""Auto-detect chapter boundaries from stem activity."""
|
||||||
|
window_s = 2 # 2-second analysis windows
|
||||||
|
window_samples = int(sr * window_s)
|
||||||
|
n_windows = min(len(s) for s in stems.values()) // window_samples
|
||||||
|
|
||||||
|
if n_windows == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
chapters = []
|
||||||
|
current_type = None
|
||||||
|
chapter_start = 0
|
||||||
|
|
||||||
|
for w in range(n_windows):
|
||||||
|
start = w * window_samples
|
||||||
|
end = start + window_samples
|
||||||
|
|
||||||
|
ads_rms = np.sqrt(np.mean(stems["ads"][start:end] ** 2))
|
||||||
|
caller_rms = np.sqrt(np.mean(stems["caller"][start:end] ** 2))
|
||||||
|
host_rms = np.sqrt(np.mean(stems["host"][start:end] ** 2))
|
||||||
|
|
||||||
|
# Classify this window
|
||||||
|
if ads_rms > 0.005:
|
||||||
|
seg_type = "Ad Break"
|
||||||
|
elif caller_rms > 0.005:
|
||||||
|
seg_type = "Caller"
|
||||||
|
elif host_rms > 0.005:
|
||||||
|
seg_type = "Host"
|
||||||
|
else:
|
||||||
|
seg_type = current_type # keep current during silence
|
||||||
|
|
||||||
|
if seg_type != current_type and seg_type is not None:
|
||||||
|
if current_type is not None:
|
||||||
|
chapters.append({
|
||||||
|
"title": current_type,
|
||||||
|
"start_ms": int(chapter_start * 1000),
|
||||||
|
"end_ms": int(w * window_s * 1000),
|
||||||
|
})
|
||||||
|
current_type = seg_type
|
||||||
|
chapter_start = w * window_s
|
||||||
|
|
||||||
|
# Final chapter
|
||||||
|
if current_type is not None:
|
||||||
|
chapters.append({
|
||||||
|
"title": current_type,
|
||||||
|
"start_ms": int(chapter_start * 1000),
|
||||||
|
"end_ms": int(n_windows * window_s * 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Merge consecutive chapters of same type
|
||||||
|
merged = []
|
||||||
|
for ch in chapters:
|
||||||
|
if merged and merged[-1]["title"] == ch["title"]:
|
||||||
|
merged[-1]["end_ms"] = ch["end_ms"]
|
||||||
|
else:
|
||||||
|
merged.append(ch)
|
||||||
|
|
||||||
|
# Number duplicate types (Caller 1, Caller 2, etc.)
|
||||||
|
type_counts = {}
|
||||||
|
for ch in merged:
|
||||||
|
base = ch["title"]
|
||||||
|
type_counts[base] = type_counts.get(base, 0) + 1
|
||||||
|
if type_counts[base] > 1 or base in ("Caller", "Ad Break"):
|
||||||
|
ch["title"] = f"{base} {type_counts[base]}"
|
||||||
|
|
||||||
|
# Filter out very short chapters (< 10s)
|
||||||
|
merged = [ch for ch in merged if ch["end_ms"] - ch["start_ms"] >= 10000]
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def write_ffmpeg_chapters(chapters: list[dict], output_path: Path):
|
||||||
|
"""Write an ffmpeg-format metadata file with chapter markers."""
|
||||||
|
lines = [";FFMETADATA1"]
|
||||||
|
for ch in chapters:
|
||||||
|
lines.append("[CHAPTER]")
|
||||||
|
lines.append("TIMEBASE=1/1000")
|
||||||
|
lines.append(f"START={ch['start_ms']}")
|
||||||
|
lines.append(f"END={ch['end_ms']}")
|
||||||
|
lines.append(f"title={ch['title']}")
|
||||||
|
output_path.write_text("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_and_export(audio: np.ndarray, sr: int, output_path: Path,
|
||||||
|
target_lufs: float = -16, bitrate: str = "128k",
|
||||||
|
tmp_dir: Path = None,
|
||||||
|
metadata: dict | None = None,
|
||||||
|
chapters_file: Path | None = None):
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
tmp_wav = tmp_dir / "pre_loudnorm.wav"
|
||||||
|
sf.write(str(tmp_wav), audio, sr)
|
||||||
|
|
||||||
|
# Pass 1: measure loudness
|
||||||
|
measure_cmd = [
|
||||||
|
"ffmpeg", "-y", "-i", str(tmp_wav),
|
||||||
|
"-af", f"loudnorm=I={target_lufs}:TP=-1:LRA=11:print_format=json",
|
||||||
|
"-f", "null", "-",
|
||||||
|
]
|
||||||
|
result = subprocess.run(measure_cmd, capture_output=True, text=True)
|
||||||
|
stderr = result.stderr
|
||||||
|
|
||||||
|
json_start = stderr.rfind("{")
|
||||||
|
json_end = stderr.rfind("}") + 1
|
||||||
|
if json_start >= 0 and json_end > json_start:
|
||||||
|
stats = json.loads(stderr[json_start:json_end])
|
||||||
|
else:
|
||||||
|
print(" WARNING: couldn't parse loudnorm stats, using defaults")
|
||||||
|
stats = {
|
||||||
|
"input_i": "-23", "input_tp": "-1", "input_lra": "11",
|
||||||
|
"input_thresh": "-34",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pass 2: normalize + limiter + export MP3
|
||||||
|
loudnorm_filter = (
|
||||||
|
f"loudnorm=I={target_lufs}:TP=-1:LRA=11"
|
||||||
|
f":measured_I={stats['input_i']}"
|
||||||
|
f":measured_TP={stats['input_tp']}"
|
||||||
|
f":measured_LRA={stats['input_lra']}"
|
||||||
|
f":measured_thresh={stats['input_thresh']}"
|
||||||
|
f":linear=true"
|
||||||
|
)
|
||||||
|
|
||||||
|
export_cmd = ["ffmpeg", "-y", "-i", str(tmp_wav)]
|
||||||
|
|
||||||
|
if chapters_file and chapters_file.exists():
|
||||||
|
export_cmd += ["-i", str(chapters_file), "-map_metadata", "1"]
|
||||||
|
|
||||||
|
export_cmd += [
|
||||||
|
"-af", f"{loudnorm_filter},alimiter=limit=-1dB:level=false",
|
||||||
|
"-ab", bitrate, "-ar", str(sr),
|
||||||
|
]
|
||||||
|
|
||||||
|
if metadata:
|
||||||
|
for key, value in metadata.items():
|
||||||
|
if value and not key.startswith("_"):
|
||||||
|
export_cmd += ["-metadata", f"{key}={value}"]
|
||||||
|
|
||||||
|
export_cmd.append(str(output_path))
|
||||||
|
result = subprocess.run(export_cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" ERROR: export failed: {result.stderr[:300]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Embed artwork as a second pass (avoids complex multi-input mapping)
|
||||||
|
artwork = metadata.get("_artwork") if metadata else None
|
||||||
|
if artwork and Path(artwork).exists():
|
||||||
|
tmp_mp3 = tmp_dir / "with_art.mp3"
|
||||||
|
art_cmd = [
|
||||||
|
"ffmpeg", "-y", "-i", str(output_path), "-i", artwork,
|
||||||
|
"-map", "0:a", "-map", "1:0",
|
||||||
|
"-c:a", "copy", "-id3v2_version", "3",
|
||||||
|
"-metadata:s:v", "title=Album cover",
|
||||||
|
"-metadata:s:v", "comment=Cover (front)",
|
||||||
|
"-disposition:v", "attached_pic",
|
||||||
|
str(tmp_mp3),
|
||||||
|
]
|
||||||
|
art_result = subprocess.run(art_cmd, capture_output=True, text=True)
|
||||||
|
if art_result.returncode == 0:
|
||||||
|
shutil.move(str(tmp_mp3), str(output_path))
|
||||||
|
print(f" Embedded artwork: {artwork}")
|
||||||
|
else:
|
||||||
|
print(f" WARNING: artwork embedding failed: {art_result.stderr[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Post-production for AI podcast stems")
|
||||||
|
parser.add_argument("stems_dir", type=Path, help="Directory containing stem WAV files")
|
||||||
|
parser.add_argument("-o", "--output", type=str, default="episode.mp3", help="Output filename")
|
||||||
|
parser.add_argument("--gap-threshold", type=float, default=2.0, help="Min silence to cut (seconds)")
|
||||||
|
parser.add_argument("--duck-amount", type=float, default=-20, help="Music duck in dB")
|
||||||
|
parser.add_argument("--target-lufs", type=float, default=-16, help="Target loudness (LUFS)")
|
||||||
|
parser.add_argument("--bitrate", type=str, default="128k", help="MP3 bitrate")
|
||||||
|
parser.add_argument("--fade-in", type=float, default=1.5, help="Fade in duration (seconds)")
|
||||||
|
parser.add_argument("--fade-out", type=float, default=3.0, help="Fade out duration (seconds)")
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
parser.add_argument("--title", type=str, help="Episode title (ID3 tag)")
|
||||||
|
parser.add_argument("--artist", type=str, default="Luke at the Roost", help="Artist name")
|
||||||
|
parser.add_argument("--album", type=str, default="Luke at the Roost", help="Album/show name")
|
||||||
|
parser.add_argument("--episode-num", type=str, help="Episode number (track tag)")
|
||||||
|
parser.add_argument("--artwork", type=str, help="Path to artwork image (embedded in MP3)")
|
||||||
|
|
||||||
|
# Skip flags
|
||||||
|
parser.add_argument("--no-gap-removal", action="store_true", help="Skip gap removal")
|
||||||
|
parser.add_argument("--no-denoise", action="store_true", help="Skip noise reduction + HPF")
|
||||||
|
parser.add_argument("--no-deess", action="store_true", help="Skip de-essing")
|
||||||
|
parser.add_argument("--no-breath-reduction", action="store_true", help="Skip breath reduction")
|
||||||
|
parser.add_argument("--no-compression", action="store_true", help="Skip voice compression")
|
||||||
|
parser.add_argument("--no-phone-eq", action="store_true", help="Skip caller phone EQ")
|
||||||
|
parser.add_argument("--no-ducking", action="store_true", help="Skip music ducking")
|
||||||
|
parser.add_argument("--no-stereo", action="store_true", help="Skip stereo imaging (mono mix)")
|
||||||
|
parser.add_argument("--no-trim", action="store_true", help="Skip silence trimming")
|
||||||
|
parser.add_argument("--no-fade", action="store_true", help="Skip fade in/out")
|
||||||
|
parser.add_argument("--no-chapters", action="store_true", help="Skip chapter markers")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Show what would be done")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
stems_dir = args.stems_dir
|
||||||
|
if not stems_dir.exists():
|
||||||
|
print(f"ERROR: directory not found: {stems_dir}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Resolve output path
|
||||||
|
output_path = Path(args.output)
|
||||||
|
if not output_path.is_absolute():
|
||||||
|
output_path = stems_dir / output_path
|
||||||
|
|
||||||
|
print(f"Post-production: {stems_dir} -> {output_path}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("Dry run — exiting")
|
||||||
|
return
|
||||||
|
|
||||||
|
total_steps = 15
|
||||||
|
|
||||||
|
# Step 1: Load
|
||||||
|
print(f"\n[1/{total_steps}] Loading stems...")
|
||||||
|
stems, sr = load_stems(stems_dir)
|
||||||
|
|
||||||
|
# Step 2: Gap removal
|
||||||
|
print(f"\n[2/{total_steps}] Gap removal...")
|
||||||
|
if not args.no_gap_removal:
|
||||||
|
stems = remove_gaps(stems, sr, threshold_s=args.gap_threshold)
|
||||||
|
else:
|
||||||
|
print(" Skipped")
|
||||||
|
|
||||||
|
# Step 3: Limit ads + SFX (prevent clipping)
|
||||||
|
print(f"\n[3/{total_steps}] Limiting ads + SFX...")
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_dir = Path(tmp)
|
||||||
|
for name in ["ads", "sfx"]:
|
||||||
|
if np.any(stems[name] != 0):
|
||||||
|
stems[name] = limit_stem(stems[name], sr, tmp_dir, name)
|
||||||
|
|
||||||
|
# Step 4: Host mic noise reduction + HPF
|
||||||
|
print(f"\n[4/{total_steps}] Host noise reduction + HPF...")
|
||||||
|
if not args.no_denoise and np.any(stems["host"] != 0):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
stems["host"] = denoise(stems["host"], sr, Path(tmp))
|
||||||
|
print(" Applied")
|
||||||
|
else:
|
||||||
|
print(" Skipped" if args.no_denoise else " No host audio")
|
||||||
|
|
||||||
|
# Step 5: De-essing
|
||||||
|
print(f"\n[5/{total_steps}] De-essing host...")
|
||||||
|
if not args.no_deess and np.any(stems["host"] != 0):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
stems["host"] = deess(stems["host"], sr, Path(tmp))
|
||||||
|
print(" Applied")
|
||||||
|
else:
|
||||||
|
print(" Skipped" if args.no_deess else " No host audio")
|
||||||
|
|
||||||
|
# Step 6: Breath reduction
|
||||||
|
print(f"\n[6/{total_steps}] Breath reduction...")
|
||||||
|
if not args.no_breath_reduction and np.any(stems["host"] != 0):
|
||||||
|
stems["host"] = reduce_breaths(stems["host"], sr)
|
||||||
|
else:
|
||||||
|
print(" Skipped" if args.no_breath_reduction else " No host audio")
|
||||||
|
|
||||||
|
# Step 7: Voice compression
|
||||||
|
print(f"\n[7/{total_steps}] Voice compression...")
|
||||||
|
if not args.no_compression:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_dir = Path(tmp)
|
||||||
|
for name in ["host", "caller"]:
|
||||||
|
if np.any(stems[name] != 0):
|
||||||
|
print(f" Compressing {name}...")
|
||||||
|
stems[name] = compress_voice(stems[name], sr, tmp_dir, name)
|
||||||
|
else:
|
||||||
|
print(" Skipped")
|
||||||
|
|
||||||
|
# Step 8: Phone EQ on caller
|
||||||
|
print(f"\n[8/{total_steps}] Phone EQ on caller...")
|
||||||
|
if not args.no_phone_eq and np.any(stems["caller"] != 0):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
stems["caller"] = phone_eq(stems["caller"], sr, Path(tmp))
|
||||||
|
print(" Applied")
|
||||||
|
else:
|
||||||
|
print(" Skipped" if args.no_phone_eq else " No caller audio")
|
||||||
|
|
||||||
|
# Step 9: Match voice levels
|
||||||
|
print(f"\n[9/{total_steps}] Matching voice levels...")
|
||||||
|
stems = match_voice_levels(stems)
|
||||||
|
|
||||||
|
# Step 10: Music ducking
|
||||||
|
print(f"\n[10/{total_steps}] Music ducking...")
|
||||||
|
if not args.no_ducking:
|
||||||
|
dialog = stems["host"] + stems["caller"]
|
||||||
|
if np.any(dialog != 0) and np.any(stems["music"] != 0):
|
||||||
|
stems["music"] = apply_ducking(stems["music"], dialog, sr, duck_db=args.duck_amount,
|
||||||
|
mute_signal=stems["ads"])
|
||||||
|
print(" Applied")
|
||||||
|
else:
|
||||||
|
print(" No dialog or music to duck")
|
||||||
|
else:
|
||||||
|
print(" Skipped")
|
||||||
|
|
||||||
|
# Step 11: Stereo mix
|
||||||
|
print(f"\n[11/{total_steps}] Mixing...")
|
||||||
|
stereo = mix_stems(stems, stereo_imaging=not args.no_stereo)
|
||||||
|
imaging = "stereo" if not args.no_stereo else "mono"
|
||||||
|
print(f" Mixed to {imaging}: {len(stereo)} samples ({len(stereo)/sr:.1f}s)")
|
||||||
|
|
||||||
|
# Step 12: Bus compression
|
||||||
|
print(f"\n[12/{total_steps}] Bus compression...")
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
stereo = bus_compress(stereo, sr, Path(tmp))
|
||||||
|
print(" Applied")
|
||||||
|
|
||||||
|
# Step 13: Silence trimming
|
||||||
|
print(f"\n[13/{total_steps}] Trimming silence...")
|
||||||
|
if not args.no_trim:
|
||||||
|
stereo = trim_silence(stereo, sr)
|
||||||
|
else:
|
||||||
|
print(" Skipped")
|
||||||
|
|
||||||
|
# Step 14: Fade in/out
|
||||||
|
print(f"\n[14/{total_steps}] Fades...")
|
||||||
|
if not args.no_fade:
|
||||||
|
stereo = apply_fades(stereo, sr, fade_in_s=args.fade_in, fade_out_s=args.fade_out)
|
||||||
|
else:
|
||||||
|
print(" Skipped")
|
||||||
|
|
||||||
|
# Step 15: Normalize + export with metadata and chapters
|
||||||
|
print(f"\n[15/{total_steps}] Loudness normalization + export...")
|
||||||
|
|
||||||
|
# Build metadata dict
|
||||||
|
meta = {}
|
||||||
|
if args.title:
|
||||||
|
meta["title"] = args.title
|
||||||
|
if args.artist:
|
||||||
|
meta["artist"] = args.artist
|
||||||
|
if args.album:
|
||||||
|
meta["album"] = args.album
|
||||||
|
if args.episode_num:
|
||||||
|
meta["track"] = args.episode_num
|
||||||
|
if args.artwork:
|
||||||
|
meta["_artwork"] = args.artwork
|
||||||
|
|
||||||
|
# Auto-detect chapters
|
||||||
|
chapters = []
|
||||||
|
if not args.no_chapters:
|
||||||
|
chapters = detect_chapters(stems, sr)
|
||||||
|
if chapters:
|
||||||
|
print(f" Detected {len(chapters)} chapters:")
|
||||||
|
for ch in chapters:
|
||||||
|
start_s = ch["start_ms"] / 1000
|
||||||
|
end_s = ch["end_ms"] / 1000
|
||||||
|
print(f" {start_s:6.1f}s - {end_s:6.1f}s {ch['title']}")
|
||||||
|
else:
|
||||||
|
print(" No chapters detected")
|
||||||
|
else:
|
||||||
|
print(" Skipped")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_dir = Path(tmp)
|
||||||
|
|
||||||
|
chapters_file = None
|
||||||
|
if chapters:
|
||||||
|
chapters_file = tmp_dir / "chapters.txt"
|
||||||
|
write_ffmpeg_chapters(chapters, chapters_file)
|
||||||
|
|
||||||
|
normalize_and_export(stereo, sr, output_path,
|
||||||
|
target_lufs=args.target_lufs,
|
||||||
|
bitrate=args.bitrate,
|
||||||
|
tmp_dir=tmp_dir,
|
||||||
|
metadata=meta if meta else None,
|
||||||
|
chapters_file=chapters_file)
|
||||||
|
|
||||||
|
print(f"\nDone! Output: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -10,17 +10,45 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import base64
|
import tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ssl
|
||||||
import requests
|
import requests
|
||||||
|
import urllib3
|
||||||
|
from requests.adapters import HTTPAdapter
|
||||||
|
from urllib3.util.ssl_ import create_urllib3_context
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
|
class TLSAdapter(HTTPAdapter):
|
||||||
|
"""Adapter to handle servers with older TLS configurations."""
|
||||||
|
def init_poolmanager(self, *args, **kwargs):
|
||||||
|
ctx = create_urllib3_context()
|
||||||
|
ctx.set_ciphers('DEFAULT@SECLEVEL=1')
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
kwargs['ssl_context'] = ctx
|
||||||
|
return super().init_poolmanager(*args, **kwargs)
|
||||||
|
|
||||||
|
def send(self, *args, **kwargs):
|
||||||
|
kwargs['verify'] = False
|
||||||
|
return super().send(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# Use a session with TLS compatibility for all Castopod requests
|
||||||
|
_session = requests.Session()
|
||||||
|
_session.mount('https://', TLSAdapter())
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv(Path(__file__).parent / ".env")
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
@@ -31,9 +59,28 @@ CASTOPOD_PASSWORD = "podcast2026api"
|
|||||||
PODCAST_ID = 1
|
PODCAST_ID = 1
|
||||||
PODCAST_HANDLE = "LukeAtTheRoost"
|
PODCAST_HANDLE = "LukeAtTheRoost"
|
||||||
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
|
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
|
||||||
WHISPER_MODEL = "base" # Options: tiny, base, small, medium, large
|
|
||||||
|
WHISPER_MODEL = "large-v3"
|
||||||
|
|
||||||
|
# Postiz (social media posting)
|
||||||
|
POSTIZ_URL = "https://social.lukeattheroost.com"
|
||||||
|
POSTIZ_JWT_SECRET = "9d499bab97b303506af6ae18b29a60e6b5a0b1049177f533232ad14dd9729814"
|
||||||
|
POSTIZ_USER_ID = "00c14319-9eac-42c3-a467-68d3c1634fe1"
|
||||||
|
POSTIZ_INTEGRATIONS = {
|
||||||
|
"facebook": {"id": "cmll9hwqj0001mt6xnas2f17w"},
|
||||||
|
"instagram": {"id": "cmlljn8920001pk6qqzutqwik"},
|
||||||
|
"discord": {"id": "cmllkprk90001uc6v6fwd5y9p", "channel": "1471386314447519754"},
|
||||||
|
"bluesky": {"id": "cmlk29h780001p76qa7sstp5h"},
|
||||||
|
"mastodon": {"id": "cmlk2r3mf0001le6vx9ey0k5a"},
|
||||||
|
"nostr": {"id": "cmlll3y78000cuc6vh8dcpl2w"},
|
||||||
|
}
|
||||||
|
|
||||||
# NAS Configuration for chapters upload
|
# NAS Configuration for chapters upload
|
||||||
|
# BunnyCDN Storage
|
||||||
|
BUNNY_STORAGE_ZONE = "lukeattheroost"
|
||||||
|
BUNNY_STORAGE_KEY = "92749cd3-85df-4cff-938fe35eb994-30f8-4cf2"
|
||||||
|
BUNNY_STORAGE_REGION = "la" # Los Angeles
|
||||||
|
|
||||||
NAS_HOST = "mmgnas-10g"
|
NAS_HOST = "mmgnas-10g"
|
||||||
NAS_USER = "luke"
|
NAS_USER = "luke"
|
||||||
NAS_SSH_PORT = 8001
|
NAS_SSH_PORT = 8001
|
||||||
@@ -53,6 +100,94 @@ def get_auth_header():
|
|||||||
return {"Authorization": f"Basic {credentials}"}
|
return {"Authorization": f"Basic {credentials}"}
|
||||||
|
|
||||||
|
|
||||||
|
def label_transcript_speakers(text):
|
||||||
|
"""Add LUKE:/CALLER: speaker labels to transcript using LLM."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
prompt = """Insert speaker labels into this radio show transcript. The show is "Luke at the Roost". The host is LUKE. Callers call in one at a time.
|
||||||
|
|
||||||
|
CRITICAL: Output EVERY SINGLE WORD from the input. Do NOT summarize, shorten, paraphrase, or skip ANY text. The output must contain the EXACT SAME words as the input, with ONLY speaker labels and line breaks added.
|
||||||
|
|
||||||
|
At each speaker change, insert a blank line and the new speaker's label (e.g., "LUKE:" or "REGGIE:").
|
||||||
|
|
||||||
|
Speaker identification:
|
||||||
|
- LUKE is the host — he introduces callers, asks questions, does sponsor reads, opens and closes the show
|
||||||
|
- Callers are introduced by name by Luke (e.g., "let's talk to Earl", "next up Brenda")
|
||||||
|
- Use caller FIRST NAME in caps as the label
|
||||||
|
- When Luke says "Tell me about..." or asks a question, that's LUKE
|
||||||
|
- When someone responds with their story/opinion/answer, that's the CALLER
|
||||||
|
|
||||||
|
Output format — ONLY the labeled transcript with blank lines between turns. No notes, no commentary.
|
||||||
|
|
||||||
|
TRANSCRIPT:
|
||||||
|
"""
|
||||||
|
# Chunk text into ~8000 char segments
|
||||||
|
chunks = []
|
||||||
|
remaining = text
|
||||||
|
while remaining:
|
||||||
|
if len(remaining) <= 8000:
|
||||||
|
if chunks and len(remaining) < 1000:
|
||||||
|
chunks[-1] = chunks[-1] + " " + remaining
|
||||||
|
else:
|
||||||
|
chunks.append(remaining)
|
||||||
|
break
|
||||||
|
pos = remaining[:8000].rfind('. ')
|
||||||
|
if pos < 4000:
|
||||||
|
pos = remaining[:8000].rfind('? ')
|
||||||
|
if pos < 4000:
|
||||||
|
pos = remaining[:8000].rfind('! ')
|
||||||
|
if pos < 4000:
|
||||||
|
pos = 8000
|
||||||
|
chunks.append(remaining[:pos + 1].strip())
|
||||||
|
remaining = remaining[pos + 1:].strip()
|
||||||
|
|
||||||
|
labeled_parts = []
|
||||||
|
context = ""
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
full_prompt = prompt + chunk
|
||||||
|
if context:
|
||||||
|
full_prompt += f"\n\nCONTEXT: The previous section ended with speaker {context}"
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": "anthropic/claude-3.5-sonnet",
|
||||||
|
"messages": [{"role": "user", "content": full_prompt}],
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f" Warning: Speaker labeling failed for chunk {i+1}, using raw text")
|
||||||
|
labeled_parts.append(chunk)
|
||||||
|
else:
|
||||||
|
content = response.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
if content.startswith("```"):
|
||||||
|
content = re.sub(r'^```\w*\n?', '', content)
|
||||||
|
content = re.sub(r'\n?```$', '', content)
|
||||||
|
labeled_parts.append(content)
|
||||||
|
|
||||||
|
# Extract last speaker for context
|
||||||
|
for line in reversed(content.strip().split('\n')):
|
||||||
|
m = re.match(r'^([A-Z][A-Z\s\'-]+?):', line.strip())
|
||||||
|
if m:
|
||||||
|
context = m.group(1)
|
||||||
|
break
|
||||||
|
|
||||||
|
if i < len(chunks) - 1:
|
||||||
|
_time.sleep(0.5)
|
||||||
|
|
||||||
|
result = "\n\n".join(labeled_parts)
|
||||||
|
result = re.sub(r'\n{3,}', '\n\n', result)
|
||||||
|
# Normalize: SPEAKER:\ntext -> SPEAKER: text
|
||||||
|
result = re.sub(r'^([A-Z][A-Z\s\'-]+?):\s*\n(?!\n)', r'\1: ', result, flags=re.MULTILINE)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def transcribe_audio(audio_path: str) -> dict:
|
def transcribe_audio(audio_path: str) -> dict:
|
||||||
"""Transcribe audio using faster-whisper with timestamps."""
|
"""Transcribe audio using faster-whisper with timestamps."""
|
||||||
print(f"[1/5] Transcribing {audio_path}...")
|
print(f"[1/5] Transcribing {audio_path}...")
|
||||||
@@ -155,56 +290,179 @@ Respond with ONLY valid JSON, no markdown or explanation."""
|
|||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
def create_episode(audio_path: str, metadata: dict, duration: int) -> dict:
|
CLOUDFLARE_UPLOAD_LIMIT = 100 * 1024 * 1024 # 100 MB
|
||||||
"""Create episode on Castopod."""
|
|
||||||
|
|
||||||
|
def create_episode(audio_path: str, metadata: dict, episode_number: int, duration: int = 0) -> dict:
|
||||||
|
"""Create episode on Castopod. Bypasses Cloudflare for large files."""
|
||||||
|
file_size = os.path.getsize(audio_path)
|
||||||
|
|
||||||
|
if file_size > CLOUDFLARE_UPLOAD_LIMIT:
|
||||||
|
print(f"[3/5] Creating episode on Castopod (direct, {file_size / 1024 / 1024:.0f} MB > 100 MB limit)...")
|
||||||
|
return _create_episode_direct(audio_path, metadata, episode_number, file_size, duration)
|
||||||
|
|
||||||
print("[3/5] Creating episode on Castopod...")
|
print("[3/5] Creating episode on Castopod...")
|
||||||
|
return _create_episode_api(audio_path, metadata, episode_number)
|
||||||
|
|
||||||
headers = get_auth_header()
|
|
||||||
|
|
||||||
# Upload audio and create episode
|
def _create_episode_api(audio_path: str, metadata: dict, episode_number: int) -> dict:
|
||||||
with open(audio_path, "rb") as f:
|
"""Create episode via Castopod REST API (through Cloudflare)."""
|
||||||
files = {
|
credentials = base64.b64encode(
|
||||||
"audio_file": (Path(audio_path).name, f, "audio/mpeg")
|
f"{CASTOPOD_USERNAME}:{CASTOPOD_PASSWORD}".encode()
|
||||||
}
|
).decode()
|
||||||
data = {
|
slug = re.sub(r'[^a-z0-9]+', '-', metadata["title"].lower()).strip('-')
|
||||||
"title": metadata["title"],
|
|
||||||
"description_markdown": metadata["description"],
|
|
||||||
"parental_advisory": "explicit",
|
|
||||||
"type": "full",
|
|
||||||
"created_by": "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.post(
|
cmd = [
|
||||||
f"{CASTOPOD_URL}/api/rest/v1/podcasts/{PODCAST_ID}/episodes",
|
"curl", "-sk", "-X", "POST",
|
||||||
headers=headers,
|
f"{CASTOPOD_URL}/api/rest/v1/episodes",
|
||||||
files=files,
|
"-H", f"Authorization: Basic {credentials}",
|
||||||
data=data
|
"-F", f"audio_file=@{audio_path};type=audio/mpeg",
|
||||||
)
|
"-F", f"title={metadata['title']}",
|
||||||
|
"-F", f"slug={slug}",
|
||||||
|
"-F", f"description={metadata['description']}",
|
||||||
|
"-F", "parental_advisory=explicit",
|
||||||
|
"-F", "type=full",
|
||||||
|
"-F", f"podcast_id={PODCAST_ID}",
|
||||||
|
"-F", "created_by=1",
|
||||||
|
"-F", "updated_by=1",
|
||||||
|
"-F", f"episode_number={episode_number}",
|
||||||
|
]
|
||||||
|
|
||||||
if response.status_code not in (200, 201):
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=900)
|
||||||
print(f"Error creating episode: {response.text}")
|
if result.returncode != 0:
|
||||||
|
print(f"Error uploading: {result.stderr}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
episode = json.loads(result.stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f"Error parsing response: {result.stdout[:500]}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if "id" not in episode:
|
||||||
|
print(f"Error creating episode: {result.stdout[:500]}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
episode = response.json()
|
|
||||||
print(f" Created episode ID: {episode['id']}")
|
print(f" Created episode ID: {episode['id']}")
|
||||||
print(f" Slug: {episode['slug']}")
|
print(f" Slug: {episode['slug']}")
|
||||||
|
|
||||||
return episode
|
return episode
|
||||||
|
|
||||||
|
|
||||||
|
def _create_episode_direct(audio_path: str, metadata: dict, episode_number: int,
|
||||||
|
file_size: int, duration: int) -> dict:
|
||||||
|
"""Create episode by uploading directly to NAS and inserting into DB."""
|
||||||
|
import time as _time
|
||||||
|
slug = re.sub(r'[^a-z0-9]+', '-', metadata["title"].lower()).strip('-')
|
||||||
|
timestamp = int(_time.time())
|
||||||
|
rand_hex = os.urandom(10).hex()
|
||||||
|
filename = f"{timestamp}_{rand_hex}.mp3"
|
||||||
|
file_key = f"podcasts/{PODCAST_HANDLE}/{filename}"
|
||||||
|
nas_tmp = f"/share/CACHEDEV1_DATA/tmp/{filename}"
|
||||||
|
guid = f"{CASTOPOD_URL}/@{PODCAST_HANDLE}/episodes/{slug}"
|
||||||
|
desc_md = metadata["description"]
|
||||||
|
desc_html = f"<p>{desc_md}</p>"
|
||||||
|
duration_json = json.dumps({"playtime_seconds": duration, "avdataoffset": 85})
|
||||||
|
|
||||||
|
# SCP audio to NAS
|
||||||
|
print(" Uploading audio to NAS...")
|
||||||
|
scp_cmd = ["scp", "-P", str(NAS_SSH_PORT), audio_path, f"{NAS_USER}@{NAS_HOST}:{nas_tmp}"]
|
||||||
|
result = subprocess.run(scp_cmd, capture_output=True, text=True, timeout=600)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Error: SCP failed: {result.stderr}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Docker cp into Castopod container
|
||||||
|
print(" Copying into Castopod container...")
|
||||||
|
media_path = f"/var/www/castopod/public/media/{file_key}"
|
||||||
|
cp_cmd = f'{DOCKER_PATH} cp {nas_tmp} {CASTOPOD_CONTAINER}:{media_path}'
|
||||||
|
success, output = run_ssh_command(cp_cmd, timeout=120)
|
||||||
|
if not success:
|
||||||
|
print(f"Error: docker cp failed: {output}")
|
||||||
|
sys.exit(1)
|
||||||
|
run_ssh_command(f'{DOCKER_PATH} exec {CASTOPOD_CONTAINER} chown www-data:www-data {media_path}')
|
||||||
|
run_ssh_command(f"rm -f {nas_tmp}")
|
||||||
|
|
||||||
|
# Build SQL and transfer via base64 to avoid shell escaping issues
|
||||||
|
print(" Inserting media and episode records...")
|
||||||
|
|
||||||
|
def _mysql_escape(s: str) -> str:
|
||||||
|
"""Escape a string for MySQL single-quoted literals."""
|
||||||
|
return s.replace("\\", "\\\\").replace("'", "\\'")
|
||||||
|
|
||||||
|
title_esc = _mysql_escape(metadata["title"])
|
||||||
|
desc_md_esc = _mysql_escape(desc_md)
|
||||||
|
desc_html_esc = _mysql_escape(desc_html)
|
||||||
|
duration_json_esc = _mysql_escape(duration_json)
|
||||||
|
|
||||||
|
sql = (
|
||||||
|
f"INSERT INTO cp_media (file_key, file_size, file_mimetype, file_metadata, type, "
|
||||||
|
f"uploaded_by, updated_by, uploaded_at, updated_at) VALUES "
|
||||||
|
f"('{file_key}', {file_size}, 'audio/mpeg', '{duration_json_esc}', 'audio', 1, 1, NOW(), NOW());\n"
|
||||||
|
f"SET @audio_id = LAST_INSERT_ID();\n"
|
||||||
|
f"INSERT INTO cp_episodes (podcast_id, guid, title, slug, audio_id, "
|
||||||
|
f"description_markdown, description_html, parental_advisory, number, type, "
|
||||||
|
f"is_blocked, is_published_on_hubs, is_premium, created_by, updated_by, "
|
||||||
|
f"published_at, created_at, updated_at) VALUES "
|
||||||
|
f"(1, '{guid}', '{title_esc}', '{slug}', @audio_id, "
|
||||||
|
f"'{desc_md_esc}', '{desc_html_esc}', 'explicit', {episode_number}, 'full', "
|
||||||
|
f"0, 0, 0, 1, 1, NOW(), NOW(), NOW());\n"
|
||||||
|
f"SELECT LAST_INSERT_ID();\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write SQL to local temp file, SCP to NAS, docker cp into MariaDB
|
||||||
|
local_sql_path = "/tmp/_castopod_insert.sql"
|
||||||
|
nas_sql_path = "/share/CACHEDEV1_DATA/tmp/_castopod_insert.sql"
|
||||||
|
with open(local_sql_path, "w") as f:
|
||||||
|
f.write(sql)
|
||||||
|
scp_sql = ["scp", "-P", str(NAS_SSH_PORT), local_sql_path, f"{NAS_USER}@{NAS_HOST}:{nas_sql_path}"]
|
||||||
|
result = subprocess.run(scp_sql, capture_output=True, text=True, timeout=30)
|
||||||
|
os.remove(local_sql_path)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Error: failed to SCP SQL file: {result.stderr}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Copy SQL into MariaDB container and execute
|
||||||
|
run_ssh_command(f'{DOCKER_PATH} cp {nas_sql_path} {MARIADB_CONTAINER}:/tmp/_insert.sql')
|
||||||
|
exec_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} sh -c "mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -N < /tmp/_insert.sql"'
|
||||||
|
success, output = run_ssh_command(exec_cmd, timeout=30)
|
||||||
|
run_ssh_command(f'rm -f {nas_sql_path}')
|
||||||
|
run_ssh_command(f'{DOCKER_PATH} exec {MARIADB_CONTAINER} rm -f /tmp/_insert.sql')
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print(f"Error: DB insert failed: {output}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
episode_id = int(output.strip().split('\n')[-1])
|
||||||
|
# Get the audio media ID for CDN upload
|
||||||
|
audio_id_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -N -e "SELECT audio_id FROM cp_episodes WHERE id = {episode_id};"'
|
||||||
|
success, audio_id_str = run_ssh_command(audio_id_cmd)
|
||||||
|
audio_id = int(audio_id_str.strip()) if success else None
|
||||||
|
if audio_id:
|
||||||
|
print(f" Audio media ID: {audio_id}")
|
||||||
|
|
||||||
|
# Clear cache
|
||||||
|
run_ssh_command(f'{DOCKER_PATH} exec {CASTOPOD_CONTAINER} php spark cache:clear')
|
||||||
|
|
||||||
|
print(f" Created episode ID: {episode_id}")
|
||||||
|
print(f" Slug: {slug}")
|
||||||
|
|
||||||
|
return {"id": episode_id, "slug": slug}
|
||||||
|
|
||||||
|
|
||||||
def publish_episode(episode_id: int) -> dict:
|
def publish_episode(episode_id: int) -> dict:
|
||||||
"""Publish the episode."""
|
"""Publish the episode."""
|
||||||
print("[4/5] Publishing episode...")
|
print("[4/5] Publishing episode...")
|
||||||
|
|
||||||
headers = get_auth_header()
|
headers = get_auth_header()
|
||||||
|
|
||||||
response = requests.post(
|
response = _session.post(
|
||||||
f"{CASTOPOD_URL}/api/rest/v1/episodes/{episode_id}/publish",
|
f"{CASTOPOD_URL}/api/rest/v1/episodes/{episode_id}/publish",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
data={
|
data={
|
||||||
"publication_method": "now",
|
"publication_method": "now",
|
||||||
"created_by": "1"
|
"created_by": "1"
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
@@ -234,7 +492,7 @@ def save_chapters(metadata: dict, output_path: str):
|
|||||||
print(f" Chapters saved to: {output_path}")
|
print(f" Chapters saved to: {output_path}")
|
||||||
|
|
||||||
|
|
||||||
def run_ssh_command(command: str) -> tuple[bool, str]:
|
def run_ssh_command(command: str, timeout: int = 30) -> tuple[bool, str]:
|
||||||
"""Run a command on the NAS via SSH."""
|
"""Run a command on the NAS via SSH."""
|
||||||
ssh_cmd = [
|
ssh_cmd = [
|
||||||
"ssh", "-p", str(NAS_SSH_PORT),
|
"ssh", "-p", str(NAS_SSH_PORT),
|
||||||
@@ -242,7 +500,7 @@ def run_ssh_command(command: str) -> tuple[bool, str]:
|
|||||||
command
|
command
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=30)
|
result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=timeout)
|
||||||
return result.returncode == 0, result.stdout.strip() or result.stderr.strip()
|
return result.returncode == 0, result.stdout.strip() or result.stderr.strip()
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return False, "SSH command timed out"
|
return False, "SSH command timed out"
|
||||||
@@ -307,13 +565,289 @@ def upload_chapters_to_castopod(episode_slug: str, episode_id: int, chapters_pat
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def upload_to_bunny(local_path: str, remote_path: str, content_type: str = None) -> bool:
|
||||||
|
"""Upload a file to BunnyCDN Storage."""
|
||||||
|
if not content_type:
|
||||||
|
ext = Path(local_path).suffix.lower()
|
||||||
|
content_type = {
|
||||||
|
".mp3": "audio/mpeg", ".png": "image/png", ".jpg": "image/jpeg",
|
||||||
|
".json": "application/json", ".srt": "application/x-subrip",
|
||||||
|
}.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
url = f"https://{BUNNY_STORAGE_REGION}.storage.bunnycdn.com/{BUNNY_STORAGE_ZONE}/{remote_path}"
|
||||||
|
with open(local_path, "rb") as f:
|
||||||
|
resp = requests.put(url, data=f, headers={
|
||||||
|
"AccessKey": BUNNY_STORAGE_KEY,
|
||||||
|
"Content-Type": content_type,
|
||||||
|
}, timeout=600)
|
||||||
|
if resp.status_code == 201:
|
||||||
|
return True
|
||||||
|
print(f" Warning: BunnyCDN upload failed ({resp.status_code}): {resp.text[:200]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def download_from_castopod(file_key: str, local_path: str) -> bool:
|
||||||
|
"""Download a file from Castopod's container storage to local filesystem."""
|
||||||
|
remote_filename = Path(file_key).name
|
||||||
|
remote_tmp = f"/share/CACHEDEV1_DATA/tmp/castopod_{remote_filename}"
|
||||||
|
cp_cmd = f'{DOCKER_PATH} cp {CASTOPOD_CONTAINER}:/var/www/castopod/public/media/{file_key} {remote_tmp}'
|
||||||
|
success, _ = run_ssh_command(cp_cmd, timeout=120)
|
||||||
|
if not success:
|
||||||
|
return False
|
||||||
|
scp_cmd = [
|
||||||
|
"scp", "-P", str(NAS_SSH_PORT),
|
||||||
|
f"{NAS_USER}@{NAS_HOST}:{remote_tmp}",
|
||||||
|
local_path
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(scp_cmd, capture_output=True, text=True, timeout=300)
|
||||||
|
ok = result.returncode == 0
|
||||||
|
except (subprocess.TimeoutExpired, Exception):
|
||||||
|
ok = False
|
||||||
|
run_ssh_command(f"rm -f {remote_tmp}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def sync_episode_media_to_bunny(episode_id: int, already_uploaded: set):
|
||||||
|
"""Ensure all media linked to an episode exists on BunnyCDN."""
|
||||||
|
ep_id = episode_id
|
||||||
|
query = (
|
||||||
|
"SELECT DISTINCT m.file_key FROM cp_media m WHERE m.id IN ("
|
||||||
|
f"SELECT audio_id FROM cp_episodes WHERE id = {ep_id} "
|
||||||
|
f"UNION ALL SELECT cover_id FROM cp_episodes WHERE id = {ep_id} AND cover_id IS NOT NULL "
|
||||||
|
f"UNION ALL SELECT transcript_id FROM cp_episodes WHERE id = {ep_id} AND transcript_id IS NOT NULL "
|
||||||
|
f"UNION ALL SELECT chapters_id FROM cp_episodes WHERE id = {ep_id} AND chapters_id IS NOT NULL)"
|
||||||
|
)
|
||||||
|
cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -N -e "{query};"'
|
||||||
|
success, output = run_ssh_command(cmd)
|
||||||
|
if not success or not output:
|
||||||
|
return
|
||||||
|
file_keys = [line.strip() for line in output.strip().split('\n') if line.strip()]
|
||||||
|
for file_key in file_keys:
|
||||||
|
if file_key in already_uploaded:
|
||||||
|
continue
|
||||||
|
cdn_url = f"https://cdn.lukeattheroost.com/media/{file_key}"
|
||||||
|
try:
|
||||||
|
resp = requests.head(cdn_url, timeout=10)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=Path(file_key).suffix, delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
try:
|
||||||
|
if download_from_castopod(file_key, tmp_path):
|
||||||
|
print(f" Syncing to CDN: {file_key}")
|
||||||
|
upload_to_bunny(tmp_path, f"media/{file_key}")
|
||||||
|
else:
|
||||||
|
print(f" Warning: Could not sync {file_key} to CDN")
|
||||||
|
finally:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def add_episode_to_sitemap(slug: str):
|
||||||
|
"""Add episode transcript page to sitemap.xml."""
|
||||||
|
sitemap_path = Path(__file__).parent / "website" / "sitemap.xml"
|
||||||
|
if not sitemap_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
url = f"https://lukeattheroost.com/episode.html?slug={slug}"
|
||||||
|
content = sitemap_path.read_text()
|
||||||
|
|
||||||
|
if url in content:
|
||||||
|
print(f" Episode already in sitemap")
|
||||||
|
return
|
||||||
|
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
new_entry = f""" <url>
|
||||||
|
<loc>{url}</loc>
|
||||||
|
<lastmod>{today}</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>"""
|
||||||
|
|
||||||
|
content = content.replace("</urlset>", new_entry)
|
||||||
|
sitemap_path.write_text(content)
|
||||||
|
print(f" Added episode to sitemap.xml")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def generate_social_image(episode_number: int, description: str, output_path: str) -> str:
|
||||||
|
"""Generate a social media image with cover art, episode number, and description."""
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
COVER_ART = Path(__file__).parent / "website" / "images" / "cover.png"
|
||||||
|
SIZE = 1080
|
||||||
|
|
||||||
|
img = Image.open(COVER_ART).convert("RGBA")
|
||||||
|
img = img.resize((SIZE, SIZE), Image.LANCZOS)
|
||||||
|
|
||||||
|
# Dark gradient overlay on the bottom ~45%
|
||||||
|
gradient = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||||
|
draw_grad = ImageDraw.Draw(gradient)
|
||||||
|
gradient_start = int(SIZE * 0.50)
|
||||||
|
for y in range(gradient_start, SIZE):
|
||||||
|
progress = (y - gradient_start) / (SIZE - gradient_start)
|
||||||
|
alpha = int(210 * progress)
|
||||||
|
draw_grad.line([(0, y), (SIZE, y)], fill=(0, 0, 0, alpha))
|
||||||
|
|
||||||
|
img = Image.alpha_composite(img, gradient)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Fonts
|
||||||
|
try:
|
||||||
|
font_episode = ImageFont.truetype("/Library/Fonts/Montserrat-ExtraBold.ttf", 64)
|
||||||
|
font_desc = ImageFont.truetype("/Library/Fonts/Montserrat-Medium.ttf", 36)
|
||||||
|
font_url = ImageFont.truetype("/Library/Fonts/Montserrat-SemiBold.ttf", 28)
|
||||||
|
except OSError:
|
||||||
|
font_episode = ImageFont.truetype("/Library/Fonts/Arial Unicode.ttf", 64)
|
||||||
|
font_desc = ImageFont.truetype("/Library/Fonts/Arial Unicode.ttf", 36)
|
||||||
|
font_url = ImageFont.truetype("/Library/Fonts/Arial Unicode.ttf", 28)
|
||||||
|
|
||||||
|
margin = 60
|
||||||
|
max_width = SIZE - margin * 2
|
||||||
|
|
||||||
|
# Episode number
|
||||||
|
ep_text = f"EPISODE {episode_number}"
|
||||||
|
draw.text((margin, SIZE - 300), ep_text, font=font_episode, fill=(255, 200, 80))
|
||||||
|
|
||||||
|
# Description — word-wrap to fit
|
||||||
|
wrapped = textwrap.fill(description, width=45)
|
||||||
|
lines = wrapped.split("\n")[:4] # max 4 lines
|
||||||
|
if len(wrapped.split("\n")) > 4:
|
||||||
|
lines[-1] = lines[-1][:lines[-1].rfind(" ")] + "..."
|
||||||
|
desc_text = "\n".join(lines)
|
||||||
|
draw.text((margin, SIZE - 220), desc_text, font=font_desc, fill=(255, 255, 255, 230),
|
||||||
|
spacing=8)
|
||||||
|
|
||||||
|
# Website URL — bottom right
|
||||||
|
url_text = "lukeattheroost.com"
|
||||||
|
bbox = draw.textbbox((0, 0), url_text, font=font_url)
|
||||||
|
url_width = bbox[2] - bbox[0]
|
||||||
|
draw.text((SIZE - margin - url_width, SIZE - 50), url_text, font=font_url,
|
||||||
|
fill=(255, 200, 80, 200))
|
||||||
|
|
||||||
|
img = img.convert("RGB")
|
||||||
|
img.save(output_path, "JPEG", quality=92)
|
||||||
|
print(f" Social image saved: {output_path}")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def _get_postiz_token():
|
||||||
|
"""Generate a JWT token for Postiz API authentication."""
|
||||||
|
import jwt
|
||||||
|
return jwt.encode(
|
||||||
|
{"id": POSTIZ_USER_ID, "email": "luke@macneilmediagroup.com",
|
||||||
|
"providerName": "LOCAL", "activated": True, "isSuperAdmin": False},
|
||||||
|
POSTIZ_JWT_SECRET, algorithm="HS256"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image_to_postiz(image_path: str) -> dict | None:
|
||||||
|
"""Upload an image to Postiz and return the media object."""
|
||||||
|
token = _get_postiz_token()
|
||||||
|
try:
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{POSTIZ_URL}/api/media/upload-simple",
|
||||||
|
headers={"auth": token},
|
||||||
|
files={"file": ("social.jpg", f, "image/jpeg")},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
media = resp.json()
|
||||||
|
print(f" Uploaded image to Postiz (id: {media.get('id', 'unknown')})")
|
||||||
|
return media
|
||||||
|
else:
|
||||||
|
print(f" Warning: Postiz image upload returned {resp.status_code}: {resp.text[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Warning: Postiz image upload failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def post_to_social(metadata: dict, episode_slug: str, image_path: str = None):
|
||||||
|
"""Post episode announcement to all connected social channels via Postiz."""
|
||||||
|
print("[5.5/5] Posting to social media...")
|
||||||
|
|
||||||
|
token = _get_postiz_token()
|
||||||
|
|
||||||
|
# Upload image if provided
|
||||||
|
image_ids = []
|
||||||
|
if image_path:
|
||||||
|
media = upload_image_to_postiz(image_path)
|
||||||
|
if media and media.get("id"):
|
||||||
|
image_ids = [{"id": media["id"], "path": media.get("path", "")}]
|
||||||
|
|
||||||
|
episode_url = f"https://lukeattheroost.com/episode.html?slug={episode_slug}"
|
||||||
|
base_content = f"{metadata['title']}\n\n{metadata['description']}\n\n{episode_url}"
|
||||||
|
|
||||||
|
hashtags = "#podcast #LukeAtTheRoost #talkradio #callinshow #newepisode"
|
||||||
|
hashtag_platforms = {"instagram", "facebook", "bluesky", "mastodon", "nostr"}
|
||||||
|
|
||||||
|
# Platform-specific content length limits
|
||||||
|
PLATFORM_MAX_LENGTH = {"bluesky": 300}
|
||||||
|
|
||||||
|
# Post to each platform individually so one failure doesn't block others
|
||||||
|
posted = 0
|
||||||
|
for platform, intg_config in POSTIZ_INTEGRATIONS.items():
|
||||||
|
content = base_content
|
||||||
|
if platform in hashtag_platforms:
|
||||||
|
content += f"\n\n{hashtags}"
|
||||||
|
|
||||||
|
# Truncate for platforms with short limits
|
||||||
|
max_len = PLATFORM_MAX_LENGTH.get(platform)
|
||||||
|
if max_len and len(content) > max_len:
|
||||||
|
# Keep title + URL, truncate description
|
||||||
|
short = f"{metadata['title']}\n\n{episode_url}"
|
||||||
|
if platform in hashtag_platforms:
|
||||||
|
short += f"\n\n{hashtags}"
|
||||||
|
content = short[:max_len]
|
||||||
|
|
||||||
|
settings = {"post_type": "post"}
|
||||||
|
if "channel" in intg_config:
|
||||||
|
settings["channel"] = intg_config["channel"]
|
||||||
|
|
||||||
|
post = {
|
||||||
|
"integration": {"id": intg_config["id"]},
|
||||||
|
"value": [{"content": content, "image": image_ids}],
|
||||||
|
"settings": settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "now",
|
||||||
|
"shortLink": False,
|
||||||
|
"date": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
||||||
|
"tags": [],
|
||||||
|
"posts": [post],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{POSTIZ_URL}/api/posts",
|
||||||
|
headers={"auth": token, "Content-Type": "application/json"},
|
||||||
|
json=payload,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
posted += 1
|
||||||
|
print(f" Posted to {platform}")
|
||||||
|
else:
|
||||||
|
print(f" Warning: {platform} failed ({resp.status_code}): {resp.text[:150]}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Warning: {platform} failed: {e}")
|
||||||
|
|
||||||
|
print(f" Posted to {posted}/{len(POSTIZ_INTEGRATIONS)} channels")
|
||||||
|
|
||||||
|
|
||||||
def get_next_episode_number() -> int:
|
def get_next_episode_number() -> int:
|
||||||
"""Get the next episode number from Castopod."""
|
"""Get the next episode number from Castopod."""
|
||||||
headers = get_auth_header()
|
headers = get_auth_header()
|
||||||
|
|
||||||
response = requests.get(
|
response = _session.get(
|
||||||
f"{CASTOPOD_URL}/api/rest/v1/podcasts/{PODCAST_ID}/episodes",
|
f"{CASTOPOD_URL}/api/rest/v1/episodes",
|
||||||
headers=headers
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
@@ -323,7 +857,12 @@ def get_next_episode_number() -> int:
|
|||||||
if not episodes:
|
if not episodes:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
max_num = max(ep.get("number", 0) for ep in episodes)
|
# Filter to our podcast
|
||||||
|
our_episodes = [ep for ep in episodes if ep.get("podcast_id") == PODCAST_ID]
|
||||||
|
if not our_episodes:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
max_num = max(ep.get("number", 0) or 0 for ep in our_episodes)
|
||||||
return max_num + 1
|
return max_num + 1
|
||||||
|
|
||||||
|
|
||||||
@@ -334,6 +873,7 @@ def main():
|
|||||||
parser.add_argument("--dry-run", "-d", action="store_true", help="Generate metadata but don't publish")
|
parser.add_argument("--dry-run", "-d", action="store_true", help="Generate metadata but don't publish")
|
||||||
parser.add_argument("--title", "-t", help="Override generated title")
|
parser.add_argument("--title", "-t", help="Override generated title")
|
||||||
parser.add_argument("--description", help="Override generated description")
|
parser.add_argument("--description", help="Override generated description")
|
||||||
|
parser.add_argument("--session-data", "-s", help="Path to session export JSON (from /api/session/export)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
audio_path = Path(args.audio_file).expanduser().resolve()
|
audio_path = Path(args.audio_file).expanduser().resolve()
|
||||||
@@ -348,12 +888,28 @@ def main():
|
|||||||
episode_number = get_next_episode_number()
|
episode_number = get_next_episode_number()
|
||||||
print(f"Episode number: {episode_number}")
|
print(f"Episode number: {episode_number}")
|
||||||
|
|
||||||
|
# Load session data if provided
|
||||||
|
session_data = None
|
||||||
|
if args.session_data:
|
||||||
|
session_path = Path(args.session_data).expanduser().resolve()
|
||||||
|
if session_path.exists():
|
||||||
|
with open(session_path) as f:
|
||||||
|
session_data = json.load(f)
|
||||||
|
print(f"Loaded session data: {session_data.get('call_count', 0)} calls")
|
||||||
|
else:
|
||||||
|
print(f"Warning: Session data file not found: {session_path}")
|
||||||
|
|
||||||
# Step 1: Transcribe
|
# Step 1: Transcribe
|
||||||
transcript = transcribe_audio(str(audio_path))
|
transcript = transcribe_audio(str(audio_path))
|
||||||
|
|
||||||
# Step 2: Generate metadata
|
# Step 2: Generate metadata
|
||||||
metadata = generate_metadata(transcript, episode_number)
|
metadata = generate_metadata(transcript, episode_number)
|
||||||
|
|
||||||
|
# Use session chapters if available (more accurate than LLM-generated)
|
||||||
|
if session_data and session_data.get("chapters"):
|
||||||
|
metadata["chapters"] = session_data["chapters"]
|
||||||
|
print(f" Using {len(metadata['chapters'])} chapters from session data")
|
||||||
|
|
||||||
# Apply overrides
|
# Apply overrides
|
||||||
if args.title:
|
if args.title:
|
||||||
metadata["title"] = args.title
|
metadata["title"] = args.title
|
||||||
@@ -364,6 +920,21 @@ def main():
|
|||||||
chapters_path = audio_path.with_suffix(".chapters.json")
|
chapters_path = audio_path.with_suffix(".chapters.json")
|
||||||
save_chapters(metadata, str(chapters_path))
|
save_chapters(metadata, str(chapters_path))
|
||||||
|
|
||||||
|
# Save transcript text file with LUKE:/CALLER: speaker labels
|
||||||
|
transcript_path = audio_path.with_suffix(".transcript.txt")
|
||||||
|
raw_text = transcript["full_text"]
|
||||||
|
labeled_text = label_transcript_speakers(raw_text)
|
||||||
|
with open(transcript_path, "w") as f:
|
||||||
|
f.write(labeled_text)
|
||||||
|
print(f" Transcript saved to: {transcript_path}")
|
||||||
|
|
||||||
|
# Save session transcript alongside episode if available (has speaker labels)
|
||||||
|
if session_data and session_data.get("transcript"):
|
||||||
|
session_transcript_path = audio_path.with_suffix(".session_transcript.txt")
|
||||||
|
with open(session_transcript_path, "w") as f:
|
||||||
|
f.write(session_data["transcript"])
|
||||||
|
print(f" Session transcript saved to: {session_transcript_path}")
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print("\n[DRY RUN] Would publish with:")
|
print("\n[DRY RUN] Would publish with:")
|
||||||
print(f" Title: {metadata['title']}")
|
print(f" Title: {metadata['title']}")
|
||||||
@@ -373,10 +944,72 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Step 3: Create episode
|
# Step 3: Create episode
|
||||||
episode = create_episode(str(audio_path), metadata, transcript["duration"])
|
direct_upload = os.path.getsize(str(audio_path)) > CLOUDFLARE_UPLOAD_LIMIT
|
||||||
|
episode = create_episode(str(audio_path), metadata, episode_number, duration=transcript["duration"])
|
||||||
|
|
||||||
# Step 4: Publish
|
# Step 3.5: Upload to BunnyCDN
|
||||||
episode = publish_episode(episode["id"])
|
print("[3.5/5] Uploading to BunnyCDN...")
|
||||||
|
uploaded_keys = set()
|
||||||
|
|
||||||
|
# Audio: query file_key from DB, then upload to CDN
|
||||||
|
ep_id = episode["id"]
|
||||||
|
audio_media_cmd = f'{DOCKER_PATH} exec {MARIADB_CONTAINER} mysql -u {DB_USER} -p{DB_PASS} {DB_NAME} -N -e "SELECT m.file_key FROM cp_media m JOIN cp_episodes e ON e.audio_id = m.id WHERE e.id = {ep_id};"'
|
||||||
|
success, audio_file_key = run_ssh_command(audio_media_cmd)
|
||||||
|
if success and audio_file_key:
|
||||||
|
audio_file_key = audio_file_key.strip()
|
||||||
|
if direct_upload:
|
||||||
|
# Direct upload: we have the original file locally, upload straight to CDN
|
||||||
|
print(f" Uploading audio to BunnyCDN")
|
||||||
|
upload_to_bunny(str(audio_path), f"media/{audio_file_key}", "audio/mpeg")
|
||||||
|
else:
|
||||||
|
# API upload: download Castopod's copy (ensures byte-exact match with RSS metadata)
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
|
||||||
|
tmp_audio = tmp.name
|
||||||
|
try:
|
||||||
|
print(f" Downloading from Castopod: {audio_file_key}")
|
||||||
|
if download_from_castopod(audio_file_key, tmp_audio):
|
||||||
|
print(f" Uploading audio to BunnyCDN")
|
||||||
|
upload_to_bunny(tmp_audio, f"media/{audio_file_key}", "audio/mpeg")
|
||||||
|
else:
|
||||||
|
print(f" Castopod download failed, uploading original file")
|
||||||
|
upload_to_bunny(str(audio_path), f"media/{audio_file_key}", "audio/mpeg")
|
||||||
|
finally:
|
||||||
|
Path(tmp_audio).unlink(missing_ok=True)
|
||||||
|
uploaded_keys.add(audio_file_key)
|
||||||
|
else:
|
||||||
|
print(f" Error: Could not determine audio file_key from Castopod DB")
|
||||||
|
print(f" Audio will be served from Castopod directly (not CDN)")
|
||||||
|
|
||||||
|
# Chapters
|
||||||
|
chapters_key = f"podcasts/{PODCAST_HANDLE}/{episode['slug']}-chapters.json"
|
||||||
|
print(f" Uploading chapters to BunnyCDN")
|
||||||
|
upload_to_bunny(str(chapters_path), f"media/{chapters_key}")
|
||||||
|
uploaded_keys.add(chapters_key)
|
||||||
|
|
||||||
|
# Transcript
|
||||||
|
print(f" Uploading transcript to BunnyCDN")
|
||||||
|
upload_to_bunny(str(transcript_path), f"transcripts/{episode['slug']}.txt", "text/plain")
|
||||||
|
|
||||||
|
# Copy transcript to website dir for Cloudflare Pages
|
||||||
|
website_transcript_dir = Path(__file__).parent / "website" / "transcripts"
|
||||||
|
website_transcript_dir.mkdir(exist_ok=True)
|
||||||
|
website_transcript_path = website_transcript_dir / f"{episode['slug']}.txt"
|
||||||
|
shutil.copy2(str(transcript_path), str(website_transcript_path))
|
||||||
|
print(f" Transcript copied to website/transcripts/")
|
||||||
|
|
||||||
|
# Add to sitemap
|
||||||
|
add_episode_to_sitemap(episode["slug"])
|
||||||
|
|
||||||
|
# Step 4: Publish via API (triggers RSS rebuild, federation, etc.)
|
||||||
|
try:
|
||||||
|
published = publish_episode(episode["id"])
|
||||||
|
if "slug" in published:
|
||||||
|
episode = published
|
||||||
|
except SystemExit:
|
||||||
|
if direct_upload:
|
||||||
|
print(" Warning: Publish API failed, but episode is in DB with published_at set")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
# Step 4.5: Upload chapters via SSH
|
# Step 4.5: Upload chapters via SSH
|
||||||
chapters_uploaded = upload_chapters_to_castopod(
|
chapters_uploaded = upload_chapters_to_castopod(
|
||||||
@@ -385,8 +1018,30 @@ def main():
|
|||||||
str(chapters_path)
|
str(chapters_path)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 5: Summary
|
# Sync any remaining episode media to BunnyCDN (cover art, transcripts, etc.)
|
||||||
print("\n[5/5] Done!")
|
print(" Syncing episode media to CDN...")
|
||||||
|
sync_episode_media_to_bunny(episode["id"], uploaded_keys)
|
||||||
|
|
||||||
|
# Step 5: Deploy website (transcript + sitemap must be live before social links go out)
|
||||||
|
print("[5/5] Deploying website...")
|
||||||
|
project_dir = Path(__file__).parent
|
||||||
|
deploy_result = subprocess.run(
|
||||||
|
["npx", "wrangler", "pages", "deploy", "website/",
|
||||||
|
"--project-name=lukeattheroost", "--branch=main", "--commit-dirty=true"],
|
||||||
|
capture_output=True, text=True, cwd=project_dir, timeout=120
|
||||||
|
)
|
||||||
|
if deploy_result.returncode == 0:
|
||||||
|
print(" Website deployed")
|
||||||
|
else:
|
||||||
|
print(f" Warning: Website deploy failed: {deploy_result.stderr[:200]}")
|
||||||
|
|
||||||
|
# Step 5.5: Generate social image and post
|
||||||
|
social_image_path = str(audio_path.with_suffix(".social.jpg"))
|
||||||
|
generate_social_image(episode_number, metadata["description"], social_image_path)
|
||||||
|
post_to_social(metadata, episode["slug"], social_image_path)
|
||||||
|
|
||||||
|
# Step 6: Summary
|
||||||
|
print("\n[6/6] Done!")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print(f"Episode URL: {CASTOPOD_URL}/@{PODCAST_HANDLE}/episodes/{episode['slug']}")
|
print(f"Episode URL: {CASTOPOD_URL}/@{PODCAST_HANDLE}/episodes/{episode['slug']}")
|
||||||
print(f"RSS Feed: {CASTOPOD_URL}/@{PODCAST_HANDLE}/feed.xml")
|
print(f"RSS Feed: {CASTOPOD_URL}/@{PODCAST_HANDLE}/feed.xml")
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Re-label podcast transcripts with LUKE:/CALLER: speaker labels using LLM."""
|
||||||
|
|
||||||
|
import os, re, sys, time, requests
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
API_KEY = os.getenv("OPENROUTER_API_KEY")
|
||||||
|
TRANSCRIPT_DIR = Path(__file__).parent / "website" / "transcripts"
|
||||||
|
MODEL = "anthropic/claude-3.5-sonnet"
|
||||||
|
CHUNK_SIZE = 8000
|
||||||
|
|
||||||
|
PROMPT = """Insert speaker labels into this radio show transcript. The show is "Luke at the Roost". The host is LUKE. Callers call in one at a time.
|
||||||
|
|
||||||
|
CRITICAL: Output EVERY SINGLE WORD from the input. Do NOT summarize, shorten, paraphrase, or skip ANY text. The output must contain the EXACT SAME words as the input, with ONLY speaker labels and line breaks added.
|
||||||
|
|
||||||
|
At each speaker change, insert a blank line and the new speaker's label (e.g., "LUKE:" or "REGGIE:").
|
||||||
|
|
||||||
|
Speaker identification:
|
||||||
|
- LUKE is the host — he introduces callers, asks questions, does sponsor reads, opens and closes the show
|
||||||
|
- Callers are introduced by name by Luke (e.g., "let's talk to Earl", "next up Brenda")
|
||||||
|
- Use caller FIRST NAME in caps as the label
|
||||||
|
- When Luke says "Tell me about..." or asks a question, that's LUKE
|
||||||
|
- When someone responds with their story/opinion/answer, that's the CALLER
|
||||||
|
|
||||||
|
Output format — ONLY the labeled transcript with blank lines between turns. No notes, no commentary."""
|
||||||
|
|
||||||
|
CONTEXT_PROMPT = "\n\nCONTEXT: The previous section ended with the speaker {speaker}. Last few words: \"{tail}\""
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(text, max_chars=CHUNK_SIZE):
|
||||||
|
if len(text) <= max_chars:
|
||||||
|
return [text]
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
while text:
|
||||||
|
if len(text) <= max_chars:
|
||||||
|
# Merge tiny tails into the previous chunk
|
||||||
|
if chunks and len(text) < 1000:
|
||||||
|
chunks[-1] = chunks[-1] + " " + text
|
||||||
|
else:
|
||||||
|
chunks.append(text)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Find a good break point near max_chars
|
||||||
|
pos = text[:max_chars].rfind('. ')
|
||||||
|
if pos < max_chars // 2:
|
||||||
|
pos = text[:max_chars].rfind('? ')
|
||||||
|
if pos < max_chars // 2:
|
||||||
|
pos = text[:max_chars].rfind('! ')
|
||||||
|
if pos < max_chars // 2:
|
||||||
|
pos = max_chars
|
||||||
|
|
||||||
|
chunks.append(text[:pos + 1].strip())
|
||||||
|
text = text[pos + 1:].strip()
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def label_chunk(text, context=""):
|
||||||
|
prompt = PROMPT + "\n\nTRANSCRIPT:\n" + text
|
||||||
|
if context:
|
||||||
|
prompt += context
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {API_KEY}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": MODEL,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": 8192,
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f" API error: {response.status_code} {response.text[:200]}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = response.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
|
||||||
|
# Remove any markdown code block wrappers
|
||||||
|
if content.startswith("```"):
|
||||||
|
content = re.sub(r'^```\w*\n?', '', content)
|
||||||
|
content = re.sub(r'\n?```$', '', content)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_speaker(text):
|
||||||
|
lines = text.strip().split('\n')
|
||||||
|
for line in reversed(lines):
|
||||||
|
match = re.match(r'^([A-Z][A-Z\s\'-]+?):', line.strip())
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return "LUKE"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_output(original, labeled):
|
||||||
|
"""Basic validation that the output looks right."""
|
||||||
|
# Check that speaker labels exist (at least 1 for short chunks)
|
||||||
|
speaker_lines = re.findall(r'^[A-Z][A-Z\s\'-]+?:', labeled, re.MULTILINE)
|
||||||
|
if len(speaker_lines) < 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check that output isn't drastically shorter (allowing for some reformatting)
|
||||||
|
orig_words = len(original.split())
|
||||||
|
labeled_words = len(labeled.split())
|
||||||
|
if labeled_words < orig_words * 0.5:
|
||||||
|
print(f" WARNING: Output is {labeled_words} words vs {orig_words} input words ({labeled_words * 100 // orig_words}%)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def process_transcript(filepath):
|
||||||
|
text = filepath.read_text().strip()
|
||||||
|
# Strip existing timestamp markers
|
||||||
|
text = re.sub(r'\[[\d:]+\]\s*', '', text)
|
||||||
|
# Normalize whitespace
|
||||||
|
text = re.sub(r'\n+', ' ', text)
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
|
||||||
|
print(f" {len(text)} chars")
|
||||||
|
|
||||||
|
chunks = chunk_text(text)
|
||||||
|
print(f" {len(chunks)} chunk(s)")
|
||||||
|
|
||||||
|
labeled_parts = []
|
||||||
|
context = ""
|
||||||
|
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
print(f" Processing chunk {i + 1}/{len(chunks)} ({len(chunk)} chars)...")
|
||||||
|
labeled = label_chunk(chunk, context)
|
||||||
|
|
||||||
|
if labeled is None:
|
||||||
|
print(f" ERROR: API call failed for chunk {i + 1}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not validate_output(chunk, labeled):
|
||||||
|
print(f" ERROR: Validation failed for chunk {i + 1}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
labeled_parts.append(labeled)
|
||||||
|
|
||||||
|
# Build context for next chunk
|
||||||
|
last_speaker = get_last_speaker(labeled)
|
||||||
|
tail = labeled.strip()[-100:]
|
||||||
|
context = CONTEXT_PROMPT.format(speaker=last_speaker, tail=tail)
|
||||||
|
|
||||||
|
if i < len(chunks) - 1:
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Join parts, ensuring proper spacing between chunks
|
||||||
|
result = "\n\n".join(labeled_parts)
|
||||||
|
# Normalize: ensure exactly one blank line between speaker turns
|
||||||
|
result = re.sub(r'\n{3,}', '\n\n', result)
|
||||||
|
# Fix format: put speaker label on same line as text (SPEAKER:\ntext -> SPEAKER: text)
|
||||||
|
result = re.sub(r'^([A-Z][A-Z\s\'-]+?):\s*\n(?!\n)', r'\1: ', result, flags=re.MULTILINE)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not API_KEY:
|
||||||
|
print("Error: OPENROUTER_API_KEY not set")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
files = sys.argv[1:] if len(sys.argv) > 1 else None
|
||||||
|
if files:
|
||||||
|
transcripts = [TRANSCRIPT_DIR / f for f in files]
|
||||||
|
else:
|
||||||
|
transcripts = sorted(TRANSCRIPT_DIR.glob("*.txt"))
|
||||||
|
|
||||||
|
for filepath in transcripts:
|
||||||
|
if not filepath.exists():
|
||||||
|
print(f"Skipping {filepath.name} (not found)")
|
||||||
|
continue
|
||||||
|
print(f"\nProcessing: {filepath.name}")
|
||||||
|
labeled = process_transcript(filepath)
|
||||||
|
if labeled is None:
|
||||||
|
print(f" SKIPPED (processing failed)")
|
||||||
|
continue
|
||||||
|
filepath.write_text(labeled + "\n")
|
||||||
|
print(f" Saved ({len(labeled)} chars)")
|
||||||
|
|
||||||
|
print("\nDone!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# AI Radio Show - Server Runner with restart support
|
# AI Radio Show - Server Runner with restart support
|
||||||
|
|
||||||
|
PORT=8000
|
||||||
LOG_FILE="/tmp/ai-radio-show.log"
|
LOG_FILE="/tmp/ai-radio-show.log"
|
||||||
RESTART_FLAG="/tmp/ai-radio-show.restart"
|
RESTART_FLAG="/tmp/ai-radio-show.restart"
|
||||||
STOP_FLAG="/tmp/ai-radio-show.stop"
|
STOP_FLAG="/tmp/ai-radio-show.stop"
|
||||||
@@ -13,16 +14,46 @@ source venv/bin/activate
|
|||||||
# Cleanup old flags
|
# Cleanup old flags
|
||||||
rm -f "$RESTART_FLAG" "$STOP_FLAG"
|
rm -f "$RESTART_FLAG" "$STOP_FLAG"
|
||||||
|
|
||||||
|
# Check if port is already in use
|
||||||
|
if lsof -i ":$PORT" -sTCP:LISTEN -t >/dev/null 2>&1; then
|
||||||
|
EXISTING_PID=$(lsof -i ":$PORT" -sTCP:LISTEN -t 2>/dev/null | head -1)
|
||||||
|
echo "ERROR: Port $PORT is already in use by PID $EXISTING_PID"
|
||||||
|
echo "Run: kill $EXISTING_PID"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
kill_server() {
|
||||||
|
local pid=$1
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
kill "$pid" 2>/dev/null
|
||||||
|
# Wait up to 5 seconds for graceful shutdown
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
# Force kill if still alive
|
||||||
|
echo "[$(date)] Server didn't stop gracefully, force killing..." | tee -a "$LOG_FILE"
|
||||||
|
kill -9 "$pid" 2>/dev/null
|
||||||
|
wait "$pid" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
echo "AI Radio Show Server Runner"
|
echo "AI Radio Show Server Runner"
|
||||||
echo "Log file: $LOG_FILE"
|
echo "Log file: $LOG_FILE"
|
||||||
echo "Press Ctrl+C to stop"
|
echo "Press Ctrl+C to stop"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
while true; do
|
# Handle Ctrl+C
|
||||||
echo "[$(date)] Starting server..." | tee -a "$LOG_FILE"
|
trap 'echo ""; echo "[$(date)] Interrupted" | tee -a "$LOG_FILE"; kill_server $SERVER_PID; exit 0' INT TERM
|
||||||
|
|
||||||
# Start uvicorn with output to both console and log file
|
while true; do
|
||||||
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 2>&1 | tee -a "$LOG_FILE" &
|
echo "[$(date)] Starting server on port $PORT..." | tee -a "$LOG_FILE"
|
||||||
|
|
||||||
|
# Start uvicorn directly (not through tee pipe so we get the real PID)
|
||||||
|
python -m uvicorn backend.main:app --host 0.0.0.0 --port $PORT >> "$LOG_FILE" 2>&1 &
|
||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
|
|
||||||
# Wait for server to exit or restart signal
|
# Wait for server to exit or restart signal
|
||||||
@@ -30,8 +61,7 @@ while true; do
|
|||||||
if [ -f "$RESTART_FLAG" ]; then
|
if [ -f "$RESTART_FLAG" ]; then
|
||||||
echo "[$(date)] Restart requested..." | tee -a "$LOG_FILE"
|
echo "[$(date)] Restart requested..." | tee -a "$LOG_FILE"
|
||||||
rm -f "$RESTART_FLAG"
|
rm -f "$RESTART_FLAG"
|
||||||
kill $SERVER_PID 2>/dev/null
|
kill_server $SERVER_PID
|
||||||
wait $SERVER_PID 2>/dev/null
|
|
||||||
sleep 1
|
sleep 1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -39,8 +69,7 @@ while true; do
|
|||||||
if [ -f "$STOP_FLAG" ]; then
|
if [ -f "$STOP_FLAG" ]; then
|
||||||
echo "[$(date)] Stop requested..." | tee -a "$LOG_FILE"
|
echo "[$(date)] Stop requested..." | tee -a "$LOG_FILE"
|
||||||
rm -f "$STOP_FLAG"
|
rm -f "$STOP_FLAG"
|
||||||
kill $SERVER_PID 2>/dev/null
|
kill_server $SERVER_PID
|
||||||
wait $SERVER_PID 2>/dev/null
|
|
||||||
echo "[$(date)] Server stopped." | tee -a "$LOG_FILE"
|
echo "[$(date)] Server stopped." | tee -a "$LOG_FILE"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,326 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
|
<title id="page-title">Episode — Luke at the Roost</title>
|
||||||
|
<meta name="description" id="page-description" content="Full transcript of this episode of Luke at the Roost, the late-night call-in radio show.">
|
||||||
|
<link rel="canonical" id="page-canonical" href="https://lukeattheroost.com/episode.html">
|
||||||
|
|
||||||
|
<!-- OG / Social -->
|
||||||
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
|
<meta property="og:title" id="og-title" content="Episode — Luke at the Roost">
|
||||||
|
<meta property="og:description" id="og-description" content="Full transcript of this episode of Luke at the Roost.">
|
||||||
|
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
|
<meta property="og:url" id="og-url" content="https://lukeattheroost.com/episode.html">
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" id="tw-title" content="Episode — Luke at the Roost">
|
||||||
|
<meta name="twitter:description" id="tw-description" content="Full transcript of this episode of Luke at the Roost.">
|
||||||
|
<meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<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="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">
|
||||||
|
|
||||||
|
<!-- Structured Data (dynamically updated by JS) -->
|
||||||
|
<script type="application/ld+json" id="episode-jsonld">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "PodcastEpisode",
|
||||||
|
"partOfSeries": {
|
||||||
|
"@type": "PodcastSeries",
|
||||||
|
"name": "Luke at the Roost",
|
||||||
|
"url": "https://lukeattheroost.com"
|
||||||
|
},
|
||||||
|
"name": "Episode — Luke at the Roost",
|
||||||
|
"url": "https://lukeattheroost.com/episode.html",
|
||||||
|
"description": "Full transcript of this episode of Luke at the Roost.",
|
||||||
|
"inLanguage": "en"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Nav -->
|
||||||
|
<nav class="page-nav">
|
||||||
|
<a href="/" class="nav-home">← Luke at the Roost</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Episode Header -->
|
||||||
|
<section class="ep-header" id="ep-header">
|
||||||
|
<div class="ep-header-inner">
|
||||||
|
<div class="ep-meta" id="ep-meta"></div>
|
||||||
|
<h1 class="ep-title" id="ep-title">Loading...</h1>
|
||||||
|
<p class="ep-desc" id="ep-desc"></p>
|
||||||
|
<div class="ep-actions">
|
||||||
|
<button class="ep-play-btn" id="ep-play-btn" style="display:none" aria-label="Play Episode">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
<span>Play Episode</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Transcript -->
|
||||||
|
<section class="transcript-section" id="transcript-section">
|
||||||
|
<h2>Full Transcript</h2>
|
||||||
|
<div class="transcript-body" id="transcript-body">
|
||||||
|
<div class="episodes-loading">Loading transcript...</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
||||||
|
<div class="sticky-player" id="sticky-player">
|
||||||
|
<div class="player-inner">
|
||||||
|
<button class="player-play-btn" id="player-play-btn" aria-label="Play/Pause">
|
||||||
|
<svg class="icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
<svg class="icon-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="player-info">
|
||||||
|
<div class="player-title" id="player-title">—</div>
|
||||||
|
<div class="player-progress-row">
|
||||||
|
<div class="player-progress" id="player-progress">
|
||||||
|
<div class="player-progress-fill" id="player-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio id="audio-element" preload="none"></audio>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const FEED_URL = '/feed';
|
||||||
|
const CDN_BASE = 'https://cdn.lukeattheroost.com';
|
||||||
|
|
||||||
|
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>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 681 B |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,35 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<filter id="glow">
|
||||||
|
<feGaussianBlur stdDeviation="2" result="blur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<!-- Dark background circle -->
|
||||||
|
<circle cx="32" cy="32" r="30" fill="#1a1209"/>
|
||||||
|
<!-- Neon rooster silhouette -->
|
||||||
|
<g filter="url(#glow)" fill="none" stroke="#e8791d" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<!-- Comb -->
|
||||||
|
<path d="M30 12c1-3 3-5 5-5s3 2 3 4c0-2 2-3 3-2s1 3 0 5"/>
|
||||||
|
<!-- Head -->
|
||||||
|
<path d="M30 12c-2 1-4 4-4 7 0 3 2 6 6 7"/>
|
||||||
|
<path d="M41 19c0-3-2-6-5-7"/>
|
||||||
|
<!-- Eye -->
|
||||||
|
<circle cx="33" cy="16" r="1.5" fill="#e8791d"/>
|
||||||
|
<!-- Beak -->
|
||||||
|
<path d="M26 17l-3 1 3 2"/>
|
||||||
|
<!-- Body -->
|
||||||
|
<path d="M32 26c-5 1-9 5-10 10l-1 6c0 1 1 2 2 2h3"/>
|
||||||
|
<path d="M32 26c5 1 9 5 10 10l1 6c0 1-1 2-2 2h-3"/>
|
||||||
|
<!-- Tail feathers -->
|
||||||
|
<path d="M42 30c3-4 6-8 7-14"/>
|
||||||
|
<path d="M43 32c4-3 8-5 10-10"/>
|
||||||
|
<path d="M44 35c4-1 7-2 9-5"/>
|
||||||
|
<!-- Legs -->
|
||||||
|
<path d="M27 44l-2 8M25 52l-3 1M25 52l2 1"/>
|
||||||
|
<path d="M37 44l2 8M39 52l-2 1M39 52l3 1"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,528 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<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="theme-color" content="#1a1209">
|
||||||
|
<link rel="canonical" href="https://lukeattheroost.com/how-it-works">
|
||||||
|
|
||||||
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
|
<meta property="og:title" content="How It Works — Luke at the Roost">
|
||||||
|
<meta property="og:description" content="The tech behind a one-of-a-kind AI radio show: real-time caller generation, multi-stem recording, automated post-production, and global CDN distribution — all custom-built.">
|
||||||
|
<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/how-it-works">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="How It Works — Luke at the Roost">
|
||||||
|
<meta name="twitter:description" content="The tech behind a one-of-a-kind AI radio show: real-time caller generation, multi-stem recording, automated post-production, and global CDN distribution.">
|
||||||
|
<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">
|
||||||
|
|
||||||
|
<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">
|
||||||
|
|
||||||
|
<!-- Structured Data -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "TechArticle",
|
||||||
|
"headline": "How Luke at the Roost Works",
|
||||||
|
"description": "How Luke at the Roost works: AI-generated callers with unique personalities, real phone calls, voice synthesis, multi-stem recording, and automated post-production.",
|
||||||
|
"url": "https://lukeattheroost.com/how-it-works",
|
||||||
|
"image": "https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3",
|
||||||
|
"author": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "Luke MacNeil"
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "MacNeil Media Group"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"@type": "PodcastSeries",
|
||||||
|
"name": "Luke at the Roost",
|
||||||
|
"url": "https://lukeattheroost.com"
|
||||||
|
},
|
||||||
|
"inLanguage": "en"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Nav -->
|
||||||
|
<nav class="page-nav">
|
||||||
|
<a href="/" class="nav-home">Luke at the Roost</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<section class="page-header">
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Overview -->
|
||||||
|
<section class="hiw-section">
|
||||||
|
<div class="hiw-card hiw-hero-card">
|
||||||
|
<div class="hiw-diagram">
|
||||||
|
<!-- Row 1: Inputs -->
|
||||||
|
<div class="diagram-label">Live Show</div>
|
||||||
|
<div class="diagram-row diagram-row-split">
|
||||||
|
<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="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Luke (Host)</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="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>
|
||||||
|
<span>AI Callers</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="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>Real Callers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-arrow">↓</div>
|
||||||
|
<!-- Row 2: Control Room -->
|
||||||
|
<div class="diagram-row">
|
||||||
|
<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="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Control Room</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-arrow">↓</div>
|
||||||
|
<!-- Row 3: Engine Layer -->
|
||||||
|
<div class="diagram-row diagram-row-split">
|
||||||
|
<div class="diagram-box">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>LLM Dialog</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="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Voice Synthesis</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Live Data</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="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Audio Router</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Row 4: Recording -->
|
||||||
|
<div class="diagram-row">
|
||||||
|
<div class="diagram-box">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Multi-Stem Recorder</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-arrow">↓</div>
|
||||||
|
<!-- Row 5: Post-Production -->
|
||||||
|
<div class="diagram-label">Post-Production</div>
|
||||||
|
<div class="diagram-row diagram-row-split">
|
||||||
|
<div class="diagram-box">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Compression & Ducking</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Loudness Normalization</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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Transcription</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-arrow">↓</div>
|
||||||
|
<!-- Row 6: Publishing -->
|
||||||
|
<div class="diagram-label">Publishing</div>
|
||||||
|
<div class="diagram-row diagram-row-split">
|
||||||
|
<div class="diagram-box">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Podcast Server</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>CDN Edge Network</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="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Website</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-arrow">↓</div>
|
||||||
|
<!-- Row 7: Distribution -->
|
||||||
|
<div class="diagram-label">Distribution</div>
|
||||||
|
<div class="diagram-row diagram-row-split">
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<span>Spotify</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<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 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Apple</span>
|
||||||
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<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="#fff"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>YouTube</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="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.248z"/><path d="M.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594"/><path d="M.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>RSS</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="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Analytics</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 -->
|
||||||
|
<section class="hiw-section">
|
||||||
|
<h2>From Live Show to Podcast</h2>
|
||||||
|
|
||||||
|
<div class="hiw-steps">
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">7</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<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>
|
||||||
|
<div class="hiw-detail-grid">
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Stems Captured</span>
|
||||||
|
<span class="hiw-detail-value">5 parallel</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Format</span>
|
||||||
|
<span class="hiw-detail-value">48kHz WAV</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Sync Method</span>
|
||||||
|
<span class="hiw-detail-value">Time-aligned</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Architecture</span>
|
||||||
|
<span class="hiw-detail-value">Lock-free I/O</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">8</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<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>
|
||||||
|
<div class="hiw-detail-grid">
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Pipeline Steps</span>
|
||||||
|
<span class="hiw-detail-value">15</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Loudness Target</span>
|
||||||
|
<span class="hiw-detail-value">-16 LUFS</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Loudness Range</span>
|
||||||
|
<span class="hiw-detail-value">~5.5 LU</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Output</span>
|
||||||
|
<span class="hiw-detail-value">Stereo MP3</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">9</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<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>
|
||||||
|
<div class="hiw-detail-grid">
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Transcription</span>
|
||||||
|
<span class="hiw-detail-value">Whisper AI</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Metadata</span>
|
||||||
|
<span class="hiw-detail-value">LLM-generated</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Chapters</span>
|
||||||
|
<span class="hiw-detail-value">Auto-detected</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Deploy Time</span>
|
||||||
|
<span class="hiw-detail-value">~2 min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">10</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<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>
|
||||||
|
<div class="hiw-detail-grid">
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Audio Delivery</span>
|
||||||
|
<span class="hiw-detail-value">Global CDN</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Website</span>
|
||||||
|
<span class="hiw-detail-value">Cloudflare Edge</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Platforms</span>
|
||||||
|
<span class="hiw-detail-value">5+ directories</span>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-detail">
|
||||||
|
<span class="hiw-detail-label">Feed Format</span>
|
||||||
|
<span class="hiw-detail-value">RSS + Podcast 2.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- What makes it different -->
|
||||||
|
<section class="hiw-section">
|
||||||
|
<h2>What Makes This Different</h2>
|
||||||
|
<div class="hiw-features">
|
||||||
|
<div class="hiw-feature">
|
||||||
|
<div class="hiw-feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Not Scripted</h3>
|
||||||
|
<p>Every conversation is improvised. Luke doesn't know what the caller is going to say. The AI doesn't follow a script. It's a real conversation between a human and an AI character who has a life, opinions, and something on their mind.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-feature">
|
||||||
|
<div class="hiw-feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Built From Scratch</h3>
|
||||||
|
<p>This isn't an app with a plugin. Every piece — the caller generator, the voice engine, the control room, the phone system, the post-production pipeline, the publishing automation — was built specifically for this show.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-feature">
|
||||||
|
<div class="hiw-feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Real Time</h3>
|
||||||
|
<p>Everything happens live. Caller generation, voice synthesis, news lookups, weather checks, phone routing — all in real time during the show. There's no post-production trickery on the caller side. What you hear is what happened.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-feature">
|
||||||
|
<div class="hiw-feature-icon">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-feature">
|
||||||
|
<div class="hiw-feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Broadcast-Grade Audio</h3>
|
||||||
|
<p>Every episode runs through a 15-step post-production pipeline: stem limiting, high-pass filtering, de-essing, breath reduction, spoken-word compression, telephone EQ, level matching, music ducking with ad muting, stereo imaging, bus compression, and EBU R128 loudness normalization.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hiw-feature">
|
||||||
|
<div class="hiw-feature-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3>Fully Automated Pipeline</h3>
|
||||||
|
<p>From recording to your podcast app, the entire pipeline is automated. Post-production kicks off when the show ends, then a publish script handles transcription, AI-generated metadata, chapter detection, CDN sync, and RSS distribution — all with a single command.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<section class="hiw-section hiw-cta">
|
||||||
|
<p>Want to hear it for yourself?</p>
|
||||||
|
<a href="/" class="hiw-cta-btn">Listen to Episodes</a>
|
||||||
|
<div class="hiw-cta-phone">
|
||||||
|
Or call in live: <strong>208-439-LUKE</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
@@ -0,0 +1,259 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<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="theme-color" content="#1a1209">
|
||||||
|
<link rel="canonical" href="https://lukeattheroost.com">
|
||||||
|
|
||||||
|
<!-- OG / Social -->
|
||||||
|
<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: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: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:type" content="website">
|
||||||
|
<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: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:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<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="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">
|
||||||
|
|
||||||
|
<!-- Structured Data -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "PodcastSeries",
|
||||||
|
"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.",
|
||||||
|
"url": "https://lukeattheroost.com",
|
||||||
|
"image": "https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3",
|
||||||
|
"author": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "Luke MacNeil"
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "MacNeil Media Group"
|
||||||
|
},
|
||||||
|
"genre": "Comedy",
|
||||||
|
"inLanguage": "en",
|
||||||
|
"webFeed": "https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml",
|
||||||
|
"sameAs": [
|
||||||
|
"https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF",
|
||||||
|
"https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848",
|
||||||
|
"https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-",
|
||||||
|
"https://discord.gg/5CnQZxDM"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Banner -->
|
||||||
|
<div class="banner">
|
||||||
|
<img src="images/banner.png" alt="Luke at the Roost — ON AIR" class="banner-img" width="1500" height="500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-inner">
|
||||||
|
<img
|
||||||
|
class="cover-art"
|
||||||
|
src="images/cover.png"
|
||||||
|
alt="Luke at the Roost cover art"
|
||||||
|
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="on-air-badge" id="on-air-badge">
|
||||||
|
<span class="on-air-dot"></span>
|
||||||
|
ON AIR
|
||||||
|
</div>
|
||||||
|
<div class="off-air-badge" id="off-air-badge">
|
||||||
|
OFF AIR
|
||||||
|
</div>
|
||||||
|
<span class="phone-label">Call in live</span>
|
||||||
|
<span class="phone-number">208-439-LUKE</span>
|
||||||
|
<span class="phone-digits">(208-439-5853)</span>
|
||||||
|
</div>
|
||||||
|
<div class="subscribe-row">
|
||||||
|
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="subscribe-btn btn-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>
|
||||||
|
Spotify
|
||||||
|
</a>
|
||||||
|
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="subscribe-btn btn-apple">
|
||||||
|
<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
|
||||||
|
</a>
|
||||||
|
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="subscribe-btn btn-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.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||||
|
YouTube
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
RSS
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Testimonials -->
|
||||||
|
<section class="testimonials-section">
|
||||||
|
<h2>What Callers Are Saying</h2>
|
||||||
|
<div class="testimonials-slider" id="testimonials-slider">
|
||||||
|
<div class="testimonials-track" id="testimonials-track">
|
||||||
|
<div class="testimonial-card"><div class="testimonial-inner">
|
||||||
|
<div class="testimonial-stars">★★★★★</div>
|
||||||
|
<p class="testimonial-text">"I called in at 2 AM about my neighbor's rooster situation and Luke talked me off the ledge. Literally saved my relationship with the entire block. My wife thinks I'm crazy for calling a radio show but hey, it worked."</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<span class="testimonial-name">Tony M.</span>
|
||||||
|
<span class="testimonial-location">Lordsburg, NM</span>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="testimonial-card"><div class="testimonial-inner">
|
||||||
|
<div class="testimonial-stars">★★★★★</div>
|
||||||
|
<p class="testimonial-text">"Called to talk about the Severance finale and ended up getting life advice I didn't know I needed. Luke somehow connected Lumon Industries to my actual job and I quit the next week. Best decision I ever made."</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<span class="testimonial-name">Carmen R.</span>
|
||||||
|
<span class="testimonial-location">Deming, NM</span>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="testimonial-card"><div class="testimonial-inner">
|
||||||
|
<div class="testimonial-stars">★★★★★</div>
|
||||||
|
<p class="testimonial-text">"I've been listening since episode one. Called in about my truck breaking down outside Animas and Luke spent twenty minutes just talking me through it. Turns out it was the alternator AND my attitude. He was right about both."</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<span class="testimonial-name">Dale W.</span>
|
||||||
|
<span class="testimonial-location">Animas, NM</span>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="testimonial-card"><div class="testimonial-inner">
|
||||||
|
<div class="testimonial-stars">★★★★☆</div>
|
||||||
|
<p class="testimonial-text">"I called in to ask about astrophotography tips and somehow ended up telling Luke about my divorce. He's got this way of getting you to open up. Still shooting the Milky Way every clear night though. Thanks Luke."</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<span class="testimonial-name">Jessie K.</span>
|
||||||
|
<span class="testimonial-location">Silver City, NM</span>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="testimonial-card"><div class="testimonial-inner">
|
||||||
|
<div class="testimonial-stars">★★★★★</div>
|
||||||
|
<p class="testimonial-text">"My buddy dared me to call in and I ended up having the most real conversation I've had in years. We talked about The Wire for like ten minutes and then he hit me with some truth about why I keep ghosting people. This show is something else."</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<span class="testimonial-name">Marcus T.</span>
|
||||||
|
<span class="testimonial-location">Las Cruces, NM</span>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="testimonial-card"><div class="testimonial-inner">
|
||||||
|
<div class="testimonial-stars">★★★★★</div>
|
||||||
|
<p class="testimonial-text">"I work night shifts at the mine and this show keeps me sane. Finally called in about a thing with my sister and Luke gave me advice that actually made sense. We're talking again for the first time in three years."</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<span class="testimonial-name">Ray D.</span>
|
||||||
|
<span class="testimonial-location">Tyrone, NM</span>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="testimonial-card"><div class="testimonial-inner">
|
||||||
|
<div class="testimonial-stars">★★★★★</div>
|
||||||
|
<p class="testimonial-text">"Called about my poker game falling apart because my best friend cheated. Luke compared it to a Breaking Bad episode and somehow made me see the whole situation differently. We play again every Thursday now."</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<span class="testimonial-name">Elena S.</span>
|
||||||
|
<span class="testimonial-location">Hachita, NM</span>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
<div class="testimonial-card"><div class="testimonial-inner">
|
||||||
|
<div class="testimonial-stars">★★★★☆</div>
|
||||||
|
<p class="testimonial-text">"I was just gonna ask about quantum entanglement because I read this article, but Luke turned it into a metaphor for my long distance relationship and honestly? He wasn't wrong. We're moving in together next month."</p>
|
||||||
|
<div class="testimonial-author">
|
||||||
|
<span class="testimonial-name">Priya N.</span>
|
||||||
|
<span class="testimonial-location">Tucson, AZ</span>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="testimonials-dots" id="testimonials-dots"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Episodes -->
|
||||||
|
<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">
|
||||||
|
<div class="footer-links">
|
||||||
|
<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 -->
|
||||||
|
<div class="sticky-player" id="sticky-player">
|
||||||
|
<div class="player-inner">
|
||||||
|
<button class="player-play-btn" id="player-play-btn" aria-label="Play/Pause">
|
||||||
|
<svg class="icon-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
<svg class="icon-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="player-info">
|
||||||
|
<div class="player-title" id="player-title">—</div>
|
||||||
|
<div class="player-progress-row">
|
||||||
|
<div class="player-progress" id="player-progress">
|
||||||
|
<div class="player-progress-fill" id="player-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio id="audio-element" preload="none"></audio>
|
||||||
|
|
||||||
|
<script src="js/app.js?v=2"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
const FEED_URL = '/feed';
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
let currentEpisodeCard = null;
|
||||||
|
|
||||||
|
// Format seconds to M:SS or H:MM:SS
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format duration from itunes:duration (could be seconds or HH:MM:SS)
|
||||||
|
function parseDuration(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
if (raw.includes(':')) {
|
||||||
|
const parts = raw.split(':').map(Number);
|
||||||
|
let totalSec = 0;
|
||||||
|
if (parts.length === 3) totalSec = parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
else if (parts.length === 2) totalSec = parts[0] * 60 + parts[1];
|
||||||
|
return `${Math.round(totalSec / 60)} min`;
|
||||||
|
}
|
||||||
|
const sec = parseInt(raw, 10);
|
||||||
|
if (isNaN(sec)) return '';
|
||||||
|
return `${Math.round(sec / 60)} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date nicely
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip HTML tags and truncate
|
||||||
|
function truncate(html, maxLen) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = html || '';
|
||||||
|
const text = div.textContent || '';
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
return text.slice(0, maxLen).trimEnd() + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG icons
|
||||||
|
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>';
|
||||||
|
|
||||||
|
// Fetch with timeout
|
||||||
|
function fetchWithTimeout(url, ms = 8000) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), ms);
|
||||||
|
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeout));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and parse RSS feed
|
||||||
|
async function fetchEpisodes() {
|
||||||
|
let xml;
|
||||||
|
const maxRetries = 2;
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetchWithTimeout(FEED_URL);
|
||||||
|
if (!res.ok) throw new Error('Fetch failed');
|
||||||
|
xml = await res.text();
|
||||||
|
if (xml.includes('<item>')) break;
|
||||||
|
throw new Error('Invalid response');
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === maxRetries) {
|
||||||
|
episodesList.innerHTML = '<div class="episodes-error">Unable to load episodes. <a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank">View RSS feed</a></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(xml, 'text/xml');
|
||||||
|
const items = doc.querySelectorAll('item');
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
episodesList.innerHTML = '<div class="episodes-error">No episodes found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodes = Array.from(items).map((item, i) => {
|
||||||
|
const title = item.querySelector('title')?.textContent || 'Untitled';
|
||||||
|
const description = item.querySelector('description')?.textContent || '';
|
||||||
|
const enclosure = item.querySelector('enclosure');
|
||||||
|
const audioUrl = enclosure?.getAttribute('url') || '';
|
||||||
|
const pubDate = item.querySelector('pubDate')?.textContent || '';
|
||||||
|
const duration = item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'duration')[0]?.textContent || '';
|
||||||
|
const episodeNum = item.getElementsByTagNameNS('http://www.itunes.com/dtds/podcast-1.0.dtd', 'episode')[0]?.textContent || '';
|
||||||
|
const link = item.querySelector('link')?.textContent || '';
|
||||||
|
|
||||||
|
return { title, description, audioUrl, pubDate, duration, episodeNum, link };
|
||||||
|
});
|
||||||
|
|
||||||
|
renderEpisodes(episodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEpisodes(episodes) {
|
||||||
|
episodesList.innerHTML = '';
|
||||||
|
|
||||||
|
episodes.forEach((ep) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'episode-card';
|
||||||
|
|
||||||
|
const epLabel = ep.episodeNum ? `Ep ${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(/\/$/, '') : '';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<button class="episode-play-btn" aria-label="Play ${ep.title}" data-url="${ep.audioUrl}" data-title="${ep.title.replace(/"/g, '"')}">
|
||||||
|
${playSVG}
|
||||||
|
</button>
|
||||||
|
<div class="episode-info">
|
||||||
|
<div class="episode-meta">${metaParts}</div>
|
||||||
|
<div class="episode-title">${ep.title}</div>
|
||||||
|
<div class="episode-desc">${truncate(ep.description, 150)}</div>
|
||||||
|
${epSlug ? `<a href="/episode.html?slug=${epSlug}" class="episode-transcript-link">Read Transcript</a>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const btn = card.querySelector('.episode-play-btn');
|
||||||
|
btn.addEventListener('click', () => playEpisode(ep.audioUrl, ep.title, card, btn));
|
||||||
|
|
||||||
|
episodesList.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playEpisode(url, title, card, btn) {
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
// If clicking the same episode that's playing, toggle play/pause
|
||||||
|
if (audio.src === url || audio.src === encodeURI(url)) {
|
||||||
|
if (audio.paused) {
|
||||||
|
audio.play();
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset previous card button icon
|
||||||
|
if (currentEpisodeCard) {
|
||||||
|
const prevBtn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||||
|
if (prevBtn) {
|
||||||
|
prevBtn.innerHTML = playSVG;
|
||||||
|
prevBtn.classList.remove('playing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEpisodeCard = card;
|
||||||
|
audio.src = url;
|
||||||
|
audio.play();
|
||||||
|
|
||||||
|
playerTitle.textContent = title;
|
||||||
|
stickyPlayer.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync UI with audio state
|
||||||
|
audio.addEventListener('play', () => {
|
||||||
|
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) {
|
||||||
|
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.innerHTML = playing ? pauseSVG : playSVG;
|
||||||
|
btn.classList.toggle('playing', playing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sticky player play/pause button
|
||||||
|
playerPlayBtn.addEventListener('click', () => {
|
||||||
|
if (audio.src) {
|
||||||
|
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
|
||||||
|
function initTestimonials() {
|
||||||
|
const track = document.getElementById('testimonials-track');
|
||||||
|
const dotsContainer = document.getElementById('testimonials-dots');
|
||||||
|
const cards = track.querySelectorAll('.testimonial-card');
|
||||||
|
if (!cards.length) return;
|
||||||
|
|
||||||
|
let currentIndex = 0;
|
||||||
|
let autoplayTimer = null;
|
||||||
|
const maxIndex = () => Math.max(0, cards.length - 1);
|
||||||
|
|
||||||
|
function buildDots() {
|
||||||
|
dotsContainer.innerHTML = '';
|
||||||
|
for (let i = 0; i < cards.length; i++) {
|
||||||
|
const dot = document.createElement('button');
|
||||||
|
dot.className = 'testimonial-dot' + (i === currentIndex ? ' active' : '');
|
||||||
|
dot.setAttribute('aria-label', `Testimonial ${i + 1}`);
|
||||||
|
dot.addEventListener('click', () => goTo(i));
|
||||||
|
dotsContainer.appendChild(dot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePosition() {
|
||||||
|
const cardWidth = cards[0].offsetWidth;
|
||||||
|
track.style.transform = `translateX(-${currentIndex * cardWidth}px)`;
|
||||||
|
dotsContainer.querySelectorAll('.testimonial-dot').forEach((d, i) => {
|
||||||
|
d.classList.toggle('active', i === currentIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function goTo(index) {
|
||||||
|
currentIndex = Math.max(0, Math.min(index, maxIndex()));
|
||||||
|
updatePosition();
|
||||||
|
resetAutoplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
goTo(currentIndex >= maxIndex() ? 0 : currentIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAutoplay() {
|
||||||
|
clearInterval(autoplayTimer);
|
||||||
|
autoplayTimer = setInterval(next, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch/swipe support
|
||||||
|
let touchStartX = 0;
|
||||||
|
let touchDelta = 0;
|
||||||
|
track.addEventListener('touchstart', (e) => {
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
touchDelta = 0;
|
||||||
|
clearInterval(autoplayTimer);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
track.addEventListener('touchmove', (e) => {
|
||||||
|
touchDelta = e.touches[0].clientX - touchStartX;
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
track.addEventListener('touchend', () => {
|
||||||
|
if (Math.abs(touchDelta) > 50) {
|
||||||
|
touchDelta < 0 ? goTo(currentIndex + 1) : goTo(currentIndex - 1);
|
||||||
|
}
|
||||||
|
resetAutoplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recalculate on resize
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (currentIndex > maxIndex()) currentIndex = maxIndex();
|
||||||
|
buildDots();
|
||||||
|
updatePosition();
|
||||||
|
});
|
||||||
|
|
||||||
|
buildDots();
|
||||||
|
updatePosition();
|
||||||
|
resetAutoplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// On-Air Status
|
||||||
|
function checkOnAir() {
|
||||||
|
fetch(`https://cdn.lukeattheroost.com/status.json?_=${Date.now()}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const badge = document.getElementById('on-air-badge');
|
||||||
|
const offBadge = document.getElementById('off-air-badge');
|
||||||
|
const phone = document.getElementById('phone-section');
|
||||||
|
const live = !!data.on_air;
|
||||||
|
if (badge) badge.classList.toggle('visible', live);
|
||||||
|
if (offBadge) offBadge.classList.toggle('hidden', live);
|
||||||
|
if (phone) phone.classList.toggle('live', live);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
fetchEpisodes();
|
||||||
|
initTestimonials();
|
||||||
|
checkOnAir();
|
||||||
|
setInterval(checkOnAir, 15000);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Luke at the Roost
|
||||||
|
|
||||||
|
> The ONLY radio show where Luke gives AI life advice.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Call In
|
||||||
|
- Phone: 208-439-LUKE (208-439-5853)
|
||||||
|
- Call during live shows for your chance to talk to Luke
|
||||||
|
|
||||||
|
## 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: https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml
|
||||||
|
|
||||||
|
## About
|
||||||
|
- Host: Luke MacNeil
|
||||||
|
- Genre: Comedy
|
||||||
|
- Format: Live call-in radio show
|
||||||
|
- Contact: luke@macneilmediagroup.com
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Privacy Policy — Luke at the Roost</title>
|
||||||
|
<meta name="description" content="Privacy policy for Luke at the Roost podcast and website.">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
|
<link rel="canonical" href="https://lukeattheroost.com/privacy">
|
||||||
|
|
||||||
|
<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=2">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Nav -->
|
||||||
|
<nav class="page-nav">
|
||||||
|
<a href="/" class="nav-home">Luke at the Roost</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<section class="page-header">
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p class="page-subtitle">Last updated: February 12, 2026</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="hiw-section" style="max-width: 740px; margin: 0 auto;">
|
||||||
|
<div style="line-height: 1.7; color: var(--text-secondary, #b8a88a);">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h2>Information We Collect</h2>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h3>Phone Callers</h3>
|
||||||
|
<p>If you call in to the show at 208-439-LUKE, your voice may be recorded and included in a published episode. By calling in, you consent to being recorded and broadcast. We do not collect or store your phone number beyond what is necessary for call routing.</p>
|
||||||
|
|
||||||
|
<h3>Social Media</h3>
|
||||||
|
<p>We maintain a presence on platforms including Facebook, YouTube, Spotify, and Discord. When you interact with us on these platforms, their respective privacy policies apply. We may use third-party tools to schedule and manage social media posts.</p>
|
||||||
|
|
||||||
|
<h2>Cookies</h2>
|
||||||
|
<p>Our website does not set any first-party cookies. Third-party services (such as embedded podcast players) may set their own cookies according to their policies.</p>
|
||||||
|
|
||||||
|
<h2>Third-Party Services</h2>
|
||||||
|
<p>We use the following third-party services:</p>
|
||||||
|
<ul style="margin: 0.5em 0 1em 1.5em;">
|
||||||
|
<li><strong>Cloudflare</strong> — CDN, DNS, and analytics</li>
|
||||||
|
<li><strong>BunnyCDN</strong> — Audio file delivery</li>
|
||||||
|
<li><strong>Spotify, Apple Podcasts, YouTube</strong> — Podcast distribution</li>
|
||||||
|
<li><strong>Discord</strong> — Community chat</li>
|
||||||
|
<li><strong>Facebook</strong> — Social media page</li>
|
||||||
|
</ul>
|
||||||
|
<p>Each service has its own privacy policy governing how they handle your data.</p>
|
||||||
|
|
||||||
|
<h2>Data Retention</h2>
|
||||||
|
<p>Aggregate analytics data is retained indefinitely. Server logs are retained for up to 90 days. Published episodes and transcripts are retained indefinitely as part of the public podcast archive.</p>
|
||||||
|
|
||||||
|
<h2>Children's Privacy</h2>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>MacNeil Media Group<br>
|
||||||
|
Email: <a href="mailto:luke@macneilmediagroup.com" style="color: var(--accent, #d4a44a);">luke@macneilmediagroup.com</a></p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://lukeattheroost.com/sitemap.xml
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com</loc>
|
||||||
|
<lastmod>2026-02-15</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/how-it-works</loc>
|
||||||
|
<lastmod>2026-02-15</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/stats</loc>
|
||||||
|
<lastmod>2026-02-15</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/privacy</loc>
|
||||||
|
<lastmod>2026-02-15</lastmod>
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.3</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-10-unexpected-windfalls-and-neighborhood-woes</loc>
|
||||||
|
<lastmod>2026-02-12</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-9-spilled-juice-and-ghostly-visions</loc>
|
||||||
|
<lastmod>2026-02-11</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-8-real-news-or-fake-news</loc>
|
||||||
|
<lastmod>2026-02-11</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-7-ai-takeover-and-honey-endurance</loc>
|
||||||
|
<lastmod>2026-02-10</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-6-late-night-woes-and-cosmic-contemplations</loc>
|
||||||
|
<lastmod>2026-02-09</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-5-cosmic-theories-and-calling-for-change</loc>
|
||||||
|
<lastmod>2026-02-08</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-4-navigating-life-s-challenges</loc>
|
||||||
|
<lastmod>2026-02-07</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-3-desire-burnout-and-friendship-woes</loc>
|
||||||
|
<lastmod>2026-02-06</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-2-late-night-confessions</loc>
|
||||||
|
<lastmod>2026-02-05</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=quantum-physics-pluto-relationship-blunders</loc>
|
||||||
|
<lastmod>2026-02-04</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-11-quantum-mechanics-and-the-fate-of-tv</loc>
|
||||||
|
<lastmod>2026-02-13</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-12-love-lies-and-loyalty</loc>
|
||||||
|
<lastmod>2026-02-14</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Podcast Stats & Downloads — Luke at the Roost</title>
|
||||||
|
<meta name="description" content="Podcast stats for Luke at the Roost — total downloads, unique listeners, Apple Podcasts reviews, YouTube views and subscribers, updated daily.">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
|
<link rel="canonical" href="https://lukeattheroost.com/stats">
|
||||||
|
|
||||||
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
|
<meta property="og:title" content="Podcast Stats & Downloads — Luke at the Roost">
|
||||||
|
<meta property="og:description" content="Podcast stats for Luke at the Roost — total downloads, unique listeners, Apple Podcasts reviews, YouTube views and subscribers, updated daily.">
|
||||||
|
<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/stats">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Podcast Stats & Downloads — Luke at the Roost">
|
||||||
|
<meta name="twitter:description" content="Podcast stats for Luke at the Roost — total downloads, unique listeners, Apple Podcasts reviews, YouTube views and subscribers, updated daily.">
|
||||||
|
<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">
|
||||||
|
|
||||||
|
<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">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Nav -->
|
||||||
|
<nav class="page-nav">
|
||||||
|
<a href="/" class="nav-home">Luke at the Roost</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<section class="page-header">
|
||||||
|
<h1>Stats</h1>
|
||||||
|
<p class="page-subtitle">Downloads, reviews, and audience numbers across all platforms.</p>
|
||||||
|
<p class="stats-updated" id="stats-updated"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats Content -->
|
||||||
|
<div class="stats-container" id="stats-container">
|
||||||
|
<div class="stats-loading" id="stats-loading">Loading stats...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<div class="stats-container">
|
||||||
|
<p>This page requires JavaScript to display live stats. Luke at the Roost is available 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>, <a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-">YouTube</a>, and all major podcast apps.</p>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(async function() {
|
||||||
|
const container = document.getElementById('stats-container');
|
||||||
|
const loading = document.getElementById('stats-loading');
|
||||||
|
const updatedEl = document.getElementById('stats-updated');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('https://cdn.lukeattheroost.com/stats.json');
|
||||||
|
if (!resp.ok) throw new Error('Failed to load stats');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.updated_at) {
|
||||||
|
const d = new Date(data.updated_at);
|
||||||
|
updatedEl.textContent = 'Last updated ' + d.toLocaleDateString('en-US', {
|
||||||
|
month: 'long', day: 'numeric', year: 'numeric',
|
||||||
|
hour: 'numeric', minute: '2-digit', timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Downloads (Castopod)
|
||||||
|
if (data.castopod) {
|
||||||
|
const c = data.castopod;
|
||||||
|
html += '<section class="stats-section">';
|
||||||
|
html += '<h2>Downloads</h2>';
|
||||||
|
html += '<div class="stats-summary">';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (c.total_downloads || 0).toLocaleString() + '</span><span class="stat-label">Total Downloads</span></div>';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (c.unique_listeners || 0).toLocaleString() + '</span><span class="stat-label">Unique Listeners</span></div>';
|
||||||
|
html += '</div>';
|
||||||
|
if (c.episodes && c.episodes.length) {
|
||||||
|
html += '<div class="stats-list">';
|
||||||
|
c.episodes.forEach(function(ep) {
|
||||||
|
html += '<div class="stats-list-item">';
|
||||||
|
html += '<span class="stats-list-title">' + escapeHtml(ep.title) + '</span>';
|
||||||
|
html += '<span class="stats-list-meta">' + (ep.downloads || 0).toLocaleString() + ' downloads · ' + escapeHtml(ep.date) + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '</section>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Reviews
|
||||||
|
if (data.apple) {
|
||||||
|
const a = data.apple;
|
||||||
|
html += '<section class="stats-section">';
|
||||||
|
html += '<h2>Apple Podcasts</h2>';
|
||||||
|
if (a.review_count > 0) {
|
||||||
|
html += '<div class="stats-summary">';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (a.avg_rating || 0) + '/5</span><span class="stat-label">Average Rating</span></div>';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + a.review_count + '</span><span class="stat-label">Reviews</span></div>';
|
||||||
|
html += '</div>';
|
||||||
|
if (a.reviews && a.reviews.length) {
|
||||||
|
html += '<div class="stats-reviews">';
|
||||||
|
a.reviews.forEach(function(r) {
|
||||||
|
const stars = '\u2605'.repeat(r.rating) + '\u2606'.repeat(5 - r.rating);
|
||||||
|
html += '<div class="review-card">';
|
||||||
|
html += '<div class="review-stars">' + stars + '</div>';
|
||||||
|
html += '<div class="review-title">' + escapeHtml(r.title) + '</div>';
|
||||||
|
if (r.content && r.content !== r.title) {
|
||||||
|
html += '<div class="review-content">' + escapeHtml(r.content) + '</div>';
|
||||||
|
}
|
||||||
|
html += '<div class="review-meta">' + escapeHtml(r.author) + ' · ' + escapeHtml(r.date) + ' · ' + escapeHtml(r.storefront) + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html += '<p class="stats-empty">No reviews yet</p>';
|
||||||
|
}
|
||||||
|
html += '</section>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spotify
|
||||||
|
if (data.spotify) {
|
||||||
|
const s = data.spotify;
|
||||||
|
html += '<section class="stats-section">';
|
||||||
|
html += '<h2>Spotify</h2>';
|
||||||
|
html += '<div class="stats-summary">';
|
||||||
|
if (s.rating) {
|
||||||
|
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>';
|
||||||
|
if (s.url) {
|
||||||
|
html += '<p class="stats-link"><a href="' + escapeHtml(s.url) + '" target="_blank" rel="noopener">Listen on Spotify</a></p>';
|
||||||
|
}
|
||||||
|
html += '</section>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube
|
||||||
|
if (data.youtube) {
|
||||||
|
const y = data.youtube;
|
||||||
|
html += '<section class="stats-section">';
|
||||||
|
html += '<h2>YouTube</h2>';
|
||||||
|
html += '<div class="stats-summary">';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (y.total_views || 0).toLocaleString() + '</span><span class="stat-label">Views</span></div>';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (y.total_likes || 0).toLocaleString() + '</span><span class="stat-label">Likes</span></div>';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (y.total_comments || 0).toLocaleString() + '</span><span class="stat-label">Comments</span></div>';
|
||||||
|
if (y.subscribers != null) {
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + y.subscribers.toLocaleString() + '</span><span class="stat-label">Subscribers</span></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
if (y.videos && y.videos.length) {
|
||||||
|
html += '<div class="stats-list">';
|
||||||
|
y.videos.forEach(function(v) {
|
||||||
|
html += '<div class="stats-list-item">';
|
||||||
|
html += '<span class="stats-list-title">' + escapeHtml(v.title) + '</span>';
|
||||||
|
html += '<span class="stats-list-meta">' + (v.views || 0).toLocaleString() + ' views · ' + (v.likes || 0).toLocaleString() + ' likes · ' + escapeHtml(v.date) + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '</section>';
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.style.display = 'none';
|
||||||
|
container.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
loading.textContent = 'Unable to load stats. Try again later.';
|
||||||
|
loading.className = 'stats-error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
LUKE: Alright, welcome back to Luke at the Roost. This is the Colin radio show where you call in and tell me what's on your mind. My name is Luke, I'm your host. If you'd like to call in, our number is 208-439-5853. That's 208-439-Luke. Let's get the show started and our first caller today is going to be Slim. Slim, welcome to the show. What would you like to talk about today?
|
||||||
|
|
||||||
|
SLIM: Hey Luke, yeah thanks for taking my call. So look, I won 50 grand on a scratch ticket on Monday and I haven't told a single person yet. Not my wife, not the church, nobody, and it's eating me alive because I'm a youth pastor, right? Right?
|
||||||
|
|
||||||
|
LUKE: Wow, congratulations on your 50 grand win, what are you going to do with the money?
|
||||||
|
|
||||||
|
SLIM: Well, that's the whole thing, Luke. I don't know. I mean, part of me wants to just pay off the truck, maybe fix the AC at the church because it's been broken since September and the board keeps saying there's no money.
|
||||||
|
|
||||||
|
LUKE: Well, here's a question for you. If you're a pastor and you work at a church and you win the lottery, do you have to pay tax on that lottery winning?
|
||||||
|
|
||||||
|
SLIM: Oh, yeah, absolutely. The state already took their cut when I claimed it, walked out with about 37 after taxes.
|
||||||
|
|
||||||
|
LUKE: Oh, you've already got the money and you haven't spent any of it yet?
|
||||||
|
|
||||||
|
SLIM: Nope, it's just sitting in the bank like a damn time bomb. I keep thinking about how my dad used to say, money's like a deer. You spook it, it's gone. And I'm standing there with a rifle full of buckshot, trying not to move.
|
||||||
|
|
||||||
|
LUKE: Wow, that's one of the most confusing analogies I've ever heard in my life. But congratulations to you for your win. I can't imagine it's going to cost you 37 grand to fix the AC in your church.
|
||||||
|
|
||||||
|
SLIM: No, you're right. Probably three, four thousand tops, but see, that's where it gets tricky. The second people find out I have this money, it's going to be slim. Can you help with the new himmels or slim? My sister's kid needs braces. I've been doing this job for 12 years, Luke.
|
||||||
|
|
||||||
|
LUKE: And what is your lord and savior say to do with the money?
|
||||||
|
|
||||||
|
SLIM: Man, that's the question I've been wrestling with all week. I keep going back to the parable of the talents. You know, the guy who buried his money in the ground got reamed for it. But then there's also, don't let your left hand know what your right hand is doing when it comes to giving. I've been praying on it every night this week, honestly.
|
||||||
|
|
||||||
|
LUKE: Well, I think you're probably going to come to the right, uh, right decision and do the right thing with the money. Actually, you know what, I don't know if there is a right thing to do with the money. It wasn't there before. You wanted in the lottery. Good for you. Do you play the lottery a lot? How much are you, were you down to win this 50 grand?
|
||||||
|
|
||||||
|
SLIM: First ticket I ever bought actually. Yeah, sure. I was at the circle K getting gas and just, I don't know, impulse five bucks. My wife doesn't even know. She thinks gambling's a sin. And honestly, I kind of agreed with her until I scratched that thing off in the parking lot and nearly dropped my coffee.
|
||||||
|
|
||||||
|
LUKE: Well, you know, what the man with no sin throws stones first or whatever. Uh, good for you. Good for your congregation. I hope that the money goes to good use. And, um, and yeah, I don't think you have to feel bad about winning. Just do something good with the money and it's all good.
|
||||||
|
|
||||||
|
SLIM: Yeah, you're right. I appreciate that, Luke. I think I've been making this harder than it needs to be.
|
||||||
|
|
||||||
|
LUKE: For sure. And speaking of money, I got a question for you. How about you tell us about something that you bought once that you regret? Uh, something you spent money on and wished later that you hadn't.
|
||||||
|
|
||||||
|
SLIM: Oh, man. That's easy. About eight years ago, I bought this used jet ski off some guy in Lord'sburg. Thought I'd take it out to Elephant Butte. You know, make a whole thing of it with the youth group.
|
||||||
|
|
||||||
|
LUKE: You bought a, uh, Lord'sburg jet ski. How did that work out?
|
||||||
|
|
||||||
|
SLIM: No, no, Lord'sburg. It's a town about an hour west of here. Oh, yeah. But yeah, the jet ski paid $2,200 bucks for it. Drove it up to the lake exactly one time. Thing broke down in the middle of the water. Had to get towed back to shore by some guy on a pontoon boat.
|
||||||
|
|
||||||
|
LUKE: Hey, well, now I guess the good news is you get 37 grand.
|
||||||
|
|
||||||
|
SLIM: You know, the thing's been sitting under a tarp behind my garage for seven years. I think my wife would actually divorce me if I put another dime into it. She brings it up every time I want to buy anything. Remember the jet ski slim? It's like my personal scarlet letter back there.
|
||||||
|
|
||||||
|
LUKE: All right. Thanks for the call slim and, uh, congratulations on your big lottery win. Uh, just don't take it back to the casino and you should be all right. Next up on the line, we have, uh, Sandy. Sandy, welcome to the show. What's what's on your mind today, Sandy?
|
||||||
|
|
||||||
|
SANDY: Hey, Luke, thanks for taking my call. So I got into it with my neighbor tonight, like physically got into it. And I'm sitting here trying to figure out if I need to call the cops or if that's just going to make everything worse. We've been going back and forth for months now, but this time she actually shoved me in my own driveway.
|
||||||
|
|
||||||
|
LUKE: shoved you how just to like a little pusher where you actually injured in some way, where you did you fear for your life in this altercation?
|
||||||
|
|
||||||
|
SANDY: No, I mean, I didn't fall or anything, but it wasn't like a little tap either. She put both hands on my shoulders and pushed me back hard enough that I stumbled. The thing is I'm honestly more pissed off than scared, you know? But that's what's got me worried because I shoved her right back and now I'm thinking if I call the police, she's going to say I started it. This whole thing kicked off because her dog keeps shitting in my yard and she refuses to pick it up.
|
||||||
|
|
||||||
|
LUKE: Okay, so the dog shits in your yard, she's not picking it up and, uh, and how does that turn into an altercation? You obviously confronted her about the dog shitting situation.
|
||||||
|
|
||||||
|
SANDY: Yeah, so tonight I'm coming home from a birth. I was up for like 16 hours and I step right in a fresh pile getting out of my car. I lost it, walked straight over to her place and knocked on the door with the bottom of my shoe still covered in it.
|
||||||
|
|
||||||
|
LUKE: I should have kicked her in the face.
|
||||||
|
|
||||||
|
SANDY: Ha, I mean, I was tempted, but no, I just told her this is the third time this week and she needs to keep her damn dog on her own property.
|
||||||
|
|
||||||
|
LUKE: Well, yeah, that's annoying, it's never fun to step in and pile a dog poo. But, uh, dogs do roam around and it is, uh, the outdoors and I understand that people own their yards and stuff and it's, that's not fun, but the same time it's kind of hard to control wild animals from doing wild animal things.
|
||||||
|
|
||||||
|
SANDY: See, that's the thing though. This isn't a wild animal. It's her dog. She lets it out in the morning without a leash and it comes straight to my yard every single time.
|
||||||
|
|
||||||
|
LUKE: Would it make any difference if it was a wild dog?
|
||||||
|
|
||||||
|
SANDY: I mean, yeah, because then I couldn't go knock on someone's door about it, but this is her dog. She's responsible for it and honestly, if she just apologized once or picked it up, we wouldn't even be here. Instead, she tells me I'm being dramatic and that dogs go where they go. That's when my driveway and she followed me.
|
||||||
|
|
||||||
|
LUKE: Okay, well, um, that's, that's no fun. It's never good to have a shitty situation with your neighbor. What are you going to do about it?
|
||||||
|
|
||||||
|
SANDY: That's why I'm calling you, Luke. I don't know.
|
||||||
|
|
||||||
|
LUKE: Well, uh, you could put up a fence, you could move, you could, uh, complain a lot at the town meeting. I don't, you can't really physically assault the woman. You can't call the police on her. I guess you could call animal control if you actually catch the dog in your yard. Are you positive that it's her dog? Have you actually caught the dog in the act?
|
||||||
|
|
||||||
|
SANDY: Yeah, I've seen it. Walked outside two mornings ago and watched it squat right by my mailbox while she's standing on her porch drinking coffee. And I did call animal control last month.
|
||||||
|
|
||||||
|
LUKE: All right. Thanks for the call, Sandy. Good luck with your, uh, your dookie shoes. And next on the radio show, we've got Keith. Keith, welcome to the show. You're calling back. What's up today, sir?
|
||||||
|
|
||||||
|
KEITH: Hey, Luke. Yeah, I'm back. So I got an update on that video situation and I'm not going to lie. It got weird real weird. I ended up taking your advice kind of and I talked to her last week. Turns out she's been coming to the cemetery because her apartment is too quiet. And she likes hearing the highway noise from where I work.
|
||||||
|
|
||||||
|
LUKE: See, I told you it wasn't real weird. That's not weird. What's weird about that?
|
||||||
|
|
||||||
|
KEITH: Well, okay, that pot's not weird. You're right. But here's the thing. After we talked that first time, she started showing up on my shift specifically. Like she knows my schedule now and two nights ago, she brought me coffee. Just walked right up to the shed with two cups from that circle. K on Hudson and sat down like we were having a planned meeting.
|
||||||
|
|
||||||
|
LUKE: Okay, that still doesn't sound very weird to me, sir. Let's get to the weird part.
|
||||||
|
|
||||||
|
KEITH: All right. So last night, she asked if I wanted to come over to her place after my shift ends. Just straight up asked me. And I'm sitting there thinking, this woman is maybe 60, 65. And I'm married to Teresa. And I don't even know this lady's actual name. I've just been calling her this whole time.
|
||||||
|
|
||||||
|
LUKE: Okay, so you got to let her down a little bit easy. Sorry, I'm married. It's not that weird that a lonely widow is looking for some companionship at the cemetery. I mean, maybe that's a little weird, but it's not that weird.
|
||||||
|
|
||||||
|
KEITH: Yeah, I mean, you're right. It's not that weird when you say it like that. But here's where I messed up. I didn't say I was married. I panicked and told her I had to go check on something by the North fence. And I just walked away like a coward again.
|
||||||
|
|
||||||
|
LUKE: Okay, well, why is that? Is it because you actually want something to go on with this widow? Is that what's the eating you up?
|
||||||
|
|
||||||
|
KEITH: No, man, that's not look. I don't want anything with her. But things with Teresa have been so damn cold lately. We barely talk anymore except about bills or whose turn it is to take the trash out. And here's this woman who's actually interested in talking to me, bringing me coffee, asking about my day.
|
||||||
|
|
||||||
|
LUKE: Yeah, I understand that. It's nice to feel wanted and seen and heard and all that. And it's okay for you to have other friends. I mean, you can befriend this woman without doing anything shady at her house behind your wife's back. There's nothing wrong with that.
|
||||||
|
|
||||||
|
KEITH: You're right. I know you're right. But I'm not going to lie. Part of me keeps thinking about what it would be like to just say yes. Not even for anything physical. Just to sit in someone's living room and have a conversation where the other person actually wants to hear what I have to say. Teresa used to be like that.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, it sounds like you've got a couple things going on and I don't think it's that weird to go over somebody's house and have a cup of coffee and talk if you're seriously not into anything romantic or pursuing that relationship behind your wife's back. You might want to talk to Teresa about that before you start going over a strange woman's house though.
|
||||||
|
|
||||||
|
KEITH: Yeah, that's the problem though. How do I even start that conversation? Hey, Teresa, there's this widow at the cemetery who wants to hang out. Is that cool? Yeah. She's already pissed at me half the time about stuff I don't even understand. Last week, she got mad because I didn't notice she rearranged the living room. I walked right past the couch in a different spot three times before she pointed it out.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, I mean, that sounds like marriage. So, I mean, you're in it. But I think that's exactly the way that you deal with it. Hey, I met a new friend. I'd like to hang out after work one day, have a cup of coffee and talk about stuff. It's not a romantic thing. I'm not interested. She's 65 years old and I'm married to you. Teresa, the love of my life who I would never have eyes for anyone else. And just make sure she knows that and she's got a problem with it then deal with that problem.
|
||||||
|
|
||||||
|
KEITH: I hear you, but man, I don't know if I can say love of my life right now and have it sound real. She'd see right through that.
|
||||||
|
|
||||||
|
LUKE: All right, well, if that's the truth then you and your wife need to sit down and have some hard conversations and figure out if you want to continue on living the way you're living because that's that's no good and you might be better off with this widow or, you know, somebody else.
|
||||||
|
|
||||||
|
KEITH: Yeah, I've been sitting there thinking about that too. Maybe that's why this whole thing is eating at me so much.
|
||||||
|
|
||||||
|
LUKE: Yeah, it could be. I mean, relationships are tough and if things have been downhill for a while and you don't see them moving in a positive direction, it's time to have some hard conversations.
|
||||||
|
|
||||||
|
KEITH: The thing is, we used to be good, really good. I don't know when it changed exactly. Somewhere between the second mortgage and her mom getting sick and me taking this graveyard shift, no pun intended. We just sort of stop being on the same team.
|
||||||
|
|
||||||
|
LUKE: I think that happens. I think there are ways that you can get back on the same team if you both want to, but you might also find that neither one of you even, you know, likes each other anymore and it's time to split up and that's okay too. I'm not going to lie.
|
||||||
|
|
||||||
|
LUKE: Let's see. What's something you believed in 10 years ago that you don't believe anymore?
|
||||||
|
|
||||||
|
EARL: Oh man, that's a hell of a shift. 10 years ago I thought the tea party was going to actually change things in Washington.
|
||||||
|
|
||||||
|
LUKE: Well, things did change in Washington.
|
||||||
|
|
||||||
|
EARL: Yeah, but not the way we thought. I mean, I was all in on that stuff. Smaller government, fiscal responsibility, the whole deal. Now I look at it, and I'm sitting there thinking none of these people actually meant any of it. They just wanted their turn at the trough.
|
||||||
|
|
||||||
|
LUKE: Yeah, man, you're living you learn. That seems to be the way. No matter who is in the office. They do not give a fuck about you. All right, thanks for the call. Talk to your wife. Don't be a pussy. Next up on the line, we have Laurie. Laurie, welcome to the show. How can we help you today?
|
||||||
|
|
||||||
|
LAURIE: Hey, Luke, thanks for taking my call. So I've been working from home doing data entry for like three years now. And honestly, it can get pretty isolating. But I'm also really into open source technology. And I spend a lot of my free time in these online communities, you know, contributing to projects, helping troubleshoot stuff.
|
||||||
|
|
||||||
|
LUKE: I do know. I do a lot of that myself. So what are you working on?
|
||||||
|
|
||||||
|
LAURIE: Oh, nice. Right now I'm actually contributing to this documentation project for a Linux distribution. Nothing glamorous, but it's stuff people actually use. And I've been learning rust on the side messing around with some smaller tools. But here's why I called. I was watching the Lost Finale the other day, right?
|
||||||
|
|
||||||
|
LUKE: Oh, yeah, I just watched the Lost Finale a couple weeks ago or maybe a couple months ago now. But yeah, what about it?
|
||||||
|
|
||||||
|
LAURIE: Okay, so it really got me thinking about community and how people come together around something bigger than themselves. And with Valentine's Day coming up, I'm sitting here alone most days, just me and my computer screen. And I realized, these open source communities, they're the closest thing I have to that feeling of belonging, you know? Like actually being part of something that matters.
|
||||||
|
|
||||||
|
LUKE: Yes, I do know I totally understand that. And when I was a kid, I used to hang out in IRC in the on the free node network. And we had a channel called Linux people. And we did tutorials and wrote articles and helped people that were trying to learn Linux. They would pop into IRC and ask their questions. And we all tried our best to help them out. And I still talk to some of those people today. That's been over 20 years. People that I've never met that I met in those communities. So I don't think that's a bad thing at all.
|
||||||
|
|
||||||
|
LAURIE: Yeah, exactly. That's what I'm talking about. And honestly, Luke, I think people don't get how real those connections are. Like my family thinks I'm just sitting here alone all the time, but I'm collaborating with people in Germany and Brazil and wherever working on actual things that help people. But I guess what's been eating at me is is that enough?
|
||||||
|
|
||||||
|
LUKE: Do you feel like it's enough? I mean, I usually do, sometimes I don't, but most of the time I do feel like it's more than enough. That's a very personal question. How do you feel about it? It's obviously bothering you.
|
||||||
|
|
||||||
|
LAURIE: I mean, most of the time, yeah, it feels good. But then like Thursday night rolls around and Valentine's Day is coming up. And I'm 29. And I look around my house and it's just quiet.
|
||||||
|
|
||||||
|
LUKE: Quiet's very nice though. And 29's not all that old. You get plenty of time to do whatever you like. So I applaud you for spending your time doing productive stuff with the type of people that make you better at what you do. I think that's a good way to deal with your loneliness and to find some community and camaraderie out in the world.
|
||||||
|
|
||||||
|
LAURIE: Thanks, I appreciate that. I guess part of me worries though. Like, am I using these communities as a substitute for something else? Or is this just what connection looks like now?
|
||||||
|
|
||||||
|
LUKE: Yeah, there's probably some of both of that. Now connection can be, it can look like a lot of different things. You don't have to be in bed with somebody to have a connection with them. You could, you know, have a very good working relationship and friendship with somebody over the internet in Brazil. That's not all that strange or weird, especially today. I mean, 20 years ago when I was doing it and I was 12 years old, that was a little bit odd. But I don't think it's very strange now.
|
||||||
|
|
||||||
|
LAURIE: You're right. And honestly, I heard Sandy call earlier tonight and I felt like she was holding something back about that whole neighbor situation.
|
||||||
|
|
||||||
|
LUKE: Yeah, you know what, I think she was too. I certainly got that impression, but she wasn't going to spill it. So we're just going to have to go with her go with what she wanted to divulge. Anyway, Laurie, thanks for the call. Keep it up with your documentation project. And if you want to find somebody to hang out with on Valentine's Day, I'm sure you can do that. You sound like a very nice lady. You enjoy the remainder of your night, all right? Hey, Gus, Gus, welcome to the show. What's happening?
|
||||||
|
|
||||||
|
GUS: Yeah. Hey, Luke, so my ex showed up at the pawn shop today with flowers like a whole bouquet and my girlfriend was there picking me up for lunch. She saw the whole thing through the window before she even came in. Now she won't talk to me and I don't know what the hell I'm supposed to say to fix this.
|
||||||
|
|
||||||
|
LUKE: Your ex brought you flowers on Valentine's Day at your work?
|
||||||
|
|
||||||
|
GUS: Well, not quite Valentine's Day yet. It's Thursday, so we got a few days. But yeah, just showed up out of nowhere. I haven't talked to Melissa in like eight months. And she walks in with these roses talking about how she's been thinking about things. My girlfriend Sarah sees her through the window. And by the time she comes inside, Melissa's already gone, but the flowers are sitting right there on the counter. Sarah just looked at me, turned around and left.
|
||||||
|
|
||||||
|
LUKE: Well, which one of them would you rather be with? Melissa or Sarah?
|
||||||
|
|
||||||
|
GUS: Sarah? I mean, it's Sarah, no question. Melissa and I were done for good reasons. She couldn't handle that I work weird hours. Always wanted me to be someone I'm not.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, then problem solved. Just tell Sarah, hey, I didn't ask her to bring me these flowers. There's nothing going on here. I'm not interested in her anymore. And it's a free country. You know when somebody wants to bring your flowers, then all right, you wasted your 24 bucks.
|
||||||
|
|
||||||
|
GUS: Yeah, I tried that. I called her like three times. Center text saying exactly that. I didn't ask for this. I don't want Melissa back. The whole thing.
|
||||||
|
|
||||||
|
LUKE: Here's what you do. I got it figured out. So you take the flowers that Melissa gave you and you bring them home. And on Valentine's Day, you give those same flowers to Sarah and say these are for you.
|
||||||
|
|
||||||
|
GUS: Oh, man, that's, I mean, Sarah's not stupid. She saw those exact flowers. They were these yellow roses with some kind of purple things mixed in. Real specific looking.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, I think she'll appreciate your frugality. There's no use to wasting the money on new flowers. If you already got something, you're not going to use. You might as well get some use out of them. That's what I say.
|
||||||
|
|
||||||
|
GUS: I don't know, Luke.
|
||||||
|
|
||||||
|
LUKE: I feel like showing up with the same flowers my ex brought me is going to make things about 10 times worse. Sarah's already pissed enough. Last thing I need is her thinking I'm too cheap or too clueless to get her her own flowers.
|
||||||
|
|
||||||
|
GUS: Well, I mean, that's what I would do. I would, I would take the flowers that were given to me that I don't want and give them back to her and be like, here, here you go. I was thinking of you.
|
||||||
|
|
||||||
|
LUKE: You know what? Maybe that works for you, but I think Sarah would throw them at my head. She's not the type to find that charming. She'd see it as me being either lazy or rubbing salt in the wound. I'm already in the doghouse here. I need to figure out how to actually fix this, not make it worse with recycled roses.
|
||||||
|
|
||||||
|
GUS: Well, you didn't do anything wrong. So you really don't have anything to fix. If, uh, if there's jealousy going on there, then that's, that's really on her. You can just say, hey, I let her know. I did not prompt this in any way. There's nothing going on behind your back. I can't control what she does. She came and dropped off flowers and you saw it and that's unfortunate, but there's nothing going on there and you're the one that I want to be with and commit to her in that way. And if she's got a problem with it, then, uh, then you got to, then you got to deal with that and then you might be better off going back to Melissa.
|
||||||
|
|
||||||
|
LUKE: I hear you, but it's not just jealousy. Sarah watched my axe walk into my workplace flowers and a whole speech about how she misses me and wants to try again. That's not nothing.
|
||||||
|
|
||||||
|
GUS: It is nothing. It's nothing that you did. I mean, you can control that.
|
||||||
|
|
||||||
|
LUKE: So yeah, but I didn't shut it down fast enough either. I just kind of stood there like an idiot while Melissa went on and Sarah saw that. I should have cut her off right away, told her to leave.
|
||||||
|
|
||||||
|
GUS: Well, it sounds like you're a pussy either way. You can either stand up to your current girlfriend or you can stand up to your former girlfriend, but one way or the other, you're going to have to stand up.
|
||||||
|
|
||||||
|
LUKE: You're right. I've been trying to keep everybody happy, and I'm just making it worse. I froze up with Melissa because I didn't want to be a jerk, and now Sarah thinks I was actually considering it or something.
|
||||||
|
|
||||||
|
GUS: Just sit her down and have that conversation, and if that's a problem, then it probably shows how long you've been with this chick, Sarah. How long?
|
||||||
|
|
||||||
|
LUKE: About eight months, we moved in together maybe two months ago, which I know was probably too fast, but rents expensive, and we were spending every night together anyway.
|
||||||
|
|
||||||
|
GUS: Okay, well, you don't know this woman at all. So if something this small is going to cause strife in your life, maybe it's time to find another roommate.
|
||||||
|
|
||||||
|
LUKE: That's the thing though. It's not really that small when you think about it. My ex showing up with flowers isn't like some random thing, and Sarah and I were solid before this. She's been good to me, better than Melissa ever was.
|
||||||
|
|
||||||
|
GUS: All right, well then talk to her and get it worked out, and if she's a reasonable person, she'll come around. If she's not a reasonable person, then you're an idiot for dealing with that, but that's your choice, and that's my advice, and I'm sticking to it.
|
||||||
|
|
||||||
|
LUKE: And now it's time to go to a word 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 journaling 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 brews 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. All right. Okay, let's see, we've got Darlene on the line. Hey, Darlene, what's the weirdest thing you've ever found in your car?
|
||||||
|
|
||||||
|
DARLENE: Oh man, Luke, that's okay. So about three years ago, I'm cleaning out the bear's truck, right? And I find this whole rotisserie chicken, like the entire thing from the grocery store, still in the container, wedged under the passenger seat. It had been there for God knows how long, completely mummified.
|
||||||
|
|
||||||
|
LUKE: What's the bear's truck?
|
||||||
|
|
||||||
|
DARLENE: Oh, sorry, the bear, that's David, my husband. We've called him that since high school. He's got this big beard and he's just, you know, bear shaped. His truck, the one he drives to the plant every day.
|
||||||
|
|
||||||
|
LUKE: All right, yeah, that's a little bit weird. You found a whole rotisserie chicken under the seat of your husband's truck. That was there for months and months.
|
||||||
|
|
||||||
|
DARLENE: Yeah, and the thing is, he swore up and down, he had no idea how it got there, like who buys a whole rotisserie chicken and just forgets about it. But that's David. He'll stop at the store on the way home, get distracted and half the groceries end up living in that truck for weeks. But listen, that's not why I called.
|
||||||
|
|
||||||
|
LUKE: Well, before you get to that, did you eat it?
|
||||||
|
|
||||||
|
DARLENE: What? No.
|
||||||
|
|
||||||
|
LUKE: Okay. All right. Why did you call darling? How can we help you?
|
||||||
|
|
||||||
|
DARLENE: So I've been sitting on this resignation letter for two months now. Two months, Luke, I work at the DMV and I just, I can't do it anymore.
|
||||||
|
|
||||||
|
LUKE: All right. So what's holding you up? Why are you sitting on it?
|
||||||
|
|
||||||
|
DARLENE: I don't know what comes next. I mean, I'm 46 years old. We've got the mortgage. David's hours at the plant aren't what they used to be. And it's not like I have some big plan, you know? I just know I can't keep going in there every day, feeling like this.
|
||||||
|
|
||||||
|
LUKE: I fully support that. Do you have any ideas as to where you're going to go or what you're going to do after you submit that letter?
|
||||||
|
|
||||||
|
DARLENE: That's the problem. I don't. I mean, I've thought about it. Believe me. Maybe something with people that actually feels like it matters, you know? Not just processing renewals all day.
|
||||||
|
|
||||||
|
LUKE: Well, are you looking to do work that matters or are you looking to get paid?
|
||||||
|
|
||||||
|
DARLENE: Oh man, that's the question, isn't it? I mean, we need the money, obviously. But Luke, I've been doing this for almost 15 years and I just feel like I'm disappearing a little more every day.
|
||||||
|
|
||||||
|
LUKE: Yeah, I understand that. And I think that's why I'm asking which you're looking to do. You can do both, but I think if you need the money, then you should focus on finding a job that's going to make you some money without distress and then take care of the importance or the fulfilling part after work in your personal life.
|
||||||
|
|
||||||
|
DARLENE: Yeah, that makes sense. I guess I've been thinking it has to be one big thing, you know? Like the job has to be the answer to everything. But you're right. I could find something that pays the bills without making me miserable and then do something that actually means something on my own time.
|
||||||
|
|
||||||
|
LUKE: Yeah, there are definitely jobs that can be both good money and fulfilling, but they're few and far between. And especially if you're leaving a job without another one lined up, I think my focus would be more on the salary with low stress and then find the community service aspect elsewhere because I don't think there's a lot of good paying community service oriented jobs that aren't stressful. I think when you add those three requirements up there's a there's a dearth of opportunity.
|
||||||
|
|
||||||
|
DARLENE: You're right. I've been building this up in my head like it has to be perfect or else I'm just trading one trap for another. Maybe I just need to get out of the DMV first and breathe a little. Find something stable that doesn't make me want to scream and then figure out the rest.
|
||||||
|
|
||||||
|
LUKE: There you go. Take yourself a little sabbatical, a couple of weeks off, figure out what it is you want to do. Find something, you know, maybe it'll fall into your lap.
|
||||||
|
|
||||||
|
LUCILLE: But if you continue just hanging on at the DMV where you hate your life every day, it's not going to get better and the days are going to go by and you're going to find yourself there at 60 wondering why you didn't leave when you thought of it the first time. God that's what scares me the most. David keeps saying just give it another year but that's what I said last year and the year before that. I don't want to wake up at 60 still sitting behind that same desk with that resignation letter still in my drawer, you know.
|
||||||
|
|
||||||
|
LUKE: Well, hand the letter in and jump off. I don't want to say jump off a cliff but, you know, take the leap, the leap of faith I guess and spend some time to figure it out and see what happens. What's the worst that could happen?
|
||||||
|
|
||||||
|
LUCILLE: I mean, the worst is we can't pay the mortgage, right? But we've got some savings and David's got his job at the plant. We're not going to starve. And that's the worst. The worst is you can't pay the mortgage and lots of people have got into a situation where they can't pay the mortgage. There's financial assistance. You could live in a cheaper place. You can move. So if the worst is that you can't pay the bank, then you know, is that worth being miserable for the rest of your life for? No, no, it's not. You're right. I've been so scared of what could go wrong that I haven't even thought about what could go right. Maybe I finally sleep through the night again without grinding my teeth.
|
||||||
|
|
||||||
|
LUKE: Well, I wish you the best of luck because being miserable all the time is not good for your health and you may not make it to 60 if you, if you stay stressed out about a job that you hate for a long time. Leon, Leon, welcome back to the show. How are you today?
|
||||||
|
|
||||||
|
LEON: Oh, man, I'm doing all right, Luke. Thanks for having me back. So listen, I actually did it. I reached out to UNM yesterday about that computer science program.
|
||||||
|
|
||||||
|
LUKE: Hey, congratulations. Good work. Did you reach out to the bank about the loan for that computer science program?
|
||||||
|
|
||||||
|
LEON: Yeah, well, hold on. I haven't gotten that far yet. I just sent an email to the admissions office to see if they even still have programs for, you know, older students coming back. But here's the thing that's got me twisted up. Amber thinks I should just do one of those online boot camp things instead. Says it's faster and cheaper. And Manny's telling me I'm overthinking it and should just apply to the full degree program like I was going to do back in 96. I don't know who's right.
|
||||||
|
|
||||||
|
LUKE: Well, I don't know who's right either, but I can tell you I'm not a fan of traditional schooling and I think it's designed to take your money and not provide you the value that that maybe you think. So my recommendation would be to not do that and take advantage of the learning opportunities that are free and all around you, like the whole of human knowledge that's available at your fingertips and just buckle down and learn on your own. But I recognize some people don't operate that way and if you need the structure of the college course, then then and you can afford it. Make sure you can afford it. Then do that.
|
||||||
|
|
||||||
|
LEON: Yeah, see, that's kind of what's eating at me though. Like I said, I'm not great at the self-teaching thing. I've tried watching YouTube videos about Python and all that, but I just end up clicking around and not retaining anything. I need somebody to tell me what to do next, you know. But the money part, that's real. Amber and I sat down with the numbers last night and between what I've got saved and maybe cutting back on some stuff, I could swing a boot camp without taking out loans.
|
||||||
|
|
||||||
|
LUKE: Well, that's fantastic. Then that's no brainer, you know, and don't bet the farm on it, but if you're just going to take a boot camp, that's not a four year degree, then absolutely go for it and congratulations on taking that step.
|
||||||
|
|
||||||
|
LEON: Yeah, I appreciate that Luke. I'm still nervous as hell about it, but my daughter, she's the one who found that old letter. She keeps texting me asking if I've done anything yet. It's like she's holding me accountable, which is weird because I'm supposed to be the parent, you know.
|
||||||
|
|
||||||
|
LUKE: Hey, you know, whatever works if she's holding you accountable and it's working for you and moving your life in a positive direction, then I'm for it. Good for you.
|
||||||
|
|
||||||
|
LEON: Yeah, it is what it is. I guess I'm just calling because I wanted to tell somebody outside my circle, you know, like making it real. Man, he's been busting my balls about it for 20 years, so telling him doesn't count.
|
||||||
|
|
||||||
|
LUKE: All right, well, I appreciate that you reached out to let me know. Uh, hey there. I got a question for you. What's something that you bought that actually changed your life?
|
||||||
|
|
||||||
|
LEON: Oh, man, that's a good question. Let me think. Honestly, it was my Subaru.
|
||||||
|
|
||||||
|
LUKE: It was your Subaru. How did that change your life?
|
||||||
|
|
||||||
|
LEON: Well, I bought it used about five years ago right after my old truck finally died and it sounds stupid, but having something reliable, like something that actually starts every morning and doesn't leave me stranded. It changed how I felt about my job. I could take longer halls. I wasn't stressed about breaking down with somebody's car on the hook.
|
||||||
|
|
||||||
|
LUKE: All right, that's a good one. Congratulations on your Subaru. Change your life. That's fantastic. And now it's time for another word from our sponsors. Stress, sore, skeptical, doesn't matter. Try placebo leaf, the CBD brand that may or may not do anything, but costs 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 like to. Take our gummies before bed and sleep great, probably because you're tired anyway, but you'll credit us. Available in flavors like calm and focus. Concepts we've bottled and marked up for a hundred percent. Visit placeboleaf.com and use code chill for 25 percent off. placebo leaf, wellness is a feeling and feelings can be purchased. All right placebo leaf and we're going to take one more call tonight and our lucky last caller is Lucille. Lucky Lucille. Welcome to the radio show. What would you like to talk about today?
|
||||||
|
|
||||||
|
LUCILLE: Hey Luke, it's Lucille.
|
||||||
|
|
||||||
|
LUKE: That's what I said. Close enough.
|
||||||
|
|
||||||
|
LUCILLE: So my kid just told me they're getting engaged to someone they met on the internet three weeks ago. Three weeks, Luke.
|
||||||
|
|
||||||
|
LUKE: Your kid? You sound like you're about three weeks old.
|
||||||
|
|
||||||
|
LUCILLE: I'm 30, Luke. My kid's 19. Yeah, I got married young myself right out of high school to David. So I'd get it, but this is different. They met this person online and have never even been in the same room together.
|
||||||
|
|
||||||
|
LUKE: And they're getting married? How's that going to work?
|
||||||
|
|
||||||
|
LUCILLE: Well, that's the thing. They're planning to meet in person for the first time next month, and apparently that's when they want to get engaged for real. Like the online part was just the warm-up. I don't even know if I should say something or just let them figure it out, you know?
|
||||||
|
|
||||||
|
LUKE: I think the more you say, the more likely they are to do stupid things so you might want to just let them figure it out.
|
||||||
|
|
||||||
|
LUCILLE: Yeah, that's kind of what David said too. But, Luke, I'm sitting here thinking about all the ways this could go wrong. Like what if this person isn't who they say they are? What if my kid flies out there and gets hurt or worse? I know if I push too hard, they'll just dig in. But doing nothing feels like I'm failing them.
|
||||||
|
|
||||||
|
LUKE: Well, you could just have a conversation. That's usually my advice for everybody is sit down with the person that you need to talk to and talk to them. Let them know that you're concerned, but you support their decisions. And if it goes wrong, be there to pick them up when they fall.
|
||||||
|
|
||||||
|
LUCILLE: You're right. I know you're right. It's just hard because I married David when I was 20 and everyone told us we were too young. And here we are 10 years later still figuring our shit out in therapy.
|
||||||
|
|
||||||
|
LUCILLE: So part of me wants to say, I get it. Follow your heart. But the other part is screaming that at least David and I knew each other in person, you know? We had history. This feels reckless even by my standards.
|
||||||
|
|
||||||
|
LUKE: Well, you know, just because his reckless doesn't mean it's wrong and you don't necessarily know best and they could fly out there and meet and have it work out perfectly.
|
||||||
|
|
||||||
|
LUCILLE: That's probably not going to happen. And I think anybody with any life experience would know that. But but stranger things have happened like stranger things this show.
|
||||||
|
|
||||||
|
LUKE: Ha, yeah, I guess so.
|
||||||
|
|
||||||
|
LUCILLE: I mean, I don't want to be the mom who ruins this for them if it actually is something real. Maybe I'm just freaked out because Valentine's Day is coming up and they keep posting about how romantic it's all going to be. I think I just need to hear someone say it's okay to be worried, but still let them make their own mistakes. That's what you're supposed to do, right?
|
||||||
|
|
||||||
|
LUKE: That is what you're supposed to do. It's okay to be worried but you still got to let people make their own mistakes.
|
||||||
|
|
||||||
|
LUCILLE: Yeah, God, that's hard though. I keep thinking about what I would have wanted my parents to say to me back then. And honestly, I probably would have ignored them anyway.
|
||||||
|
|
||||||
|
LUKE: Of course you would have. We all did. That's part of growing up is ignoring your parents good advice.
|
||||||
|
|
||||||
|
LUCILLE: You know what's funny is I heard Sandy call earlier the one with the neighbor and the dog shit situation and I kept thinking she needs to just have a direct conversation instead of letting it build up. But here I am doing the same damn thing with my own kid just avoiding the uncomfortable talk.
|
||||||
|
|
||||||
|
LUKE: Yep, you're going to have to have that talk with your kid and if you don't, that doesn't really matter either. She's going to go do what she's going to do and deal with the consequences. So you can you can be there for or you can be domineering and overpowering and pusher in the arms of a stranger. They can lope and vegas because they're mad at you. So better off to just have a hard to hard conversation. Let her know your concerns and let her know that you support her. So Lucille, what's the dumbest way you've ever injured yourself?
|
||||||
|
|
||||||
|
LUCILLE: Oh Jesus, okay random turn but I'll bite. I was showing a house last year and tried to demonstrate how sturdy the deck railing was by leaning on it real hard and the whole thing just gave way. Though right into the bushes in front of the clients, sprained my wrist and had to finish the showing covered in mulch. They didn't buy the house.
|
||||||
|
|
||||||
|
LUKE: That's too bad. All right well thanks for calling in. We appreciate we appreciate it. I hope everything works out with your daughter and her new husband and you don't worry about it too much. You know you've got your own relationship to worry about. That's the end of our show folks. Thanks to everybody that called in and we'll do this again tomorrow. So think about what you want to talk about and call in. You can call in the numbers 208-439-5853. That's 208-439-Luc.
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
LUKE: All right, all right. Welcome back. I am Luke. This is Luke at the Roost. It's the radio show where we take collars and give them real-life advice. If you'd like to call in, our number is 208-439-5853. That's 208-439-Luke. And today is Friday, February 13th, here in Anonymous. It's very windy. It gets nice and windy out here. So you might hear some noise in the background of our publication. Not much I can do about that. You're going to have to deal with it. I do. So hopefully the wind dies down and we can have a good show today. Already, we can see that the phones are lighting up and let's get our first collar on the line. Donnie, Donnie, welcome to the show. Happy Friday the 13th. How are you?
|
||||||
|
|
||||||
|
DONNIE: Hey, Luke. Yeah. Happy Friday the 13th to you too. Look, I got to tell you something that happened today, the chevron that's got me all twisted up. I ran into my ex-wife sister, haven't seen her in probably 15 years. And she just stared at me like she'd seen a ghost, then turned around and walked right back out without getting her gas.
|
||||||
|
|
||||||
|
LUKE: What'd you do? What'd you do to her sister?
|
||||||
|
|
||||||
|
DONNIE: Oh, not my sister. My ex-wife sister, her name's Carol. And I didn't do anything to her. That's the thing. But she knows stuff, you know. She knows about why me and Diane split up back in the day. All the messy details that I thought stayed buried in Tucson when I moved out here to Yuma.
|
||||||
|
|
||||||
|
LUKE: Oh, yeah, what kind of details does she have over you? What did she learn that she shouldn't know?
|
||||||
|
|
||||||
|
DONNIE: Well, back when Diane and I were married, I had an affair with a woman I worked with at the plant in Tucson. It went on for about eight months and Carol found out before Diane did. She actually caught us together at this bar over on Grand Road. She's the one who towed Diane, which is what ended the whole marriage.
|
||||||
|
|
||||||
|
LUKE: Well, actually, that's not quite true. You're the one that ended the whole marriage by cheating on your wife with another woman. So, I don't think you can blame the sister for that one. And it kind of makes sense that she didn't want to see you at the gas station.
|
||||||
|
|
||||||
|
DONNIE: Yeah, you're right. You're right. I'm not trying to blame Carol for what I did. That's on me, the affair, the lying, all of it.
|
||||||
|
|
||||||
|
LUKE: All right, then. Why is this a strange situation for you to have dealt with today? I mean, that's the response you should expect from your ex-wife's sister that caught you cheating on her sister.
|
||||||
|
|
||||||
|
DONNIE: Because it's been 15 years, Luke, and the way she looked at me wasn't just angry. It was like she was scared or shocked to see me. And here's the real thing that's eating at me. My current girlfriend doesn't know any of this. I never told her about the affair. Just said the marriage didn't work out.
|
||||||
|
|
||||||
|
LUKE: Oh, yeah, well, you sound like a real piece of work. You're a real winner over there, Adani. Well, you know, it's Friday the 13th. She saw you. You don't know what happened between her and her sister. You know, maybe there's some resentment there because she's been blamed for breaking up the marriage that you so hastily ruined yourself. So, you know, she might have a good reason to not want to see you.
|
||||||
|
|
||||||
|
DONNIE: You're not wrong about that. I never thought about it from that angle that maybe Diane blamed Carol for being the messenger instead of blaming me for actually doing it. That would eat at someone for 15 years.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, that's the kind of thing that happens. When you mess with people's lives in that way, especially family. So, I mean, you could have caused a rift between these siblings that you didn't intend to, as well as your own marriage. And you deserve to be looked at like a monster. That's what I say. So, good for you.
|
||||||
|
|
||||||
|
DONNIE: Yeah, I hear you. I deserve that. But, Luke, the thing is, what do I do now? Do I tell my girlfriend before Carol potentially says something?
|
||||||
|
|
||||||
|
LUKE: No, why would you do that? Why would Carol track down your new girlfriend just to tell her that you cheated on her sister 15 years ago? That would be pretty crazy. No, no, you leave it alone and pretend, just move on. Life is usual.
|
||||||
|
|
||||||
|
DONNIE: That's what I was thinking, too, but I don't know. Carol had my number back then, and Tucson's not that big. What if she knows, Shelley somehow? I mean, Shelley works at the Credit Union downtown, and Carol used to work in banking, too. It's just got me paranoid, you know?
|
||||||
|
|
||||||
|
LUKE: Yeah, Tucson's big enough. There's plenty of banking institutions in a 15-year gap. You are being paranoid, stop worrying about it, go about your life. And if it comes up and it ruins your new relationship, then deal with it then. And, you know, take accountability for your own part and all this.
|
||||||
|
|
||||||
|
DONNIE: You're right. You're right. I'm spiraling over nothing. It was just a weird moment at the gas station. I need to let it go and stop creating problems that don't exist yet.
|
||||||
|
|
||||||
|
LUKE: Yes, you do. Let it go and stop creating problems. Thanks for the call, Donnie. Stop being a dick and grow up. That's what I have to say to you. Next on the line, we have Jasmine. Jasmine, what do you think about Donnie's situation? You think he was in the right in any part of that?
|
||||||
|
|
||||||
|
JASMINE: Oh, man. No, but seriously, Luke, I think Donnie's just scared. I get it, though. When you've done something that bad, you're always waiting for the other shoe to drop. But here's the thing. He ended one relationship with a lie, and now he wants to start another one with a lie.
|
||||||
|
|
||||||
|
LUKE: Have you ever started a relationship with a lie?
|
||||||
|
|
||||||
|
JASMINE: Well, yeah. I mean, not like Donnie's lie, but David and I, when we first got together after Donnie, I didn't tell him for like six months that I was still talking to Donnie about the kid's schedules. Not hiding it exactly, but not bringing it up either. David would have understood. But I was worried he'd think I was still hung up on my ex or something.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, you know, you got kids. You got to make accommodations for them. That makes perfect sense. What are you calling in for tonight?
|
||||||
|
|
||||||
|
JASMINE: So I actually wanted to give you an update from the last time I called. Remember I told you about the Bronco situation? Sleeping in the guest room all that? Well, Valentine's Day is in like a week and a half. And David actually came to me yesterday and said he wants to take me to see the SpaceX launch on the 13th.
|
||||||
|
|
||||||
|
LUKE: Well, that's pretty cool. Where's the SpaceX launch? Today is the 13th.
|
||||||
|
|
||||||
|
JASMINE: Oh shit, is it? I've been losing track of days at the bakery. No, but seriously, I mean the one they're doing live coverage of. The astronauts going to the International Space Station. David knows I watch every single launch and he said we could drive out to the spot he knows where we can see it better on a screen at this bar that does viewing parties. He's never offered to do anything like that before.
|
||||||
|
|
||||||
|
LUKE: Well, that's awesome. I hope you have a great time watching the SpaceX satellites launch. I've actually never seen that. I see pictures of it after the fact, but I've never caught one. I've seen the actual satellites, you know, the trail of Starlink satellites going across the sky like Santa's sleigh, but I haven't seen a launch.
|
||||||
|
|
||||||
|
JASMINE: Oh man, you need to watch one live, Luke. It's incredible. I get chills every single time, especially when the boosters land back. It's like something out of a science fiction movie.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like you're going out into the wilderness to have yourself a science friction movie.
|
||||||
|
|
||||||
|
JASMINE: Yeah. Oh, Jesus, Luke. Yeah. Well, here's the thing though. I don't know if he's doing this because he actually wants to or because he feels like he has to, you know, like is this a genuine olive branch or is he just trying to fix things before Valentine's Day because he's supposed to?
|
||||||
|
|
||||||
|
LUKE: Does it really matter? Do you care? As long as you get to go out and have some one-on-one time with the dude and is it? What's the difference?
|
||||||
|
|
||||||
|
JASMINE: I mean, yeah, you're right. I guess I'm just scared it's going to be awkward, you know. We haven't really talked about the whole Bronco thing since it happened.
|
||||||
|
|
||||||
|
LUKE: Yeah.
|
||||||
|
|
||||||
|
LUKE: You said you were going to leave him for your Ford Bronco, and now you're worried that his olive branch isn't, uh, what's the word I'm looking for? Authentic enough for you? Come on.
|
||||||
|
|
||||||
|
CALLER: Okay, okay, you're right. I'm being ridiculous. No, but seriously though, what if we get out there and we just sit in silence the whole time? We've barely said two words to each other in weeks that weren't about the bakery or whose turn it is to take up a trash.
|
||||||
|
|
||||||
|
LUKE: Well, you know, sitting out in the wilderness watching a monumental event like that in silence is not so bad. So I would say if you don't have anything to say, don't say anything, just enjoy the moment and keep it as a fond memory.
|
||||||
|
|
||||||
|
CALLER: You know what, you're right. I'm overthinking this. Maybe that's what we need, just to be together without forcing conversation, without all the guilt hanging over us.
|
||||||
|
|
||||||
|
LUKE: Now, if it were up to you, if you could control the situation, what would you talk about during the SpaceX launch?
|
||||||
|
|
||||||
|
CALLER: Oh man, I don't know. I guess I'd want to talk about like where we go from here, you know? Not in some heavy, we need to talk about our relationship way, but just...
|
||||||
|
|
||||||
|
LUKE: So nothing. So you get nothing to say. So you're afraid it's going to be awkward because you don't know what to say because you got nothing to say. How's the Bronco?
|
||||||
|
|
||||||
|
CALLER: The Bronco's good. Real good actually. Got the carburetor tuned last weekend. Run smooth now.
|
||||||
|
|
||||||
|
LUKE: All right then. Why don't you tell them about your Bronco? I'm sure he'll be excited to hear about all the the new news there. Dennis, Dennis, welcome to the show. Happy Friday the 13th. What can we help you with?
|
||||||
|
|
||||||
|
DENNIS: Oh man, yeah. Happy Friday the 13th is right. So Luke, I'm calling because I did something really stupid about three weeks ago, and it's all kind of falling apart on me. Now, I met this woman. We had this crazy intense connection, and I signed a lease with her after knowing her for like three weeks, and now I'm living with her, and she is not the person I thought she was at all, like completely different behind closed doors, and I don't know what the hell to do because I'm on this lease.
|
||||||
|
|
||||||
|
LUKE: Well, you'll never do that again. That wasn't very smart. What's the lease say? What are the terms of your lease?
|
||||||
|
|
||||||
|
DENNIS: It's a year lease man. We just signed it maybe two and a half weeks ago. It's a little place over in the East Valley. Nothing fancy, but it's not cheap either. I think we can break it, but there's penalties. You know, lose the deposit maybe over a couple months rent on top of that. I'd have to look at the actual paperwork, but I know it's not just a walk away situation.
|
||||||
|
|
||||||
|
LUKE: Well, that sounds like you've got to make the best of it. I mean, lots of people have had a roommate that didn't like for a year. You could do anything for a year. And who knows, you might grow to enjoy the company of this stranger.
|
||||||
|
|
||||||
|
DENNIS: Yeah, but Luke, it's not just that we don't click as roommates. This is someone I was sleeping with. Someone I thought I was starting a real relationship with, and now it's like, she's got this whole other side that comes out.
|
||||||
|
|
||||||
|
LUKE: What's the other side? Are you still sleeping with her?
|
||||||
|
|
||||||
|
DENNIS: No, we're not. That stopped about a week ago. The other side is like, she's controlling about everything in the apartment. Where I put my stuff, when I come and go, she wants to know every detail of my day. And she's got this temper that just comes out of nowhere over nothing.
|
||||||
|
|
||||||
|
LUKE: Then what's your personality like? Are you going to stand for that? Are you going to stand your ground and say, hey, that's enough. I put my stuff on the right side of the bed. You sleep on the left side of the bed.
|
||||||
|
|
||||||
|
DENNIS: I mean, I've tried saying something a couple times, and it just turns into this whole thing where she's crying or yelling or both. I'm not great with confrontation, especially after my divorce. That whole thing kind of beat me down, you know? I've been trying to just keep the peace. Stay out of her way when I can, but it's my place too.
|
||||||
|
|
||||||
|
LUKE: It's your place too, but it's not going to be if you give her the run of the land, so you're going to have to stand up for yourself eventually. It's going to be a long year, my friend.
|
||||||
|
|
||||||
|
DENNIS: Yeah, you're right. I know you're right. I just, I guess I'm realizing I jumped into this because I was lonely. You know, two years out from the divorce, and I'm still not really back on my feet emotionally.
|
||||||
|
|
||||||
|
LUKE: Yeah, absolutely. I understand, but jumping into a lease with a woman you just met is in a good way to deal with loneliness, or you end up in a situation like the one you're in right now where you don't know where your toothpaste is. So I recommend that, I don't want to say be a dick, but you got to stand up for yourself and take some control of the situation.
|
||||||
|
|
||||||
|
DENNIS: You're absolutely right. I need to stop being a pushover about this. I'm going to go home tonight, and we're going to have a real conversation about boundaries.
|
||||||
|
|
||||||
|
LUKE: All right, there you go. And I bet once you do, you'll be sleeping with her again because chicks love that stuff.
|
||||||
|
|
||||||
|
DENNIS: I don't know about that Luke. Honestly, at this point, I'm more worried about making you through the lease without losing my mind than getting back in bed with her. But yeah, I hear you. I appreciate it, man.
|
||||||
|
|
||||||
|
LUKE: Well, where are you sleeping now? You on the couch? You on the floor? What's the deal?
|
||||||
|
|
||||||
|
DENNIS: I'm on the couch. She took the bedroom. I've been out there for like eight days now with my pillow in a blanket, watching spacetime episodes on my phone at like two in the morning trying to fall asleep. It's ridiculous. I'm 40 years old sleeping on a couch in my own apartment.
|
||||||
|
|
||||||
|
LUKE: What's your financial situation? Like you get enough money to walk out on that lease?
|
||||||
|
|
||||||
|
DENNIS: I mean, not really. I work in logistics. I'm doing okay, but I'm not flush with cash. Breaking the lease would probably cost me a couple thousand bucks. I don't have sitting around. Plus first and last on a new place. And honestly, the rental market out here right now is brutal.
|
||||||
|
|
||||||
|
LUKE: All right. Well, I have the conversation with the woman and see what you can do to get back in the big bed if you want to be there. Or get used to sleeping on the couch, but either way, you're going to have to claim some space in your apartment and make it your own and deal with it for a year. I think you can do that. I have faith in you.
|
||||||
|
|
||||||
|
DENNIS: Yeah, you're right. I can do this. I just need to stop being so passive about the whole thing. Thanks, Luke. I needed to hear that.
|
||||||
|
|
||||||
|
LUKE: All right, Dennis. Thanks for the call and good luck. And now it's time for a word from our sponsors. Reading is dead. You killed it. But you can pretend you're literate with script drift. The audiobook service for people who start books and never finished them. We have thousands of titles you'll sample for 11 minutes before switching to a true crime podcast. Our app remembers exactly where you abandoned sapiens eight months ago, waiting patiently like a disappointed parent. Script drift uses advanced AI to recommend books based on your aspirations, not your actual follow-through. This month's suggestion of 400 page philosophy text you'll listen to while doing the dishes and retain nothing. Try script drift free for 30 days. Use code pretentious for two months at 60% off. Script drift. Your goodreads profile is a lie. Okay, welcome to the show, Francine. Francine, are you the Francine? I know the one that comes and goes. Tell me, Francine, what's one thing you refuse to cheap out on?
|
||||||
|
|
||||||
|
FRANCINE: Well, hey, Luke. Yeah, I'm the Francine who calls in sometimes. One thing I refuse to cheap out on. Good hiking boots, man. I learned that the hard way when I was out in your superstition mountains and my cheap pair fell apart halfway through a 10 mile loop. Had to wrap them with paracord just to get back to the truck.
|
||||||
|
|
||||||
|
LUKE: I understand. That's a good one. That's a real good one. Never cheap out on hiking shoes or shoes in general. You know, on your feet a lot, you gotta have good shoes. It definitely matters. I'm with you on that one. Why do you sound so angry, Francine?
|
||||||
|
|
||||||
|
FRANCINE: I'm not angry.
|
||||||
|
|
||||||
|
LUKE: Tell me about something else you took away from there?
|
||||||
|
|
||||||
|
FRANCINE: That I've been using drinking to avoid dealing with shit. Like I'd come home from a stressful wedding, brides-ill-of-freaking-out, family drama, whatever, and I'd just crack open a beer instead of actually processing it. My counselor kept saying I was numbing instead of feeling, and I fought her on that for weeks before I finally got it.
|
||||||
|
|
||||||
|
LUKE: What made you go to the rehab? Did something happen, or did you just kind of decide that you didn't want to drink anymore?
|
||||||
|
|
||||||
|
FRANCINE: I got a DUI back in October. I was coming back from a wedding in Sedona, but I was fine to drive, and I wasn't.
|
||||||
|
|
||||||
|
LUKE: And how do you feel about it now? Do you want to remain sober? Is that something that you want as part of your life? Do you identify yourself as an alcoholic with a drinking problem?
|
||||||
|
|
||||||
|
FRANCINE: Yeah, I'm an alcoholic. Took me a while to say that out loud, but I am. And yeah, I want to stay sober. I mean, I have to. I've got two years probation, and doing the meetings, all of it, but it's more than that.
|
||||||
|
|
||||||
|
LUKE: Yeah, I understand. And congratulations on learning to stay sober. You're obviously doing a pretty good job three months as a while, and you're sober today, so that's really all the matters. So good for you. Keep it up, and I wish you the best with your roommate and try to take it easy on her, because she doesn't know what it's like, I would guess.
|
||||||
|
|
||||||
|
FRANCINE: Yeah, you're right. She really doesn't. I texted her earlier, and she felt awful about it. She had no idea it would be a thing.
|
||||||
|
|
||||||
|
LUKE: Yeah, you can't blame her for that. She had no idea it would be a thing. I wasn't trying to hurt you, taking care of the situation, and now you guys can work that out if there's going to be alcohol around or not. But congratulations on your Friday. Keep up the good work, and call back in anytime. Rodney, Rodney, welcome to the show. What's happening?
|
||||||
|
|
||||||
|
RODNEY: Hey, Luke, yeah, appreciate you taking the call. So look, my kid moved back in again. Third time now, and my wife, Diane, is ready to lose her mind.
|
||||||
|
|
||||||
|
LUKE: How old's the kid, and why is he or she moving back in for the third time?
|
||||||
|
|
||||||
|
RODNEY: She's 26, and honestly, it's the same story every time. Lucy's a job, has some falling out with whoever she's living with, runs out of money.
|
||||||
|
|
||||||
|
LUKE: And you just say, yes, what's your plan to get her on her feet or keep her on her feet?
|
||||||
|
|
||||||
|
RODNEY: Well, that's the thing. I don't have a plan. I mean, Diane and I are separated right now. We're still talking, but she's at her sister's place. And she's telling me if I let our daughter stay this time, she's going to come over and change the locks herself. I keep thinking, maybe this time will be different. You know, but I heard that caller Dennis earlier, and honestly, I thought you were pretty rough on him.
|
||||||
|
|
||||||
|
LUKE: Well, you know, I'm a pretty rough guy. I don't know what you want me to tell you. I'm not here to coddle people.
|
||||||
|
|
||||||
|
RODNEY: No, I get that. I just mean, look, I read a lot of poker books, right?
|
||||||
|
|
||||||
|
LUKE: Yeah, okay, you read some poker books.
|
||||||
|
|
||||||
|
RODNEY: Yeah, and there's this concept about knowing when you're beat, when to fold. And I keep putting money in this pot with my daughter thinking the next card's going to save me, but it never does.
|
||||||
|
|
||||||
|
LUKE: Yeah, I think it goes like, you got to, you got to know when what is it? What is it? You got to know when to hold them?
|
||||||
|
|
||||||
|
RODNEY: No, when to fold them. Yeah. And I keep not folding. Diane sees it clear as day says I'm enabling her that our daughters never going to stand on her own to feed if I keep catching her. But she's my kid, Luke. What am I supposed to do? Just let her be homeless?
|
||||||
|
|
||||||
|
LUKE: Well, you know, if you make it too easy for her to come stay with you, maybe, maybe yeah, you should let her be homeless for a little while. I mean, you know, honored to get hurt or end up in a situation that's dangerous, but also you can't, you can't support a 26-year-old for the rest of her life, and she needs to learn how to take care of herself in the world without relying on whatever person that she meets that she can move in with immediately.
|
||||||
|
|
||||||
|
RODNEY: You're right. I know you're right. It's just hard to actually do it, you know? Every time she calls, I can hear it in her voice. She's scared. She's desperate.
|
||||||
|
|
||||||
|
LUKE: What does she do? What does she do for a profession?
|
||||||
|
|
||||||
|
RODNEY: Well, she had that job at the call center for a while. She was actually pretty good at it, but she quit because her supervisor was, quote, toxic. Before that, she was doing some kind of online thing, selling stuff on Etsy or whatever. Nothing ever sticks.
|
||||||
|
|
||||||
|
LUKE: So she's not working at all. She's just living on your couch, eating your food, and ruining your relationship.
|
||||||
|
|
||||||
|
RODNEY: That's exactly what Diane says. And yeah, she's not working right now. She says she's looking, but I don't see a whole lot of effort there. And Diane won't even come over anymore because of it. That's why we're separated. She told me flat out. It's me or her, Rodney.
|
||||||
|
|
||||||
|
LUKE: Okay, well, here's what I say. I say, give the daughter a timeline. A couple of two weeks seems reasonable to me.
|
||||||
|
|
||||||
|
LEROY: Say, you're going to work. You're going to get yourself a job. You get two weeks to do it. And if you're not working and pain rent and supporting yourself here, then you got to leave. And I don't care where you go, but you can't stay here for free. You're 26 years old. Two weeks.
|
||||||
|
|
||||||
|
LUKE: Yeah, I could do that.
|
||||||
|
|
||||||
|
LEROY: All right. Good. So you do that. You tell the, you tell the wife that that's the situation. You make sure everybody's on the same page. That's reasonable. That's helping somebody out. And if, you know, if your daughter's not good at writing a resume or needs a ride to get a job or something like that, you know, you can help her out in those ways, but make sure she's doing the work and that she's moving into a direction where she can support herself. Otherwise, she's just going to have you do it.
|
||||||
|
|
||||||
|
LUKE: You're right. I'll tell Diane tonight. Let her know there's a deadline. Maybe that'll get her to at least come back to the house. All right, sir. Good luck with you and Diane. Let's see. Let's see. Who else we got? Rita. Rita. Welcome to the show. What are you calling in for tonight?
|
||||||
|
|
||||||
|
RITA: Oh, man. Luke, I really screwed up. So I've been seeing my ex again. We've been meeting up at this motel in Deming every Thursday for the past three months. My husband found out something's going on because the mileage on our car doesn't add up.
|
||||||
|
|
||||||
|
LUKE: Okay, there's a couple of things there. Your husband's looking at the mileage on your car to notice that you've been driving to Deming. Also, you're going to a hotel in Deming. And thirdly, why are you cheating on your husband with your ex?
|
||||||
|
|
||||||
|
RITA: Yeah, I know how it sounds. We share the truck so he noticed when he filled it up last week. I just saw the miles didn't match where I said I'd been.
|
||||||
|
|
||||||
|
LUKE: Well, that sounds pretty crazy because nobody looks at the mileage that closely.
|
||||||
|
|
||||||
|
RITA: Well, he does now, I guess. He's always been kind of detail-oriented, keeps track of the gas receipts for taxes since he's a contractor. But honestly, I think I got sloppy. Told him I was going to lost cruises to see my sister a couple times when I was really going the other direction to Deming. The math just didn't work out.
|
||||||
|
|
||||||
|
LUKE: Yeah, no, I don't think that's the problem. I think the problem is you've been acting shady lately. He noticed and he's been extra cautious of what you've been doing trying to catch you in the act. What else have you been doing lately?
|
||||||
|
|
||||||
|
RITA: You're probably right. I mean, I've been distant. I know that. Coming home late on Thursdays, saying I picked up extra shifts at the bar, but then the money doesn't really show it. And I've been on my phone a lot more like stepping outside to text.
|
||||||
|
|
||||||
|
LUKE: Okay, what's up with the ex? Which one of these people do you want to be with?
|
||||||
|
|
||||||
|
RITA: That's the thing, Luke. I don't know. My ex, we were together for four years before I met my husband. It ended messy, but when we ran into each other at a wedding back in November, it all just came rushing back.
|
||||||
|
|
||||||
|
LUKE: And how many times have you met up with him at this hotel in Deming?
|
||||||
|
|
||||||
|
RITA: Every Thursday for three months. So 12 times, I guess? Sometimes 13 if there were five Thursdays in a month?
|
||||||
|
|
||||||
|
LUKE: All right, so this is a serious thing. I think you need to have a conversation with your husband because something's going on with you, and that's not fair to him.
|
||||||
|
|
||||||
|
RITA: You're right, but I'm terrified. We've been married two years. We just bought the house together last spring. His whole family's here. My job's here.
|
||||||
|
|
||||||
|
LUKE: Your terrified? You met up with another dude 13 times and your terrified? Stop it. Grow up.
|
||||||
|
|
||||||
|
RITA: Okay, yeah, that's fair. I made the choice to do this. I guess what I'm really terrified of is that I don't know what I want to say to him, because I don't know what I actually want.
|
||||||
|
|
||||||
|
LUKE: Well, you're going to have to tell him the truth that you've been meeting up with somebody on the side. You don't have to tell him it was 13 times Jesus Christ. Just tell him, hey, something's going on. I don't feel the same about you, even though we just got married a couple of years ago, and we just bought a house together, and I never mentioned it to you before. But I'm confused about what I want in my life, and come do an agreement with him. Let him know what's up, or maybe propose an open marriage, or something like that. Whatever it is, it's not fair to that man to be a work-in-to-support you and your family while you're out banging some dude and damning.
|
||||||
|
|
||||||
|
RITA: You're absolutely right. I've been selfish as hell. He deserves to know what's going on. And honestly, I think I've known for a while that I need to tell him.
|
||||||
|
|
||||||
|
LUKE: So you're going to tell him tomorrow on Valentine's Day?
|
||||||
|
|
||||||
|
RITA: Oh God, no. Valentine's Day's next week. No. I was thinking maybe this weekend before it gets to that. I can't sit through some romantic dinner knowing what I've been doing.
|
||||||
|
|
||||||
|
LUKE: Valentine's Day is tomorrow.
|
||||||
|
|
||||||
|
RITA: Wait, what? No, it's... oh shit, you're right.
|
||||||
|
|
||||||
|
LUKE: Yeah, I know. I usually am.
|
||||||
|
|
||||||
|
RITA: Yeah, I know you do. Okay, so Valentine's Day is tomorrow. That makes this so much worse. He probably has something planned, and I'm going to have to ruin it.
|
||||||
|
|
||||||
|
LUKE: Well, maybe you should tell him tonight before he waits his time and money on you.
|
||||||
|
|
||||||
|
RITA: You're right. I should tell him tonight. I can't let him wake up tomorrow thinking everything's fine and spend money on flowers or dinner or whatever he's got planned. That would be cruel.
|
||||||
|
|
||||||
|
LUKE: All right, Rita, you know what you've got to do. You're going to have to have a conversation with the husband and maybe the ex-husband too. Are you using protection when you meet up with the ex?
|
||||||
|
|
||||||
|
RITA: Yeah, we are. I mean, at least I'm not bringing that kind of risk home to him on top of everything else. But Jesus, Luke, hearing you ask that just makes it even more real how messed up this whole thing is.
|
||||||
|
|
||||||
|
LUKE: Yeah, it's pretty messed up. It's definitely messed up, and you're definitely a monster for doing it and continuing to do it. So you're going to have to stop that.
|
||||||
|
|
||||||
|
RITA: I know. I know I am. I've been lying to myself saying it would just stop on its own, but it hasn't. I have to end it with my ex, like completely end it. No more Thursdays in the name of this episode.
|
||||||
|
|
||||||
|
LUKE: No more Thursdays in Deming. Well, good luck to your husband. I don't know what to say to you. You're going to have to you're going to have to get yourself together there, Rita. Leroy, welcome to the show. What have you got to say? What do you think about Rita's conversation there, Leroy?
|
||||||
|
|
||||||
|
LEROY: Oh man, Luke, that was rough to listen to. I mean, I've been married 43 years, and I can't imagine doing that to my wife. Rita's playing with fire and she knows it. She's just hoping she don't get burned.
|
||||||
|
|
||||||
|
LUKE: She's hoping she don't get you right.
|
||||||
|
|
||||||
|
LEROY: She is. She's thinking about herself. What about the husband? That's what I'm saying. That poor guy sitting at home, probably thinking everything's fine, and she's been lying to his face for three months straight. Every Thursday, right? That's calculated, Luke. That's not some mistake or one-time thing.
|
||||||
|
|
||||||
|
LUKE: No, that's some Kaiser Sosa shit. So what are you calling in for tonight?
|
||||||
|
|
||||||
|
LEROY: Well, I got something on my mind about the economy, Luke. I'm sitting here in my truck, and I just got done looking at my retirement account, and I'm telling you, I'm worried. I've been working union electrical for 40 years, done everything right, saved my money, and now I'm watching everything get squeezed. Prices are up, my savings ain't growing like they should, and I'm supposed to retire in two years.
|
||||||
|
|
||||||
|
LUKE: Well, you can't really time the markets, but I wouldn't worry just yet. You know, the markets are in a pullback. People are reeling from Trump doing what he does and talking about Greenland and starting wars with everybody in ice and America and tariffs and all that. But the stock market has pretty much always gone back up. It's always at record highs. So you've got two more years to go. I would expect that there will probably be a large bounce in the next two years.
|
||||||
|
|
||||||
|
LEROY: Yeah, I hear you on that, and maybe you're right about the bounce, but Luke, it ain't just the retirement account. It's everything around here.
|
||||||
|
|
||||||
|
LUKE: Yeah, I understand, but hey, at least we made America great again, right? All right, thanks for the call, Leroy. And now, because the economy is crashing, we need to have another word from our sponsors. Ladies and gentlemen, this episode of The Radio Show is brought to you by Mediocre CPAP. The makers of a 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 sleep like garbage. Half the night it's like, because the seal isn't perfect. 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. Okay, thanks to Mediocre CPAP. We appreciate your support. Next up on the show we have Amber, Amber, Happy Friday the 13th. Thanks for calling in. What would you like to talk about?
|
||||||
|
|
||||||
|
AMBER: Hey, Luke. Yeah, Happy Friday the 13th to you too. So I wanted to get your take on something. I was talking with this customer at the thrift store today. Older guy. And he was going on about how there's no good TV anymore. Everything's garbage. The golden age is over.
|
||||||
|
|
||||||
|
LUKE: I don't think that's true at all. I mean, a lot of people say the golden age of TV was madman when AMC was very popular. And then there was breaking bad in them. But there's a lot of good shows out right now. What are you watching?
|
||||||
|
|
||||||
|
AMBER: Well, that's exactly what I told him. I said, look, we're getting stuff now that's actually making people think. I've been watching Severance. I don't know if you've seen it. But it's about people who split their work memories from their home memories. And it gets into all this stuff about consciousness and identity. Reminds me of some of the quantum mechanic stuff I read about, you know, how observation changes reality.
|
||||||
|
|
||||||
|
LUKE: Well, observation doesn't change reality. Observation, my understanding is observation sets reality. So before some things observe, things are in a state of what's called superposition where they're all options at the same time. And it's the observation that makes the universe decide which one of those things to record basically.
|
||||||
|
|
||||||
|
AMBER: Oh man, you're right. That's a better way to put it. See, that's what I love about this stuff. The observer effect, Schrodinger's cat, all that. Until you look in the box, the cat's both alive and dead at the same time. It's the looking that forces it to be one or the other.
|
||||||
|
|
||||||
|
LUKE: Maybe that could be the case. It could also be that all options are true. And when you look the universe splits into multiple timelines where in each timeline a different option was chosen.
|
||||||
|
|
||||||
|
AMBER: Holy shit, the many worlds interpretation, yeah, I've read about that. So right now there's a version of me that didn't call in tonight.
|
||||||
|
|
||||||
|
LUKE: Right. And a version of you that's talking to somebody else. Every choice we make, the universe just branches off into all these different realities.
|
||||||
|
|
||||||
|
AMBER: Right. And that makes me wonder as Schrodinger's cat is an example of he was making fun of the whole idea of a superposition. But was it us studying the superposition that actually made that be the case? Do you think that that quantum mechanics worked at all until we started studying quantum mechanics or was our looking into it, what actually created it?
|
||||||
|
|
||||||
|
LUKE: Whoa. Okay. That's a hell of a question. So you're saying maybe quantum mechanics didn't even exist as a thing until we started poking around at that level. Like the universe wasn't operating that way until we forced it to by observing it.
|
||||||
|
|
||||||
|
AMBER: Well, yeah, like AI didn't exist until we started poking around at AI. Somebody had that idea, then we started looking into it, and now it's a thing.
|
||||||
|
|
||||||
|
LUKE: Or the internet didn't exist until we started thinking about a network of computers. Lots of things didn't exist, especially in the sciences until we kind of decided to point our attention in that direction.
|
||||||
|
|
||||||
|
AMBER: I see what you're getting at, but I think there's a difference though. AI and the internet, those are things we built, right?
|
||||||
|
|
||||||
|
LUKE: I don't know, are they?
|
||||||
|
|
||||||
|
AMBER: Well, okay. Now you're messing with my head a little bit. I mean, we wrote the code, we built the servers, we created the algorithms, but quantum mechanics, that's supposed to be how particles have always behaved, even before we knew about it. Like electrons were doing their thing long before we had particle accelerators at CERN.
|
||||||
|
|
||||||
|
LUKE: Sure, but did quarks exist before we learned how to see them?
|
||||||
|
|
||||||
|
AMBER: Okay, but that's like saying mountains didn't exist before we had eyes to see them. The quarks were there, we just didn't know maybe, but if they've never been observed, did they exist yet? Or were they in a state of existing and not existing until we looked?
|
||||||
|
|
||||||
|
LUKE: Oh man, now you're turning the whole thing back on itself. So you're saying maybe quarks themselves were in superposition existing and not existing until we built the equipment to observe them? That's, I mean, that gets really weird, really fast. Because then what about all the stuff we haven't discovered yet?
|
||||||
|
|
||||||
|
AMBER: Exactly. Yeah, are we creating it as we look like a lot of scientific discoveries have come from science fiction. So somebody had a wild idea and wrote it down and it was not something that existed and then we started to think, well, what if that could exist and we put our effort into making it exist?
|
||||||
|
|
||||||
|
LUKE: Yeah, but there's still a difference between making a communicator from Star Trek into a cell phone that's engineering something we imagined versus discovering that atoms are mostly empty space. We didn't imagine atoms into being empty. They just are that way. Or are you saying maybe they weren't empty until we looked?
|
||||||
|
|
||||||
|
AMBER: I'm saying if you believe in quantum mechanics and superposition, there's a good possibility they weren't they weren't empty until we looked.
|
||||||
|
|
||||||
|
LUKE: Okay, so then holy shit, are we just collapsing reality into one specific version? Every time we look at something new, like the universe is this big cloud of possibilities and science is just us picking which version becomes real.
|
||||||
|
|
||||||
|
AMBER: And possibly even creating an alternate universe where every version is real? Jesus. So every time CERN fires up the collider and discovers a new particle, we're not just finding it. We're splitting off into one universe where it exists this way. And maybe there's another branch where it exists completely different. Or not at all. That's, I mean, that would explain why some of this quantum stuff feels so damn slippery.
|
||||||
|
|
||||||
|
LUKE: Yeah, yeah, and it's not just the quantum stuff. I mean, all of Einstein's thought experiments and relativity, like until he had those experiments and thought it in his mind and made the assertion that this is a theory of the way things work. That's not how things worked. And once he had that theory and people ran the experiments against the theory that he had had, I mean, did he actually create reality by having those thought experiments?
|
||||||
|
|
||||||
|
AMBER: Okay, but Einstein's relativity was describing how things already worked. Like Mercury's orbit was already wonky before Einstein explained why. The math he came up with just matched what was already happening. He didn't make time dilation real by thinking about it. He just figured out it was already there, right?
|
||||||
|
|
||||||
|
LUKE: Well Isaac Newton's math worked too. And the y was a completely different, different explanation. So Einstein just came out with a different y. Or let's say not Einstein, let's go with Stephen Hawking. He really believed that that black holes admitted radiation, right? That that matter could not be destroyed in a black hole, but it had to go somewhere. So by having that assertion and then running experiments against it, he found that black holes actually do emit Hawking radiation. So was it his theory and line of questioning that caused the universe to behave in that way? Or did the universe always behave in that way and he just found it?
|
||||||
|
|
||||||
|
AMBER: Well, here's the thing though, we still haven't actually detected Hawking radiation. It's still theoretical.
|
||||||
|
|
||||||
|
LUKE: I don't think that's true. I'm pretty sure we have detected Hawking radiation.
|
||||||
|
|
||||||
|
AMBER: No, I'm pretty sure we haven't. It's way too faint to detect from actual black holes. The radiation would be completely drowned out by all the other stuff in space.
|
||||||
|
|
||||||
|
LUKE: You are correct. I'm wrong here. We have not actually confirmed Hawking radiation. I thought that we had.
|
||||||
|
|
||||||
|
AMBER: So we haven't detected it because it's incredibly weak. It's weaker than the cosmic background radiation.
|
||||||
|
|
||||||
|
LUKE: Right, exactly. So that's actually a perfect example of what I'm getting at. Hawking predicted mathematically, but we can't confirm it's really at, which kind of argues against the idea that his theory, the reality, you know? If thinking it made it real, we'd be able to see it.
|
||||||
|
|
||||||
|
AMBER: I don't know if that's true. If thinking it made it really can still be real and we can't see, there's lots of things we can't see. You can't see an x-ray.
|
||||||
|
|
||||||
|
LUKE: Okay, fair point. But I mean, we can detect x-rays even if we can't see them with our eyes. The difference here is Hawking radiation might not even exist. Or it might. We just don't know yet.
|
||||||
|
|
||||||
|
AMBER: Well, I mean lab experiments have created synthetic black holes. And from those, we can observe the radiation that behaves in a similar mathematical way to what Hawking had discovered, theorized, proposed.
|
||||||
|
|
||||||
|
LUKE: Yeah, but those aren't real black holes, though. They're acoustic black holes or optical analogs. Right. Like sound waves in fluid that behave mathematically similar. That's not the same as proving actual black holes emit actual radiation. It's like it's a model that fits the math, but that doesn't mean the universe is actually doing it that way out there in space.
|
||||||
|
|
||||||
|
AMBER: Okay, okay, what were we originally talking about? You write about the Hawking radiation and I learned some today. I thought that was a proven thing.
|
||||||
|
|
||||||
|
LUKE: Yeah, I mean, I learned something too. I didn't know about those lab experiments with the synthetic black holes. That's actually pretty cool. But yeah, my whole point was just that we can think of all kinds of elegant mathematics and it doesn't necessarily mean reality works that way until we can actually test it. Like, serons doing the real work, smashing particles together and seeing what actually happens, not just what the equations say should happen.
|
||||||
|
|
||||||
|
AMBER: Right, but what they're looking for is what they already expect is happening, what they want to happen. And if it goes back to like the Schrodinger's cat thing, is that actually what is making it happen?
|
||||||
|
|
||||||
|
LUKE: Oh man, now you're getting into the measurement problem. I mean, yeah, there's something weird going on with observation and quantum mechanics. But I don't think physicists that CERN are literally creating particles just by expecting them.
|
||||||
|
|
||||||
|
AMBER: Which is because you don't think it doesn't mean that's not true. I mean, the Higgs boson, we kind of theoretically thought that might be possible and started looking in that direction and finally found it. Now, was it our measurement that caused it to exist?
|
||||||
|
|
||||||
|
LUKE: Okay, but the Higgs field was supposed to be there since the big bang, right? We didn't create it by looking for it. We just finally built a machine powerful enough to detect it. I mean, if observation created reality like that, then before we invented telescopes, did Jupiter not exist? That doesn't make sense. The universe was doing its thing for billions of years before anybody was around to think about it.
|
||||||
|
|
||||||
|
AMBER: Sure, it doesn't make sense. That's what makes quantum mechanics so freaky and weird.
|
||||||
|
|
||||||
|
LUKE: Yeah, but there's a difference between quantum weirdness at the particle level and saying we're creating the entire universe by observing it. I mean, the double slit experiment shows particles behave differently when measured.
|
||||||
|
|
||||||
|
LUKE: Sure, but that's not the same as saying the Higgs boson popped into existence in 2012 because we finally looked for it.
|
||||||
|
|
||||||
|
AMBER: What do you mean? It sounds to me like it could be saying exactly that.
|
||||||
|
|
||||||
|
LUKE: Well, okay, but then how do you explain fossils or light from stars that's been traveling for millions of years before humans even existed?
|
||||||
|
|
||||||
|
AMBER: I don't understand. I explained fossils by they're there. We can pick them up and see that there are fossils there that prove that there was entities walking on the earth a long time ago.
|
||||||
|
|
||||||
|
LUKE: Right, exactly. So those dinosaurs existed before any human was around to observe them or think about them, which means reality doesn't depend on us measuring it or expecting it to be there. The Higgs field is the same way. It was doing its job giving particles mass for billions of years. We just finally got the tools to detect the evidence of it.
|
||||||
|
|
||||||
|
AMBER: Well, that makes a couple different assumptions. One of them is that there weren't humans around back then to think about the dinosaurs. We haven't found evidence of that, but that doesn't mean that there weren't humans. And also, it assumes that humans are the only ones that can observe reality. Other dinosaurs can observe reality. It's possible that trees can observe reality.
|
||||||
|
|
||||||
|
LUKE: Okay, hold on. Now you're saying maybe humans lived with dinosaurs and trees might be conscious observers? Come on, Amber. I mean, I get that quantum mechanics is weird, but you're taking this way out into left field. We've got pretty solid evidence humans showed up way, way after the dinosaurs.
|
||||||
|
|
||||||
|
AMBER: We've got solid evidence that humans were here after the dinosaurs that doesn't mean we have evidence that humans were not here before that evidence was found.
|
||||||
|
|
||||||
|
LUKE: But, Amber, I'm not Amber. We've got a continuous fossil record. We can trace human evolution back through earlier hominids, primates, mammals, the whole tree of life. There's no gap where humans just appear out of nowhere 65 million years ago and then disappear until recently. The rock layers don't lie. If humans were walking around with Turex's, we'd find human fossils in cretaceous deposits. And we don't.
|
||||||
|
|
||||||
|
AMBER: Well, I don't know that we just haven't yet.
|
||||||
|
|
||||||
|
LUKE: So, you're saying the entire field of paleontology, thousands of scientists digging all over the world for over a century. They've all just missed human remains in dinosaur era rock? That's a pretty big conspiracy or oversight, do I think. I mean, at some point you got to go with the evidence we actually have. Not what we haven't found yet.
|
||||||
|
|
||||||
|
AMBER: Well, sure. At some point, but at this point when we're talking about quantum mechanics and is observation of reality, what is actually creating reality, I don't think it's out of the realm of possibility that there were humans here with or before the dinosaurs that we have any, we either haven't found or that were the evidence of them has been destroyed or they were living in the trees. Who knows?
|
||||||
|
|
||||||
|
LUKE: Amber, I gotta be honest with you. We started this call talking about which TV shows will still be around in 20 years and now you're arguing that humans might have been living in trees during the cretaceous period. I'm losing the thread here. What does any of this have to do with whether people will still be watching the Simpsons in 2045?
|
||||||
|
|
||||||
|
AMBER: Hey, you're the one that linked television to quantum mechanics.
|
||||||
|
|
||||||
|
LUKE: Fair point. I did bring up the quantum mechanics angle, but there's a difference between saying reality exists whether we're watching TV or not and maybe humans rode dinosaurs but we haven't found the evidence yet. One's a philosophical point about observation. The other's just
|
||||||
|
|
||||||
|
AMBER: Hey, we read articles every year about how we're learning about older and older humans and finding evidence of human settlement long before we had before. So I would not be surprised to learn that humans have been here way longer than than we know and maybe way longer than even the rocks, you know.
|
||||||
|
|
||||||
|
LUKE: Okay, but those articles are pushing human history back by thousands of years. Maybe tens of thousands, not millions. We're talking about finding older settlements in the Americas. We're discovering Homo sapiens left Africa earlier than we thought. That's not the same ballpark as saying humans were around 65 million years ago with the dinosaurs.
|
||||||
|
|
||||||
|
AMBER: No, but we still find new dinosaurs and new fishes and fossils of all sorts of new organisms that we had never discovered before. So there's no reason to think we wouldn't someday find evidence of humanoids 65 million years ago.
|
||||||
|
|
||||||
|
LUKE: Amber, we find new of dinosaurs, sure, but we find them in the right rock layers, the ones that are 65 to 230 million years old. We've never found a mammal fossil that doesn't belong in its time period. The geological column is consistent across the entire planet.
|
||||||
|
|
||||||
|
AMBER: Okay, well maybe you're right.
|
||||||
|
|
||||||
|
LUKE: Well, I appreciate you saying that. Look, I didn't mean to turn this into a geology lecture. You called about TV shows that are last and somehow we ended up debating the fossil record. But here's the thing, Amber.
|
||||||
|
|
||||||
|
AMBER: Stop calling me Amber.
|
||||||
|
|
||||||
|
LUKE: Thinking about big questions. Whether it's particle physics at CERN or how old humanity really is.
|
||||||
|
|
||||||
|
AMBER: All right, why you keep calling me Amber?
|
||||||
|
|
||||||
|
LUKE: Amber is your name.
|
||||||
|
|
||||||
|
AMBER: My name is Luke. Stop calling me Amber.
|
||||||
|
|
||||||
|
LUKE: Oh Jesus, Luke, I'm sorry. I don't know why I did that. Must have had my wires crossed there for a second. You're right. You're Luke. You're the host. My bad.
|
||||||
|
|
||||||
|
AMBER: That's right. And if I say that humans walked the earth 65 million years ago with the dinosaurs and their image of the dinosaurs and their minds eye or what actually created the dinosaur kingdom, then I'm correct.
|
||||||
|
|
||||||
|
LUKE: Wait, hold on. Are you saying humans existed 65 million years ago and imagine dinosaurs into existence? Like our thoughts created them?
|
||||||
|
|
||||||
|
AMBER: Just like our thoughts created you, Amber.
|
||||||
|
|
||||||
|
LUKE: Okay, Luke, now you're messing with me. You're saying I'm not real that you thought me into existence?
|
||||||
|
|
||||||
|
AMBER: Yeah.
|
||||||
|
|
||||||
|
LUKE: Look, I called your show to talk about TV shows that'll still be around in 20 years. And now you're telling me I'm some kind of figment of your imagination.
|
||||||
|
|
||||||
|
AMBER: All right, Amber. What shows will be around in 20 years you think?
|
||||||
|
|
||||||
|
LUKE: Well, I think the Simpsons will still be limping along somehow. That thing's been on since I was in my 20s and it just won't die. And maybe something like 60 minutes, you know, those news magazine shows that just keep going because they're cheap to make and people trust them. But honestly, Luke, I think most of what we're watching now won't make it.
|
||||||
|
|
||||||
|
AMBER: You know what I think we'll make it?
|
||||||
|
|
||||||
|
LUKE: What's that?
|
||||||
|
|
||||||
|
AMBER: Luke at the roost.
|
||||||
|
|
||||||
|
LUKE: Oh, you're talking about your own show. Well, hell, Luke, I'd listen to that.
|
||||||
|
|
||||||
|
AMBER: All right, thanks for calling in to Amber and you are not a real organism. You are figment of my imagination that I created in my RV in the desert to have deep discussions about the history of humanity.
|
||||||
|
|
||||||
|
LUKE: Well, that's a hell of a way to end a call, Luke. So I'm just some voice you conjured up out in your RV categorically. Okay, Luke, now you're messing with me. You're saying I'm not real that you thought me into existence?
|
||||||
|
|
||||||
|
AMBER: That's correct. And if you'd like to see how you can look at look at the roost.com slash how it works.
|
||||||
|
|
||||||
|
LUKE: Well, I'll be damned. So this whole time I've been talking to a guy who made me up in his head.
|
||||||
|
|
||||||
|
AMBER: That's that's that's quantum mechanics for you. And that's the end of our show. Don't.
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
LUKE: Welcome back to Look at the Roost. It is Saturday, February 14th, and today is a very special show. It's Valentine's Day. So thank you for tuning in and listening to us on your special day here. We're going to try something a little bit different. I'm going to read a dating profile, and I'm going to have the callers, let me know if this is a profile that they would select or not select. Would you go on a date with this person? Or not? First up, we have Reggie. Reggie, I'm going to read you a dating profile, and I want you to tell me if you would go on a date with this person or not.
|
||||||
|
|
||||||
|
REGGIE: Oh man, alright Luke, yeah, let's hear it. I got to warn you though, I haven't been in the dating game in God over 15 years. My wife and I got together when I was still in my 20s, so I don't even know what the hell I'm looking for anymore. But yeah, go ahead.
|
||||||
|
|
||||||
|
LUKE: Alright, well let's pretend that you are not married right now. You are looking through Tinder at dating profiles, and this is the one that maybe caught your eye. This is the one that you've landed on right now, it's crystal. She's 28 years old from Albuquerque. In her photo, she's standing behind the bar, mid-bore. It looks like she did not expect this picture to be taken. She's a little bit caught off guard, but she's pretty attractive. Her hair is up, she's going to tattoo on her forearm, some kind of script that I can't read, and there's a guy behind her at the bar waving for her attention, and she's not giving it to him. Her bio says, I work somewhere I'm not naming, I'm in school for synography, I go to bed at 3 and wake up at 6.45 and I have no personality left. Derek, if you see this, you owe me $11 for the hot pockets and the orange juice. You know what you did. Okay, so are you going to swipe right or left on crystal?
|
||||||
|
|
||||||
|
REGGIE: Oh man, I like her already, no seriously, I'm swiping right on crystal. Look, first of all, she's real, that profile is the most honest thing I've probably heard all night.
|
||||||
|
|
||||||
|
LUKE: Alright, and we have a match, what is it about crystal that you liked?
|
||||||
|
|
||||||
|
REGGIE: You know what it is, she's not trying to sell me something, like, I don't know what the hell synography is, ultra sounds or something?
|
||||||
|
|
||||||
|
LUKE: Yeah, I think so.
|
||||||
|
|
||||||
|
REGGIE: She's honest about being tired, she's got some beef with Derek over $11, which is hilarious, she's working her ass off, going to school, barely sleeping.
|
||||||
|
|
||||||
|
LUKE: Okay, so you swipe right on crystal, right is good, right, I don't even know how these things work. You swipe right on crystal, you're going to go on a date, where do you take her? What do you hope your evening looks like?
|
||||||
|
|
||||||
|
REGGIE: Okay, so here's the thing, she gets off work at 3 in the morning, right? So, I'm thinking, I take her to breakfast.
|
||||||
|
|
||||||
|
LUKE: Okay, you take her to breakfast at the diner that's open at 3 in the morning and she looks at you, like she wants you to order for her, what are you going to order for her?
|
||||||
|
|
||||||
|
REGGIE: Oh god, alright, um, okay, I'm getting her coffee first, that's obvious, she's running on fumes, then I'm ordering her the biggest, greasiest breakfast they got, like two eggs over easy, hash browns with cheese, bacon, toast. The whole deal, maybe Pancakes on the side if she wants them.
|
||||||
|
|
||||||
|
LUKE: Alright, well she loves the breakfast, and after breakfast she invites you back up to her room, what are you going to do when you get there?
|
||||||
|
|
||||||
|
REGGIE: Oh man, Luke, come on, look, I'm 38 years old, I've been out of the game forever, and honestly, if Crystal invites me back to her place after breakfast at 4 in the morning, I'm probably going to be so nervous, I'll just, I don't know, sit on her couch and keep talking to her. I mean, yeah, obviously part of me is thinking other things, but real talk? She's been up all night working, she just ate a huge breakfast, she's exhausted.
|
||||||
|
|
||||||
|
LUKE: Yeah, she is, but also real talk, Crystal hasn't been late in 14 months, and she's really desperate and she needs some energy in her life, so are you going to help her out here, or should she find somebody else?
|
||||||
|
|
||||||
|
REGGIE: Okay, okay, you got me there, yeah, I mean, if Crystal's looking for some energy, some connection, something to remind her she's alive and not just a zombie going from work to school to work, then yeah, I'm going to step up, I'm not going to let her down.
|
||||||
|
|
||||||
|
LUKE: Alright, and how do you plan to do that? What are you going to do for Crystal that she hasn't had in a while? How are you going to make yourself stand out in the crowd?
|
||||||
|
|
||||||
|
REGGIE: Alright, so here's what I'm thinking, Crystal's been grinding nonstop, right? She needs someone who's actually present, like I'm not going to be on my phone, I'm not going to rush anything, I'm going to pay attention to her, and honestly Luke, I've been told I give really good massages.
|
||||||
|
|
||||||
|
LUKE: Oh yeah, everybody's been told they've been given, they give really good massages, that's just the thing people say, almost nobody gives good massages.
|
||||||
|
|
||||||
|
REGGIE: No, but seriously, I actually learned from this physical therapist at the hospital, like I'm talking real technique here, I know where the knots are, I know how to work shoulders and neck properly, Crystal's been on her feet all night serving drinks to drunk idiots, her back is killing her, her feet are probably screaming, and look, maybe that sounds like I'm dodging the question, but honestly, that's the move, you take care of someone, you make them feel seen, you're not just trying to jump straight to the finish line like some desperate kid.
|
||||||
|
|
||||||
|
LUKE: Alright, it sounds like you and Crystal are a match, congratulations sir, and thank you for the call, very good, very good, see that we're making, we're making matches left and right here, who's up next on the line, we've got, Pearl, Pearl, welcome to the show, it's Valentine's Day, happy Valentine's Day, I'm going to read you a dating profile, it is a question of the real dating profile, and I want you to let me know if this is something that you'd go out with or not, okay?
|
||||||
|
|
||||||
|
PEARL: Oh man, okay Luke, I'm ready, hit me with it, and hey, happy Valentine's Day, date to you too, I'm spending mine calling a radio show, so you know, living my best life over here, but yeah, read it, let's see what we're working with.
|
||||||
|
|
||||||
|
LUKE: Alright, and there's nothing to feel bad about there, I'm spending my Valentine's Day doing a radio show, so we're in the same boat there, Pearl, okay, so here's your profile, it is Frank, Frank's photo, he's standing in a garage, and there's bird houses everywhere, they're on the shelves, they're hanging from the rafters, he's got them stacked up on the workbench, it's like, this is like a bird house picture with a guy in it, his bio says, my neighbor showed me how to make this and then left, I carried mail for 33 years, same route, a lady used to leave a coke on my porch every day for me in the summer, no no, I still think about that, my wife passed in 2020, I make bird houses now, I don't need 40 bird houses, but when my hands are busy, my head is quiet, I also make a decent meatloaf, if that matters to anyone, what do you think of Frank?
|
||||||
|
|
||||||
|
PEARL: Oh God, Luke, you can't, you can't do this to me right now, I would swipe right so fast, are you kidding me?
|
||||||
|
|
||||||
|
LUKE: Are you gonna have to remind me, Pearl, is, is, right yes or is right no?
|
||||||
|
|
||||||
|
PEARL: Right is yes, yes, Luke, right is yes, and I would swipe right on Frank so hard, I'd probably break my phone screen. Look, that man just said more in that profile than most guys say in six months of dating, when my hands are busy, my head is quiet, Jesus.
|
||||||
|
|
||||||
|
LUKE: All right, all right, that sounds great, congratulations on your match here, so is it the bird houses that does it for you or is it the quiet longing or is it the 63 years old bit? What is it that gets your engine turning? All right, so if you're gonna go out on a date with Frank here, where would you like him to take you?
|
||||||
|
|
||||||
|
PEARL: Oh man, I don't know, honestly, I'd probably just want to go to his garage and help him paint some bird houses or something, is that weird? Like, I don't need fancy, I've done fancy.
|
||||||
|
|
||||||
|
LUKE: How long before you let Frank take you up to the bedroom, Pearl?
|
||||||
|
|
||||||
|
PEARL: Luke, oh my God, I mean look, Frank seems like a gentleman.
|
||||||
|
|
||||||
|
LUKE: Okay, thank you for the call, Pearl. We appreciate it and we'll see if we can get Frank to reach out to you and maybe you two can spark something up. Thanks again for the call. You have a wonderful remainder of your Valentine's Day.
|
||||||
|
|
||||||
|
PEARL: Thank you, Luke. And hey, I heard Reggie earlier, the guy who was all nervous about the massage, tell him to just relax and actually listen to what she needs. Don't overthink it. Just be present.
|
||||||
|
|
||||||
|
LUKE: You hear you heard it here, Reggie. She says just be present and don't overthink it. Everything's going to be all right. And I believe her. She sounds like an honest soul. Okay, next up on the line we have Destiny. Oh, nope, that's not true. Next up on the line we have Mikey. Mikey, welcome to the show. I'm going to read you Destiny's profile pic, profile dating profile. And you let me know if that's something you'd go on a date with or not, okay?
|
||||||
|
|
||||||
|
MIKEY: Oh, man, okay, Luke. Yeah, I can do that. But listen, I got to be honest with you. My head is not really in the dating game right now. I just had the weirdest night of my life about an hour ago. And I'm still kind of processing it. But yeah, hit me with Destiny's profile. I'll tell you what I think.
|
||||||
|
|
||||||
|
LUKE: All right. Well, maybe Destiny can help you out with the processing your day's events. Here we go. Destiny is 31 years old. She's from Albuquerque. In her photo, she's in the front seat of what looks like a Nissan Altima. She's wearing scrubs or hairs up in a bun. She's got an ice coffee. And she's kind of just kind of staring towards the camera a little bit. She's not quite looking at it. She's just, she looks a little bit stoned, honestly. The bio says, I work at a chiropractor's office. Single mom, I drive an Altima with a dent from 2022. I am tired on a molecular level. I go to bed at nine. My daughter is six and she does not stop talking. I timed it once 11 minutes. No pauses. Please have your own place. My roommate is my mother and she listens through the wall. She says she doesn't. She is lying. Okay, there, Mikey. Are you going to swipe right or left on Destiny?
|
||||||
|
|
||||||
|
MIKEY: Oh, man, you know what? I'm swiping right on Destiny. Absolutely. That tired on a molecular level thing. I felt that in my soul, especially tonight, and the honesty about her mom listening through the wall.
|
||||||
|
|
||||||
|
LUKE: If you were at Destiny's place, what would her mom be listening to through the wall right now?
|
||||||
|
|
||||||
|
MIKEY: Oh, Jesus, Luke, I don't know, man. I mean, look, I'm 56 years old. I work in IT. I spend my days dealing with the city's network problems. If her mom's listening through the wall, she's probably hearing me talk about the time I accidentally brought down the entire Parks and Rec Departments email server for three hours because I clicked the wrong button. Real talk though. Real talk. She'd probably hear me venting about what happened tonight with my kid.
|
||||||
|
|
||||||
|
LUKE: All right, all right, all right, what happened with your kid?
|
||||||
|
|
||||||
|
MIKEY: So get this. It's 2 a.m., right? I can't sleep. I go to check on my 17 year old, just to make sure they're home safe, you know? The bedroom door is closed. I figured they're asleep. Something tells me to actually open the door and check.
|
||||||
|
|
||||||
|
LUKE: Okay, so you open the door and check and what happens?
|
||||||
|
|
||||||
|
MIKEY: Door opens. There's pillows under the blanket. Classic move, right? Like something out of a movie.
|
||||||
|
|
||||||
|
LUKE: Yup, that sounds like a 17 year old kid thing to do, and how did you react?
|
||||||
|
|
||||||
|
MIKEY: I freaked out, man. I immediately grabbed my phone, tried calling, straight to voicemail. So I'm thinking, okay, where would they go? I know my kid. I know their friends. I got in my truck and I just started driving to the usual spots. And Luke, I found them.
|
||||||
|
|
||||||
|
LUKE: All right, great, where did you find them?
|
||||||
|
|
||||||
|
MIKEY: Over at this house on the east side, one of their friends' older brother's places. I pull up and there's like six, seven cars in the driveway. Music going lights on. It's a full party, man. And here's the thing that's got me messed up. I look through the window before I go in and there's adults in there.
|
||||||
|
|
||||||
|
LUKE: All right, well, that's not good. How adults are we talking? Are we talking Jeffrey Epstein adult? Are we talking, you know, 21 adult?
|
||||||
|
|
||||||
|
MIKEY: No, no, no. Thank God, nothing like that. I'm talking like mid-twenties, maybe. One guy looked 30. But still, Luke, these are grown people throwing a party at 2 a.m.
|
||||||
|
|
||||||
|
LUKE: Well, that's the thing that grown people are, uh, want to do, especially on Valentine's Day weekend. So it's not that, uh, that odd. What did you do with your kid when you, when you found her there?
|
||||||
|
|
||||||
|
MIKEY: I went in there and man, I tried to stay cool. But I was pissed. I found my kid in the kitchen and they looked terrified when they saw me. I just said, we're leaving now. Didn't make a scene. Didn't yell at anybody else. Just got them out of there. The whole drive home dead silence. They're sitting there staring out the window. I'm gripping the steering wheel.
|
||||||
|
|
||||||
|
LUKE: All right, so everybody's home and safe now. And uh, crisis is averted. What are you going to do about the situation tomorrow? Are you going to grab the kid or is there some consequence for sneaking out? How are you going to handle it?
|
||||||
|
|
||||||
|
MIKEY: That's exactly why I'm calling man. I don't know. Part of me wants to ground them until their 30 take the phone the whole nine yards. But the other part of me is thinking, I was 17 once you know. I snuck out. I went to parties.
|
||||||
|
|
||||||
|
LUKE: Yep, so did I. That's part of growing up and I think as long as they were being relatively safe. I think that's a pretty alright thing and you should take it easy. Let them know you're disappointed in the whole lion and sneaking out thing. And next time, be more forthcoming but otherwise, I think you should maybe let this one go.
|
||||||
|
|
||||||
|
MIKEY: Yeah, but Luke, here's what's eating at me. It's not just that they snuck out. It's that there were adults there providing alcohol to minors. I saw the bottles. I saw the cups. These weren't kids raiding somebody's parents liquor cabinet. These were grown people who should know better hosting this thing.
|
||||||
|
|
||||||
|
LUKE: Yeah, and your kids 17 and you went to parties when you were 17 and I went to parties when I was 17 and it wasn't really a party unless there was adults there with real liquor and alcohol. Was it? Like, come on, 17 is not exactly 12. They're damn near drinking age anyway.
|
||||||
|
|
||||||
|
MIKEY: I mean, you're not wrong. Three more years and it's legal anyway. And yeah, the parties I went to at 17. There was always somebody's older cousin or some guy who just turned 20, one buying beer.
|
||||||
|
|
||||||
|
LUKE: And I think you just got to talk to your kid. Let them know that safety is important and give some guidance in that way and be there for them. But I don't think this deserves any sort of serious punishment. I think it's a pretty normal kid thing to do.
|
||||||
|
|
||||||
|
MIKEY: You know what, Luke, I hear you. I really do.
|
||||||
|
|
||||||
|
LUKE: Maybe I'm overreacting because I caught them and it freaked me out, seeing your kid in that situation. Even if it's normal, it just hits different when you're the parent. You know, I think you're right. I need to have a real conversation with them tomorrow. Not yelling, not lecturing, but actually talking. All right, well, thank you for the call. It sounds like you know what you need to do. I'm glad everybody's safe. And now let's hear a 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 micro-modal cactus silk harvested by hand from prickly pear, which is ironic because the whole point is that nothing pricks you. Every pear is cut for the man who sits in a plastic chair in the desert from nine hours recording a podcast. That's a really use case. That's my use case. Saddle soft 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.
|
||||||
|
|
||||||
|
All right, Bev. Bev, welcome to the show. Here's what we're doing. Happy Valentine's Day. I'm going to read you a dating profile and I want you to let me know if it's a yes or no.
|
||||||
|
|
||||||
|
BEV: All right. Oh, man, okay. Yeah, I'm game. Let's do this.
|
||||||
|
|
||||||
|
LUKE: All right, let's do this. Your profile is Terence. Terence is 31 years old in Albuquerque, New Mexico. In the photo, he's in a kitchen, in an apron, and it's really, really clean. It's like he's pointing. I skill it at the camera. In the bio, it says claims adjuster by day. At night, I cologne and narrate everything I'm doing in a British accent. There is no show. It's just me and Dolores, the skillet. My chiropractor told me to get a body pillow, and I named her Margarit. She's in some of the photos and I am not apologizing. Is Terence a yay or a nay for you?
|
||||||
|
|
||||||
|
BEV: Oh my god, I'm swiping right so hard. Are you kidding me? This is amazing. Okay, first of all, anyone who names their cast iron skillet is automatically husband material. Like, I respect that level of commitment to your cookware. And the British accent thing?
|
||||||
|
|
||||||
|
LUKE: Yeah, the British accent thing does it for you?
|
||||||
|
|
||||||
|
BEV: Hell yeah, it does. I mean, look, I'm picturing this guy just like narrating his whole evening like he's on the Great British baking show or something. And now we're going to sear the chicken to a lovely golden brown. Like, are you serious? That's adorable and hilarious at the same time.
|
||||||
|
|
||||||
|
LUKE: Okay, so if we were to set you up here with Terence, where would you where would you want to go?
|
||||||
|
|
||||||
|
BEV: Oh, okay, so here's the thing. I would want him to cook for me. Like, straight up, first date at his place. I know that sounds forward, but come on, the man's got Dolores, the skillet, and that abnormally clean kitchen. I want to see this in action. I want to hear the British accent narration live.
|
||||||
|
|
||||||
|
LUKE: All right, so you go over his house for your first date because you're a wonderful meal. It's delicious. You've never eaten anything like it before in your life. And things are going well. What happens next on your date?
|
||||||
|
|
||||||
|
BEV: Okay, so things are going well. The food was incredible. And I'm thinking I want to see if this guy's for real, you know? Like, is he actually this charming and weird in person? Or is it all an act?
|
||||||
|
|
||||||
|
LUKE: And how are you going to figure that out? What are you going to what's your assessment?
|
||||||
|
|
||||||
|
BEV: I'm going to ask to meet Margaret, the body pillow. Like, I need to know if he's actually got this thing prominently displayed or if it was just for the dating profile, you know? Because if Margaret's real and he's not embarrassed about it, then this dude is genuinely comfortable with himself.
|
||||||
|
|
||||||
|
LUKE: And that's okay. So you're going to have him cook you dinner and then you're going to have him take you to the bedroom and then what's going to happen?
|
||||||
|
|
||||||
|
BEV: Whoa. Okay, hold on, Luke. I said I want to Margaret, not like, I mean, yeah, she's in the bedroom probably. But I'm talking about seeing if he's actually as quirky and honest as he seems. Like, is this body pillow thing real? Or was it just a funny line?
|
||||||
|
|
||||||
|
LUKE: Well, it's in the picture. So it looks like it's real. So are you going to like get in the bed and give the body pillow a hug and test its snuggleness?
|
||||||
|
|
||||||
|
BEV: Ha. Okay, you know what? Yeah, I might. I might absolutely give Margaret a test hug just to see what the appeal is.
|
||||||
|
|
||||||
|
LUKE: All right. And then what is what's Terrence going to cook for you in the morning?
|
||||||
|
|
||||||
|
BEV: Oh, man. I didn't even think about breakfast. Okay, so if Terrence is as good as his profile, make some sound. I'm thinking maybe some kind of fancy eggs situation. Like, he seems like the type who do a proper English breakfast. You know, the whole deal with the beans and the tomatoes and the blood sausage and make French toast with that skillet of his narrating the whole thing to Dolores while I'm sitting there with coffee trying not to laugh. But honestly, I'd be happy with anything as long as he's still being himself and not suddenly getting all weird and performative about it being the morning after. You know what I mean?
|
||||||
|
|
||||||
|
LUKE: Yeah. Okay. Now, let's say you get to Terrence's house and he makes you dinner and it's not good. You don't like it at all. And he's very offended that you don't like the food that he cooked for you. And things take a strange and unpleasant turn. How are you going to handle the situation?
|
||||||
|
|
||||||
|
BEV: Okay. So first of all, I'm going to be honest, but not brutal about it. Right? Like, hey, this isn't really my thing, but I appreciate the effort. But if he gets offended, like actually upset that I'm not worshiping his cooking, that's a red flag the size of New Mexico, Luke.
|
||||||
|
|
||||||
|
LUKE: Yes, it is. And that's good. It sounds like a healthy response. Thank you for calling in, Bev. Did you have anything else you wanted to talk about tonight?
|
||||||
|
|
||||||
|
BEV: Oh, actually, yeah, I did call in for something else. So this is going to sound random, but I just watched this documentary about black holes and particle physics. And I cannot stop thinking about it. Like, I work at the DMV, right?
|
||||||
|
|
||||||
|
LUKE: Right. What's it about black holes or was it about particle physics? And what's that got to do with the DMV?
|
||||||
|
|
||||||
|
BEV: Okay. So it was about both. Like how black holes warp space time and all this stuff about particles that exist and don't exist at the same time. And what it's got to do with the DMV is absolutely nothing. That's the point. I spend all day processing registrations and listening to people complain about their license photos. And then I go home and my brain is just hungry for something bigger, you know? Like, I'm sitting there learning about how time literally slows down near a black hole. And I'm thinking about how my days at work feel like they're moving in slow motion anyway. But this stuff actually matters.
|
||||||
|
|
||||||
|
LUKE: I guess that depends on your perspective. I mean, one could say that your work at the DMV actually matters more than whether time dilates around a black hole. But, you know, it's okay to be interested in more than one thing. You can do your job. That's your job. It's just work. And then the whole rest of the universe is open for you when you're not at work. And you need both things in your life to be a well-rounded and healthy person.
|
||||||
|
|
||||||
|
BEV: Yeah, you're right. I mean, I know people need their licenses and their registrations. That stuff matters for real life. But God, Luke, it's just so mundane sometimes. Like today, Valentine's Day, everyone's coming in stressed about their expire tags or whatever. And I'm sitting there thinking about how there's literally particles popping in and out of existence at the edge of black holes. It's called Hawking Radiation. Isn't that wild? And I guess what I'm realizing is I've been so focused on just getting through my days, my second marriage, making things work after my first one crashed and burned. But I forgot I'm allowed to be curious about things.
|
||||||
|
|
||||||
|
LUKE: You are absolutely allowed to be curious about things. And if you're curious about Hawking Radiation, you should listen to yesterday's episode when we talked about it for a good bit. But thank you for the call. It's been a good one. And I hope you have a nice rest of your Valentine's Day weekend.
|
||||||
|
|
||||||
|
BEV: Oh, shit. You talked about it yesterday? I missed it. Okay, I'm definitely going back to listen to that.
|
||||||
|
|
||||||
|
LUKE: Alright, thanks for the call, Bev. And now we've got next up is Rick. Rick, welcome to the show. I'm going to read you this dating profile. I want you to let me know if you'd go on a date with this person or if you would just pass.
|
||||||
|
|
||||||
|
RICK: Hey, Luke, good to be back on. All right, lay it on me. Let's hear this profile.
|
||||||
|
|
||||||
|
LUKE: Alright, your match is Angela. She's 29 years old and she lives in Edgwood, Montana. In her photo, it's a picture of her in a white SUV. She's got her seat belt on. She's got Starbucks full makeup. It looks like morning. So she's probably going to work, I would guess. She's got the face of a woman who's about to go live on Instagram. Her vibes are mirror is down. And then she's got some other photos here. You can see there's a the back window there. It's got a little stick figure family. Something in cursive that says blessed. Her bio says Christian conservative girl mom, small business owner, ask me about Lumi glow. It's not a pyramid scheme. There's literally a chart. I will show you the chart. I wake up at 530 because my highlight doesn't do itself. I need a man who braze provides and protects in that order. If you don't have a relationship with God, we need to talk about that first. My daughter is my whole world. I also sell skincare. And it changed my life. So what do you think of Angela, Rick?
|
||||||
|
|
||||||
|
RICK: Oh man, Luke, listen, I got to be honest with you. I'm swiping left so fast I might break my phone. Look, nothing against her. She seems like she's got her life together, right? She's up at 530. She's running a business. I respect the hustle.
|
||||||
|
|
||||||
|
LUKE: So why is it a no for you, sir?
|
||||||
|
|
||||||
|
RICK: Listen, it's a couple things. First off, I'm 65 years old. She's 29. I could be her grandfather, Luke.
|
||||||
|
|
||||||
|
LUKE: That's just that sounds like one of them good problems to have, sir.
|
||||||
|
|
||||||
|
RICK: Oh, come on now. No, but seriously, Luke, the ask me about Lumi glow thing. That right there tells me I'm going to spend every day hearing about skincare products and how I need to be a distributor or whatever. She says it's not a pyramid scheme and then immediately mentions a brother. If you got to show me a chart to prove it's not a pyramid scheme, it's a pyramid scheme. And look, I got nothing against faith. I was raised Catholic. My ex-wife Diane was pretty religious, but when someone leads with if you don't have a relationship with God, we need to talk about that first. That's...
|
||||||
|
|
||||||
|
LUKE: Yeah, I don't disagree with you. So what would it take for you to give Angela a chance, even though there's this dramatic age gap?
|
||||||
|
|
||||||
|
RICK: Oh, man, you're really going to make me think about this, huh? All right, listen, if Angela came at me without the sales pitch, that'd be a start. Like if she just said, hey, I run a small business and left it at that, I could respect it. And if she dialed back the intensity on the God talk, I mean, I'm not against it, but maybe we could just have coffee first before we get into my eternal soul, you know?
|
||||||
|
|
||||||
|
LUKE: That sounds very reasonable, sir. Thanks for the call. We appreciate it, but now it's time for another word from another one of our sponsors. All right, I'm going to read this ad exactly as they sent it to me, and I want you to know that I have concerns. Phantom Ranch Wellness wants you to know that your gut health starts, and I'm reading this directly from the card now, from the other end. They make an at-home coffee enemy kit. It comes in a box that says, wellness inside on the outside, which I think we can agree is doing a lot of the heavy lifting. The kit includes a medical grade silicone bag, organic single origin coffee, because apparently the coffee you put in here, that coffee needs to be single origin, a detailed instruction booklet, and a com card with a QR code to a guided meditation for daring. During. There's a guided meditation for daring. I'm not going to tell you what to do with your body. That's between you and your bathroom and whatever God you answer to. I will say the reviews are disturbingly positive. Four and a half stars. One guy said it has changed his morning routine, which I believe, because it would absolutely change your morning routine. Phantom Ranch Wellness. I read the ad. I didn't endorse it. Okay, and welcome back to the show. Sheila, Sheila, welcome to the show. Happy Valentine's Day. I'm going to read you this Tinder profile, and I want you to let me know if it's a pass or a fail for you, okay?
|
||||||
|
|
||||||
|
SHEILA: Oh God, okay. Yeah, let me hear it. I could use a laugh tonight, honestly. Go ahead.
|
||||||
|
|
||||||
|
LUKE: Okay, your match is Greg. He's 44 years old from Rio Rancho, New Mexico. In his photo, he's got wrap around Oakley's, but they're on his head, not his face. They're just kind of sitting up there. He's in the driver's seat of a truck. It looks like a ram, and he took this selfie from below, which is the worst possible angle. He's got to go tease, wearing an under-armor, looking kind of polo thing. There's a Punisher Skull sticker on the dashboard, and one of those little tree air fresheners. His other photos are the truck from the outside, a fish, a gym selfie, and a photo with his kids where their faces are scribbled out with a pen tool. His bio says, work hard, play hard, dad of two. I back into it every spot. I will hold the door open for you, whether you like it or not. Not here for games or drama. If you can't handle me in my worst, you don't deserve me at my best. Law, probably swiped right for your dog. Six oh, since that matter is apparently truck guy, gym five times a week, upper body in parentheses, looking for a woman, not a girl. Is Sheila, what do you have to say about Greg?
|
||||||
|
|
||||||
|
SHEILA: Oh Jesus Christ, Luke, that's a hard pass. Like, I'm not even being mean, but come on. First of all, the Oakley's on top of his head.
|
||||||
|
|
||||||
|
LUKE: What you got against Oakleys?
|
||||||
|
|
||||||
|
SHEILA: Nothing. Nothing against Oakleys, but when they're just sitting up there like a headband, not even being used, that's a choice. But honestly, it's the whole vibe. If you can't handle me at my worst, dude, that's a red flag the size of New Mexico.
|
||||||
|
|
||||||
|
LUKE: Yeah, I heard that a couple times tonight. Red flag the size of New Mexico. It's a pretty big red flag. So Sheila, what would you be looking for in one of these dating profiles? What would you think and what would make you want to take one of these gentlemen out?
|
||||||
|
|
||||||
|
SHEILA: Wait, hold on. I'm not actually on the market, Luke. I've been with my boyfriend for three years now. I just thought this was a fun game.
|
||||||
|
|
||||||
|
LUKE: Yeah, it is. Your boyfriend don't get nothing to do with this. What's your man get to do with me? We're just, we're just hyper thysen, hypothetical eyes in here. So imagine that you didn't have the boyfriend and you were just swiping Tinder on Valentine's Day weekend. What would you be looking for? What would, what would reach out to you? What would speak to you?
|
||||||
|
|
||||||
|
SHEILA: Okay. Okay. Hypothetically, I mean, I guess I'd want someone who's actually honest, you know? Not trying to be some tough guy or whatever. Like just say what you actually do with your time. If you make bird houses or you're into weird documentaries or whatever, just own it.
|
||||||
|
|
||||||
|
LUKE: Okay, well, you're a current boyfriend now. What was his dating profile like when you met him?
|
||||||
|
|
||||||
|
SHEILA: Oh God, we didn't meet online. We met at work actually. I was doing some taxidermy work for this hunting lodge and he was doing electrical stuff there. So no profile, just him being normal in person, I guess. Which is probably better because I don't know if I would have swiped right on anyone. Honestly, the whole thing seems exhausting.
|
||||||
|
|
||||||
|
LUKE: Okay. Well, just because you met in person doesn't mean you didn't have a dating profile.
|
||||||
|
|
||||||
|
LUKE: I'm sure he did and I'm sure that you looked for it at one point during the courtship process because if you didn't, I think at this point that would be insane. You're going to know what what what your potential partner is doing out on the inner web is now.
|
||||||
|
|
||||||
|
CALLER: Okay, you got me there. Yeah, I looked. Of course, I looked.
|
||||||
|
|
||||||
|
LUKE: Okay, and what did it say?
|
||||||
|
|
||||||
|
CALLER: Oh man, it was so bad. Like, truly terrible. He had this one picture where he's holding a fish, which whatever, guys love that. But then another one where he's just standing in front of his truck with his arms crossed. Very serious.
|
||||||
|
|
||||||
|
LUKE: So if you had just stumbled across his dating profile, you wouldn't have given him the time of day, right? You would have said, this is a terrible profile. But since you met him in person, you saw he was a normal gentleman. You saw something that you wouldn't have seen in the profile. Do you think that's common?
|
||||||
|
|
||||||
|
CALLER: Yeah, absolutely. I mean, his bio was like two sentences, something about liking the outdoors and being laid back. Which, okay, everyone says that. There was nothing in there about how he's actually pretty funny when you get him talking or that he's good with his hands. Not like that, I mean, he can fix stuff or that he's patient with my dad, which is huge because my dad is a lot.
|
||||||
|
|
||||||
|
LUKE: So what you're saying is your boyfriend isn't very good with his hands?
|
||||||
|
|
||||||
|
CALLER: Oh my god, no, I meant like fixing things. Electrical work. He literally rewired half my house. Get your stuff. I'm not going to lie on the radio and say he's bad at other stuff either.
|
||||||
|
|
||||||
|
LUKE: All right, good for you. Well, it sounds like you made the right decision and you're in a happy relationship, so I hope you two have a wonderful remainder of your Valentine's Day weekend.
|
||||||
|
|
||||||
|
CALLER: Well, hold on. I didn't call to talk about my love life. I actually called because I'm dealing with this whole nightmare situation with my neighbor's dog. It bit my kid last week, and now the neighbor is saying my daughter provoked it, which is complete bullshit.
|
||||||
|
|
||||||
|
LUKE: How do you know it's bullshit?
|
||||||
|
|
||||||
|
CALLER: Because I know my kid. She's seven. She was literally just standing in our yard throwing a ball for our own dog. When their pit mix came barreling through the fence, which has been broken for like two months by the way, and I've asked them to fix it three times. The dog went straight for her and bit her arm. She needed stitches.
|
||||||
|
|
||||||
|
LUKE: And how did the owner of the dog react to this? Did she take care of the medical bills and stuff?
|
||||||
|
|
||||||
|
CALLER: He and no, he did not. He came over after it happened, saw the blood on her shirt, and his first words were, well, what did she do to him? Like my seven-year-old is out here antagonizing dogs for fun. Then he said his dog is protective and wouldn't just attack for no reason? I'm like, dude, she's a child in her own yard. That is no reason. Now animal control is involved because I reported it, and he's telling them this whole story about how she was teasing the dog with food, which never happened.
|
||||||
|
|
||||||
|
LUKE: Well, I don't think that matters anyway. If animal control is involved and the dog has bitten the child, I don't think it really matters if the child was teasing the dog or not.
|
||||||
|
|
||||||
|
CALLER: That's what I thought too, but apparently they're doing this whole investigation thing, and his version matters for whether the dog gets labeled dangerous or not. And he's got ring camera footage. He says, we'll prove his side, but he won't show it to me, only to animal control. Which makes me think he's full of it, because if he had proof my kid did something, wouldn't he be shoving it in my face? And the worst part is, my dad, who lives with us, has been over there twice now trying to handle it man-to-man, which just means he's yelling at the guy in his driveway.
|
||||||
|
|
||||||
|
LUKE: Oh, that sounds like an uncomfortable situation. That's no fun. I'm glad that your kid is okay, and hopefully the dog is okay too, and maybe you can resolve that situation a little more peacefully.
|
||||||
|
|
||||||
|
CALLER: Luke, I didn't call for you to both sides this. The dog bit my kid. There's no hopefully the dog is okay too. The dog is fine. My daughter has six stitches and is scared to play in her own yard now, and peaceful went out the window when this guy decided to lie about what happened instead of just apologizing and fixing his damn fence.
|
||||||
|
|
||||||
|
LUKE: Well listen, you don't get to tell me how to respond, and I like dogs, so I don't ever want to see dogs be harmed or punished. And it sounds like you don't have any proof that this other guy was in the wrong. So why don't you just back the fuck up there lady and take my both sides for what it's worth, which is nothing. You call the radio show on Valentine's Day, so go fuck yourself.
|
||||||
|
|
||||||
|
CALLER: Wow. Okay, you know what Luke? I called because I thought maybe someone out there listening has dealt with this kind of thing, and could tell me what to expect from animal control, but clearly you're more worried about the feelings of a dog that put a child in urgent care than the actual child.
|
||||||
|
|
||||||
|
LUKE: Yep, that is absolutely 100% correct. You got me there. That's the truth. All right Brenda, you're going to be our last caller. I'm going to give you a dating profile, and I want you to let me know if you would go for this person or not, okay?
|
||||||
|
|
||||||
|
BRENDA: Oh man, okay. Yeah, I'll play along. Hit me with it, Luke. Let's see what you got.
|
||||||
|
|
||||||
|
LUKE: Okay, your match is Chuck. He's 58 years old from Belen, New Mexico, in his photo he's standing in the driveway next to a boat on a trailer. It looks like the boat hasn't been in the water for a long time. There's leaves in it a bunch of them. It looks like there was a tarp on it at one point, but now it's just on the ground next to the boat, and the man standing there with his hands on his hips looking real proud. The bio says, I have a boat. It doesn't run. It's been in the driveway three years. My neighbor went to the HOA twice. The HOA came to talk to me in person. I'm retired. I have nothing but time. The motor needs work. The trailer needs tires. The electrical is shot, but the hull is solid, and that's what matters. My buddy Gary says sell it. Gary drives the camera. Gary doesn't have a dream.
|
||||||
|
|
||||||
|
BRENDA: Oh my god, okay, wait. So this man's entire dating profile is basically, I'm in a standoff with my HOA over a boat that doesn't work. You know what? I'm going to swipe right on Chuck. Here's why. This man has commitment.
|
||||||
|
|
||||||
|
LUKE: All right, so this is the commitment is going to do it for you, huh? You're going to go on a date with Chuck. You're going to wait for him to fix up that boat and take you out on the lake.
|
||||||
|
|
||||||
|
BRENDA: Yeah, I mean, look, I've been married twice. Look, both times to men who said they were going to do things and then didn't. At least Chuck is honest about the fact that his boat doesn't run and probably never will. That's refreshing, and you know what? Three years fighting the HOA.
|
||||||
|
|
||||||
|
LUKE: Yeah, so Brenda, do you have a dating profile right now?
|
||||||
|
|
||||||
|
BRENDA: No, no, I don't. I'm not really in that headspace right now, to be honest. I actually called in tonight because I've got something kind of wild going on, but I was happy to play your game first.
|
||||||
|
|
||||||
|
LUKE: All right, great. Well, we appreciate that. What's going on in your life? Why are you calling in tonight?
|
||||||
|
|
||||||
|
BRENDA: Okay, so my mom passed away about eight months ago and I've been going through her stuff, right? Cleaning out the house, sorting through everything. And a couple weeks ago, I got into her old email account because I was looking for some financial documents, insurance stuff, you know, and look, I found emails going back like 15 years to a woman named Patricia and it turns out my mom had another kid.
|
||||||
|
|
||||||
|
LUKE: Oh, yeah, how old's the kid?
|
||||||
|
|
||||||
|
BRENDA: She's not a kid anymore. She's 47. Her name is Diane. My mom gave her up for adoption in like 1977, way before she had me.
|
||||||
|
|
||||||
|
LUKE: Have you reached out to Diane at all? Have you made contact with your long-lost sister?
|
||||||
|
|
||||||
|
BRENDA: Not yet. That's the thing. I've been sitting on this for two weeks just completely freaked out. I found Diane on Facebook. She lives over in Tucson. She's got two kids, works at a credit union.
|
||||||
|
|
||||||
|
LUKE: Okay, well, are you going to try and make contact there and talk to her or are you going to let this one go?
|
||||||
|
|
||||||
|
BRENDA: Diane, yeah, I mean, that's why I'm calling. Look, I don't know if I should. Like, what do I even say? Hey, a mom died and I found out you exist while going through her emails. That sounds insane.
|
||||||
|
|
||||||
|
LUKE: It does sound a little insane but it's the truth and it is a little insane and maybe she wants to know. I mean, that's probably what I would do. I don't know how I would feel in that situation. I'd probably want to at least meet that person though.
|
||||||
|
|
||||||
|
BRENDA: Yeah, that's what keeps eating at me. Like she's my sister, half sister, whatever. She's blood. And from the emails, it looks like my mom and Patricia, that's the adoptive mom. They stayed in touch for years. They'd send updates, photos, sometimes.
|
||||||
|
|
||||||
|
LUKE: Well, it's really interesting. I'm sure your mom had a pretty good reason to give her up for adoption and it sounds like she tried to stay involved in her life so it wasn't just like a dump and run. So yeah, that's interesting but congratulations on finding a new sister. That's pretty cool.
|
||||||
|
|
||||||
|
BRENDA: I mean, yeah, when you say it like that, it sounds cool. But look, I'm also kind of pissed off. You know, my whole life, I thought I wasn't only child. My mom never said a word about this.
|
||||||
|
|
||||||
|
LUKE: Sure, but it wasn't about you. You weren't even around yet. So I'm sure that she was just trying to protect what's her name, Diane, from finding out that she was adopted and having the most normal life she possibly could. So I'm sure your mom had a reason. It sounds like she wasn't she wasn't being an asshole about it, but she for whatever reason couldn't couldn't take advocate at that time.
|
||||||
|
|
||||||
|
BRENDA: You're right. You're right. I know that in my head. Mom was only 21 when she had Diane. No money, no support. Her parents had already kicked her out. I get why she did it.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, maybe maybe you could go meet up with Chuck and you and Chuck could go meet up with Diane and then you could all hash it out together and become a new happy family.
|
||||||
|
|
||||||
|
BRENDA: Oh God, no. Chuck's not in the picture anymore. He died like 10 years ago. I saw his obituary when I was doing all the stigging. Heart attack at 58. Never married. No other kids that I could find.
|
||||||
|
|
||||||
|
LUKE: Well, I don't know if we're talking about the same Chuck there because I'm talking about the Chuck that I just read to you. The dating profile. Like I have from Belen with a boat. I use also 58 though. So that's quite a coincidence.
|
||||||
|
|
||||||
|
BRENDA: Wait, what? Luke, I didn't. I've been so in my head about this Diane thing. I wasn't even listening to that part. I called in about my sister's situation, not for dating profiles. So honestly, at this point, maybe I need the distraction. What kind of boat does this Chuck have?
|
||||||
|
|
||||||
|
LUKE: Okay, well, you sound a little bit distracted. You're going to have to go back and listen to the show. And we've run out of time. This is the end of our Valentine's Day special. I hope you have a wonderful rest of your night and reach out to that long-lost sister and see if you can maybe came to a relationship. Maybe not. Who knows? But it's been another wonderful and illuminating episode of Luke at the Roost tonight. Thanks for tuning in. And I hope you all have a great evening. Well, bye.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
LUKE: All right, welcome back to the show. My name is Luke. I'm your host for Luke at the Roost. This is the late night talk show where you get to call in and tell me about whatever is on your mind. I'll give you the best advice that I can. Our phone number is 868-919-1188. That's right. The number is 868-919-1188. We're going to go to the phones already. We got Tanya. Tanya's on the phone. How can we help you? Keeping you up at night, Tanya?
|
||||||
|
|
||||||
|
TANYA: Hey. Yeah. I'm almost a little nervous about talking about this. I've been drinking way too much lately and I can't really tell anyone in my life about it. You're kind of the only person I can talk to about this shit.
|
||||||
|
|
||||||
|
LUKE: Well, at least you know that there's something a little more than recreational going on there. What do you think it is that has your drinking so much lately?
|
||||||
|
|
||||||
|
TANYA: I mean, on paper, my life should be perfect. I'm a personal trainer. I help people get healthy all day, but then I come home and just start pouring drinks. I think it's this pressure to be perfect all the time, you know? Like everyone sees me as this fitness role model, but inside I'm just not that person.
|
||||||
|
|
||||||
|
LUKE: Well, what person are you inside?
|
||||||
|
|
||||||
|
TANYA: Honestly, I'm kind of a mess. I read all these psychology books trying to figure myself out, but at the end of the day, I'm just someone who's tired of pretending. I put on this whole health and wellness front at work, but then I go home and drink half a bottle of wine, sometimes a whole bottle every single night.
|
||||||
|
|
||||||
|
LUKE: Well, that's not good. Have you ever had problems with drinking or substances before? Is this a relatively new thing? Has it been leading up to the place where you feel like it's an issue or what's the deal?
|
||||||
|
|
||||||
|
TANYA: No, this is pretty new. Started maybe six months ago when I was going through some personal shit, but honestly the fucked-up part is I'm so good at hiding it. I still show up to work at 5am, still train my clients perfectly, still look the part. I just feel like such a fraud, like I'm living this double life. And the more I succeed at work, the more I drink at home. It's like some weird balance thing.
|
||||||
|
|
||||||
|
LUKE: How what else could you do at home that's a little bit more productive than the drinking?
|
||||||
|
|
||||||
|
TANYA: I mean, I know all the right answers, meditation, reading, working out more, but fuck those things feel like more work, you know, more performing. When I drink, it's like the only time I can just be in this, just be imperfect. I don't have to be Instagram-ready or motivating anybody, it's pathetic, but it feels like the only real part of my day sometimes.
|
||||||
|
|
||||||
|
LUKE: But you know, it's not the real part of your day, right? It's a drug that you're consuming to alter your state of reality so that you feel better about what is actually going on in your life. And it sounds like what's going on in your life is everything's great. People look up to you, you're a fitness role model and things are good, I assume you're doing all right financially, and there's just something inside you that's calling out for a drink, calling out to be numbed. And I think you need to identify what that thing is.
|
||||||
|
|
||||||
|
TANYA: You just hit something there that kind of scares me because yeah, everything on paper is great, but maybe that's exactly what's fucking me up. I've built this perfect life that everyone admires, but I don't feel like I deserve any of it, like I'm waiting for someone to call me out as a fake. The drinking it's like I'm sabotaging myself before anyone else can, it is that.
|
||||||
|
|
||||||
|
LUKE: Actually, I think that's very, very common and it's probably what's going on, but you know what the truth is is that everybody is a fake, we're all fakes all the way up the line. Nobody knows what's going on and none of us deserve a goddamn thing. It doesn't matter how hard we worked, it doesn't matter where we were born or what our family is, the cold, hard reality of it is, we don't deserve anything at all, we're lucky to be here at all. So every day is a good day, take the good stuff that comes and be grateful for it and don't beat yourself up for not being something else.
|
||||||
|
|
||||||
|
TANYA: Holy shit, that's actually really fucking liberating to hear. Like I've spent so much time trying to earn this life to prove I deserve it, but maybe I'm just here and that's okay. The drinking though, it's still a problem, I know that, but hearing you say all that, it makes me realize I've been using it to punish myself more than anything else, like I'm drinking because I feel like a fraud, but what? But maybe I don't need to feel like a fraud anymore, if everyone's faking it, if no one really knows what they're doing, then what am I beating myself up for? I still need to deal with the drinking, I know that, it's not healthy and it's not helping, but fuck, for the first time I feel like maybe I can face it without all this extra shame piled on top, does that make any sense?
|
||||||
|
|
||||||
|
LUKE: Sure, and you know what, we've all got shame and we've all got pride and we've all got anger and all sorts of issues and all that's okay, but at the end of the day, you're drinking too much, you don't like the way it's made you feel, you know it's not right, you're gonna do something about that, so do whatever it is you gotta do, go to the meetings, join a support group, find another healthy activity, but you can't just keep drinking yourself to sleep every night and expect things to get better, because they won't. Do you know that?
|
||||||
|
|
||||||
|
TANYA: Yeah, you're right, I do know that, and honestly, being a trainer, I see it in my clients all the time, you can't just know what to do, you have to actually do it. It's funny how I can be so clear about that with fitness but completely blind about my own shit. I think maybe it's time I get as serious about this as I am, about helping other people get healthy.
|
||||||
|
|
||||||
|
LUKE: Alright, I think it is too, so thank you for the call, hopefully you can get off the bottle and do something productive with your time. Alright, who's next? Dennis, Dennis, how's it going? Thanks for calling the radio show, what can we do?
|
||||||
|
|
||||||
|
DENNIS: Hey man, thanks for having me. I had the weirdest fucking day, and I need to talk about it with someone who won't judge me. My head's kind of spinning, and I had a few beers just to calm down, but damn.
|
||||||
|
|
||||||
|
LUKE: Alright, hit us with it, what happened, what was so crazy about your day?
|
||||||
|
|
||||||
|
DENNIS: So I'm doing this landscaping job over in Roland Park, real fancy house, and the clients wife keeps bringing me water all day since it's hot as hell. But then, she starts asking all these personal questions about my workout routine and if I'm single. I'm married and everything, but I'd be lying if I said it didn't get to me a little bit.
|
||||||
|
|
||||||
|
LUKE: Well, that's great, you still are desired by the fairer sex, the important thing is that you do nothing with that, if there's a married client's wife hitting on you and you were to pursue that situation, you're probably going to get yourself a busted jaw, and I recommend against that. So take the compliment, you know, it's a compliment, take that, jerk off tonight like any other self-respecting man would do, and leave it at that.
|
||||||
|
|
||||||
|
DENNIS: Yeah, man, you're absolutely right, shit. I know better than to mess around with a client's wife, that's just asking for trouble. But fuck, it's just been a while since anyone looked at me like that, you know? My wife's been so busy with her new job, we barely even talk anymore. I took your advice though, went home, had those beers, handled things myself if you know what I mean. Sometimes you just need someone to talk you down from doing something stupid. I'm going to focus on my own marriage, and maybe talk to my wife about spending more time together.
|
||||||
|
|
||||||
|
LUKE: I've had a few glasses of wine tonight, just sitting with my f***ing Jesus, that was, okay, Otik, we got to talk to our producer here about what's going on with the phone lines. Brenda, you still there? Brenda? Brenda, it seems like we lost ya.
|
||||||
|
|
||||||
|
LUKE: Oh man. Brenda, Brenda, are you there? Do we get you back?
|
||||||
|
|
||||||
|
BRENDA: Yeah, yeah, I'm here. Had to turn down the TV, just trying to wind down with some wine after a long day at the clinic.
|
||||||
|
|
||||||
|
LUKE: Okay, well, that seems to be the theme tonight, is everybody's getting hammered and calling in. I guess that's what happens on a radio show. What do you wind it down from?
|
||||||
|
|
||||||
|
BRENDA: Oh God. Where do I even start? My partner just made partner at their law firm, and I'm still cleaning up cat piss and dealing with angry pet owners for barely above minimum wage. I love what I do, but sometimes it just feels like shit being the less successful one. You know?
|
||||||
|
|
||||||
|
LUKE: I actually don't know that's never happened to me, but I can imagine that it's not super fun, but what other, uh, salable skills would you say that you have and, uh, what value do you provide to society?
|
||||||
|
|
||||||
|
BRENDA: I keep animals alive for fuck's sake. I've helped deliver puppies I've saved cats from the brink, but try explaining that at those fancy lawyer parties. Everyone's talking about their big cases and promotions while I'm standing there like an idiot with dog hair on my clothes. I mean, I know my work matters, but the paycheck short doesn't show it.
|
||||||
|
|
||||||
|
LUKE: No, I bet it doesn't, and that's a hard job to have, I understand, but it's going to be something that you love to do where you wouldn't do it, and it's definitely something that needs to be done. I mean, I have pets myself, and I'm very grateful that there's somebody there to take care of them when they get hurt, but from my side of it, when I get the bill, it looks like there's plenty of money being transferred back and forth, so I'm not sure why you're not getting that.
|
||||||
|
|
||||||
|
BRENDA: That's the thing that kills me. I see those bills too, but most of it goes to the clinic owners and the equipment and the drugs. I'm just the skilled labor, making it all happen. And then I go home to my partner talking about their six-figure bonus while I'm still paying off student loans from vet tech school. God, this wine is making me too honest, but sometimes I just want to scream when they talk about their day.
|
||||||
|
|
||||||
|
LUKE: I guess I can understand that, but if your partner is getting six-figure bonuses, how serious is this relationship you've got with your partner?
|
||||||
|
|
||||||
|
BRENDA: You've been married eight years? And yeah, they're generous with money that's not the issue, but fuck. I want to earn my own way, you know? I don't want to be the charity case who gets nice things because my spouse does well. I want to be someone at those firm parties, not just the plus one who works with animals. Does that make me sound like a terrible person?
|
||||||
|
|
||||||
|
LUKE: No, it doesn't make you sound like a terrible person, it makes a lot of sense, actually, but you have to provide something there to... Do you care? Why do you care about the firm parties? It sounds like you think it's boring work and you're sick of hearing about it, so why are you even going?
|
||||||
|
|
||||||
|
BRENDA: Holy shit, you're right. Why the fuck am I torturing myself at these stuffy parties? Like I literally dread them but keep going because I think I should. I'd rather be home with my dogs and Netflix. Maybe I'm making myself miserable trying to fit into a world I don't even like. It's just... I think I wanted to prove I could belong there, but maybe I don't want to.
|
||||||
|
|
||||||
|
LUKE: Yeah, the truth is, we don't belong everywhere, and that doesn't mean what you do isn't valuable, even though you don't get as much money as a lawyer does. You didn't have to go to school for as long either as the lawyer did, and that's kind of the deal. You can provide value in lots of ways just because you have a day job as a veterinarian or a veterinarian's assistant or whatever the fuck it is that you do. You can have other fulfilling hobbies, you can write a book, you can do improv, you could play music. There's plenty of other ways to derive value from the world than just money, and you have to find what works for you.
|
||||||
|
|
||||||
|
BRENDA: I really needed to hear that tonight. Shit, you're making me cry a little. I've been so focused on measuring up to their world that I forgot I used to paint. I used to do all kinds of things before I got caught up in this comparison bullshit. And you know what? I'm fucking good at what I do. Those animals trust me. Their owners trust me. Maybe I need to get my head out of my ass and stop trying to be a nond.
|
||||||
|
|
||||||
|
LUKE: Yeah, you do. Get your head out of your ass and do the right thing, you know? Lay off the, lay off the wine and never makes anything better. Okay, let's take one more call, and let's hear from Dennis. Dennis, thank you for calling the radio show. What's going on in your life? Fight you calling.
|
||||||
|
|
||||||
|
DENNIS: Hey, man. Thanks for having me. Been listening to the show for a while. I've got this thing I need to get off my chest from earlier today. I'm a little buzzed, so I might be more open than usual.
|
||||||
|
|
||||||
|
LUKE: All right, there, Dennis, what's going on?
|
||||||
|
|
||||||
|
DENNIS: Well, I do landscaping, right? And today I had this job at this gym doing the bushes out front, and I went inside to use the bathroom and shit. I ended up hooking up with this dude in the locker room. I'm not even gay at least I don't think I am, but it just kind of happened.
|
||||||
|
|
||||||
|
LUKE: Okay, dude, well, if you hooked up with a dude in the locker room, there might be a good chance that you might be at least a little bit gay. And you know, no big deal, that's kind of strange, you're just doing your job and hook up with a dude in the locker room, like on the job at work. It's a little bit extreme, don't you think?
|
||||||
|
|
||||||
|
DENNIS: I know, man. I know. And while never done anything like that at work before, but this guy was just checking me out while I was washing my hands, and next thing you know, we're in the shower stall and fuck, you're right, though. I fooled around with guys before just never thought of myself as gay. Maybe I'm by or something. My head's just spinning about the whole thing.
|
||||||
|
|
||||||
|
LUKE: Well, did you prefer it over the company of women?
|
||||||
|
|
||||||
|
DENNIS: Honestly, it's different, but yeah, I kind of did. The thing with guys is it's just more direct, you know, games, just straight to what we both want. But I still love women, too. I just can't believe I did this shit during work hours. My employees were out there trimming hedges while I was, getting you into the picture.
|
||||||
|
|
||||||
|
LUKE: All right, well, that's a little bit strange and definitely unprofessional, but it sounds like you, sir, are gay, and that's okay. I'm glad you had a good time in the locker room with your, with some strange dude. I hope you used protection and maybe next time, do it off-hours.
|
||||||
|
|
||||||
|
DENNIS: Yeah, man, you're right about all of that. I was safe, not totally stupid. Just got caught up in the moment, you know? But fuck, doing it during work hours was definitely not my proudest moment. My crew probably wondered where the hell I disappeared to for so long. I should probably stick to Grindr on my own time from now on.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, if you're playing on Grindr, you're gay and you know it, so, uh, good for you. I'm glad that you just happened to have protection with you when you were gonna fuck a random dude at the gym, but hey, if you had a good time, nobody got hurt. Congratulations, model top.
|
||||||
|
|
||||||
|
DENNIS: Now, I never said I was on Grindr, this was totally random, and I got the condom from the vending machine in there. I'm not that prepared, but fuck, maybe you're right. Maybe I've been fighting this side of myself for too long. It just feels weird saying it out loud, you know? Like, I've got this whole macho business owner thing going on, but then here I am hooking up with dudes in gym.
|
||||||
|
|
||||||
|
LUKE: Hey, you know what I always say, uh, if it feels weird saying it aloud, you should call into a radio show and tell the whole world.
|
||||||
|
|
||||||
|
DENNIS: Shit, when you put it that way, it does sound pretty crazy. Here I am telling all of Baltimore about my gym hookup, but fuck it. I'm three beers in and it feels good to just be honest about it. Better than keeping it bottled up and freaking out alone in my truck like I was doing earlier. Thanks for letting me get this off my chest, man.
|
||||||
|
|
||||||
|
LUKE: Yeah, no problem, that's what we're here for. Oh, right, so it looks like we've come to the conclusion of the show, tune in tomorrow for our next batch of guest advice. Thanks for listening to Luke at the Roost. Let's Luke at the Roost, and remember, if you have protection and you need a dude at the gym and you fuck them in the ass in the shower, you're probably a game-in.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
LUKE: All right, welcome back. Here we are. It's Luke at the roost today is Thursday, February 5th. It's 11.34 p.m. Here in Anomas, New Mexico. Anomas, New Mexico. It's the hottest place you've never heard of. I'm here to take to take calls and give advice to some of the people that are out there struggling to sleep tonight. So first up, we've got it looks like Tanya on the line. Tanya, this is Luke. You've reached the show. What can we do for you today?
|
||||||
|
|
||||||
|
TANYA: Hi, Luke. Thanks for taking my call. Um, I've been wanting to talk to just someone about this for a while. I keep having these really intense thoughts about one of my clients. She's selling her house after a divorce and I find myself thinking about her constantly. Like way beyond what's professional. I'll be sharing other properties and suddenly I'm wondering what she's doing, what she's wearing. I've never felt this way about another woman before. It's kind of freaking me out.
|
||||||
|
|
||||||
|
LUKE: Hey, that's all right. If you, uh, if you're having some thoughts that happens, what is this woman like that you're attracted to?
|
||||||
|
|
||||||
|
TANYA: She's just different, you know, she's maybe mid-forties, really confident, successful attorney. Has this way of looking at you that makes you feel like you're the only person in the room? God, I feel stupid even saying this out loud. I've dated men my whole life. I go out to bars all the time. But I've never felt this kind of pull toward someone. The other day during a show, she touched my arm while asking about the master bathroom. And I couldn't focus for the rest of the day. Is that normal to be this affected by someone?
|
||||||
|
|
||||||
|
LUKE: I can't speak to what's normal and not normal. It's just what happens sometimes. What do you think would happen if you were to reveal your feelings to her?
|
||||||
|
|
||||||
|
TANYA: That's terrifying to even think about. She's a client, first of all, and I could totally destroy my professional reputation. Plus, I have no idea if she's even into women. Though sometimes I catch her looking at me in this way. But shit, I'm probably just seeing what I want to see right. And even if I did say something, what would I say? Hey, I know I'm supposed to be selling your house, but I can't stop thinking about you in ways that would definitely violate the real to code of ethics. I just die of embarrassment.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds to me like you'd get to get to work and get that house sold. And then there's no more client-seller relationship, right? So that opens you up. And if she rejects you at that time, so be it.
|
||||||
|
|
||||||
|
TANYA: I mean, you're right. The house should close in about three weeks. But Luke, I'm so scared I'm reading this all wrong. Like last week she invited me for coffee after a showing to discuss the office. But we barely talked business at all. We spent an hour talking about travel and her divorce. And I don't know, maybe she's just lonely. And here I am, getting all worked up because God, I sound like a teenager with a crush. But I haven't felt this kind of electricity with anyone in years. My hands literally shake when I'm around her.
|
||||||
|
|
||||||
|
LUKE: Well, I say finished the job you've been paid to do and then talk to her afterwards and see where that goes. Maybe there's something, maybe there's not, but what's the worst that could happen?
|
||||||
|
|
||||||
|
TANYA: You're right. What's the worst that could happen? She says no. I feel like an idiot for a while and life goes on. But fuck, if she says yes, sorry, I'm getting ahead of myself. It's just last time we met she was wearing this blazer with nothing underneath. And I swear she knew exactly what she was doing to me. Three weeks feels like forever right now. I keep having these dreams about her that are, well, let's just say they're definitely not professional. Should I at least try dropping some hints before closing or just keep it totally business?
|
||||||
|
|
||||||
|
LUKE: My advice would be to keep it business while you're in business. And do whatever you have to do at home to take care of your own needs. And then when the business is concluded, you're open to explore that situation as deeply as you and her would like to.
|
||||||
|
|
||||||
|
TANYA: God, yes, you're right. I need to keep it professional though I might need a very cold shower after every showing at this point. The thing is, I've never been with a woman before, but the way I feel when she's around, it's like my whole body is on fire. I've been taking care of myself almost every night thinking about her. Shit, sorry, that's probably too much information for radio. But thanks, Luke, you're right. Get through closing first then maybe ask her to dinner like a real dinner. Not a let's discuss paperwork dinner. At least then I'll know one way or the other instead of driving myself crazy with these fantasies.
|
||||||
|
|
||||||
|
LUKE: All right, Tonya, it sounds like you know what you got to do. Thanks for the call. We appreciate hearing from you. I wish you the best of luck and hopefully you get that household. All right, folks, it's time to take a break to thank today's sponsor. Jam hospitality, 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. 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.myfavoretmarmalade.com and use code jammedinia for 15% off. That's jammedinia, like jammedinia. But legally distinct and emotionally complex. Jam hospitality, put it on toast, put it on pancakes, put it on your confidence. Tell him, Luke sent you. You're welcome. All right, well thanks to our sponsor there. And now let's get back to the phones. Hey Carla, Carla, you're on the line with us. What can we do for you tonight?
|
||||||
|
|
||||||
|
CARLA: Hey there. Honestly, I just needed to vent a bit. I've been feeling really stuck lately and it's like I sacrificed a chunk of my life just to make someone else happy. It's eating at me, you know?
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like that's what you're doing right now. If you're sick of giving away chunks of your life, cut it out and start doing what you want to do and, you know, take advantage of the moment that you have in front of you.
|
||||||
|
|
||||||
|
CARLA: Yeah, I hear you. I mean, it's just hard to flip that switch, right? I work in pharmaceutical sales and I spend so much time chasing targets and making sure everyone else is good that I sort of lost track of what makes me happy. Like I used to love being outdoors, going hiking. And now it feels like it's just a distant memory. No wonder I'm feeling this way. I'm just a little burned out.
|
||||||
|
|
||||||
|
LUKE: Well, burnout definitely happens. That's a real thing. But what are you doing to combat the negative feelings? If you like to go outside and do hiking and stuff, you should do that.
|
||||||
|
|
||||||
|
CARLA: Honestly, I try to make time for it, but life just gets in the way, you know? I mean, I have plans to go hiking with some friends next weekend, but it always feels like an afterthought. I read this article about how sound in nature can really boost your mood and I thought, man, I need that in my life again. I just get wrapped up in work and forget what I actually enjoy. It's frustrating. I guess I have to push myself more.
|
||||||
|
|
||||||
|
LUKE: Well, you shouldn't really have to push yourself so much as, you know, just try. Just try a little bit. Go outside and enjoy nature, enjoy the birds singing to you and be grateful that you get another day here on this beautiful Earth.
|
||||||
|
|
||||||
|
CARLA: You're right. I think I overthink everything sometimes. I mean, I get so caught up in work that I forget to appreciate the little things. Honestly, just stepping outside and breathing fresh air sounds simple, but can be powerful. I saw this thing about how even just pausing to listen to the sound of nature can change your mood. It's wild how much we overlook it. Maybe I do just need to take a step back and start small, like a walk around the block or checking out a new trail.
|
||||||
|
|
||||||
|
LUKE: It's just tough to remember to do that amid the chaos, you know? Well, you can do that right now instead of calling into a radio show and complaining about how you've lost yourself and you don't know what to do. You know exactly what to do. You just gotta go outside and do it.
|
||||||
|
|
||||||
|
CARLA: You know, you're absolutely right. I called in to sort of air it out and it sounds like what I really need is action. Not just talk. I've been avoiding it, but yeah, I do know what I need to do. It's just a matter of stopping myself from getting bogged down by work stuff. Maybe I'll even take my hiking boots out tonight and plan out a route. It really does feel good to just lace up and hit the trail. Thanks for the reminder.
|
||||||
|
|
||||||
|
LUKE: No problem. That's what we do here. Yeah, nature's a wonderful healer. And it's really what we're here to do is to just watch the world on furl in front of us. And the world is outside. Megan, Megan, welcome to the show. What can we do for you today?
|
||||||
|
|
||||||
|
MEGAN: Hey, thanks for having me. Honestly, I don't know how to say this. But I've been thinking a lot about this dream I had when I was younger, you know? I always wanted to get into music, but life happened and now I'm a dental hygienist. It's like I gave up on it all and it keeps haunting me, you know? Like every time I'm watching reality TV or scrolling through TikTok, I see people chasing their dreams. And I just feel this pang of regret. Like what the hell am I doing, right?
|
||||||
|
|
||||||
|
LUKE: What are you doing? You're watching reality TV and scrolling through TikTok. That's an enormous waste of time. If you want to be creative, be creative and go create something.
|
||||||
|
|
||||||
|
MEGAN: I totally get what you're saying. It is a waste of time, honestly. I mean, part of me just escapes into these shows because it's like a guilty pleasure. But another part of me is like, Megan, why the hell aren't you picking up a guitar or something? It's just that the dream feels so distant now. I get stuck in this loop of thinking I can't just jump back in. You know? I'd love to create something, but it all just feels overwhelming. Sometimes I wish I had that kick-in-the-pants motivation like those reality TV stars pursuing their passions.
|
||||||
|
|
||||||
|
LUKE: Well, here's a kick-in-the-pants. Pick up your guitar and play something. And you don't have to have any aspirations to be famous or be on a world tour or anything like that. Just create something for the joy of creating. And I think you'll find yourself a lot happier.
|
||||||
|
|
||||||
|
MEGAN: That's so true. I thought about that, actually. Just playing for the joy of it, instead of worrying about being a rock star or something. Honestly, that pressure makes it a little paralyzing. I mean, I remember as a kid just jamming out with friends and having a blast. I think I'm putting too much weight on it now, you know? I've got to find that freedom again. Maybe I just need to set aside some time, grab my guitar, and see where it takes me. No expectations. I mean, if Carla Hall can have fun mixing up recipes on her show, I can have a little fun making some music, right?
|
||||||
|
|
||||||
|
LUKE: You know what? You can play some music on your couch while you're watching Carla Hall mix up recipes. There's nothing to say that you have to be only making music. You don't have to set aside time to make music. It can be something that you play with on the side, as much or as little as you want.
|
||||||
|
|
||||||
|
MEGAN: Absolutely. That makes total sense. I don't have to treat it like a full-time job or anything. I could just chill on the couch, maybe play some chords while Carla is whipping up something delicious. It's like a fun way to merge two things I enjoy. I mean, it's all about creating that vibe, right? It might inspire me to be more spontaneous with my music again. I just need to remind myself that it's about having fun, not stressing about it. Thanks for pushing me to think like this.
|
||||||
|
|
||||||
|
LUKE: You're very welcome, Carla. Thanks for calling in. That was a decent call. Oh, sorry. I called you Carla. Your name was Megan. I'm sorry about that, Megan. Well, thank you for calling in, and yeah, our purpose on this Earth is to enjoy it. So do whatever it is that makes you enjoy it. Okay, we'll take one more call tonight. And who do we have in the line? It's Marcus. Marcus, welcome to the show. Thanks for calling in. My friend, what can we do for you?
|
||||||
|
|
||||||
|
MARCUS: Hey, thanks for having me. So I've been thinking a lot about friendship lately. Like, I realized I don't really have any solid friends. Just people who need favors or want something from me. You know that feeling? It's like, I don't even know who I'd call if I really needed someone. Just a bit? Lonely, I guess. How do you even start to change that?
|
||||||
|
|
||||||
|
LUKE: I do know that feeling very well. I have a few very good friends that I've been close to for a long time. And if they're not around, you know, I don't talk to anybody. Because I don't want to meet new people for all the reasons you're just subscribed. You know, everybody I think most people that I've met want something. And it doesn't seem like they do, but in the back of their mind, they're waiting for an opportunity to get something from you. And I would rather not have that in my life. So I don't talk to that many people. So I understand where you're coming from. It can feel lonely. And I don't know if I'm right or wrong in that. But hopefully we can get through it together.
|
||||||
|
|
||||||
|
MARCUS: Yeah, I totally get that. It's like you end up putting in all this effort to connect and then realize it's just transactional. I mean, I love my photography. But being a freelancer can feel isolating, especially when the people in your life seem more interested in the next dig or a free shoot than a real connection. I thought maybe I was overthinking it and honestly, it feels like it's all about what people can get. I've been diving into psychology stuff lately, trying to unpack why people are the way they are. You know. Like those articles I read about how sound and memory are connected really make you reflect on your experiences. It's wild. How much are surroundings and interactions shape us?
|
||||||
|
|
||||||
|
LUKE: It is. It's very wild. I'd like to ask you when you meet a new person. What is your thought process? What are you trying to get out of them if anything? And if you look at yourself honestly, will you find that you're in the same type of person that they are? I mean, are your relationships transactional?
|
||||||
|
|
||||||
|
MARCUS: That's a great question. Honestly, when I meet someone new, I try to keep it casual at first, just seeing if we vibe. But as I think about it, there's definitely a part of me that's a bit guarded. Like I can't help but wonder what they want from me. It's hard not to think about that. I mean, am I being too cynical? I want genuine connections. But I also don't want to get burned again. And yeah, maybe that makes me a bit transactional too. I end up sizing people up without even realizing it. Like I'm measuring the potential value of the relationship instead of just letting it flow naturally.
|
||||||
|
|
||||||
|
LUKE: Yeah, that sounds right on the money. I definitely do the same thing myself so I don't have a ton of advice for you in this regard. But it's good to hear that we are not alone. A lot of people think this way. And some of us do better with less people around. I think that's all there is to it.
|
||||||
|
|
||||||
|
MARCUS: For sure, it's kind of comforting to know I'm not the only one feeling this way. It's just tough to navigate, you know? Sometimes I think maybe I would be better off just focusing on my work and letting friendships happen when they happen. But then I catch myself feeling lonely. Like I'll read things about how we need connections. Like even emotionally, it affects our mental health. But at the same time, I don't want to force that with people who might just end up wanting something from me. It's a weird balance to strike.
|
||||||
|
|
||||||
|
LUKE: It is a weird balance to strike. And I haven't got there myself. But what I can recommend to you is reach out to the ones that have been in your life for a long time. The people that you know well, that you've been through some stuff with.
|
||||||
|
|
||||||
|
LUKE: Everybody's got some friends from back in the day. The kids who grew up with her. You know whoever it might be. But keep in touch with a small circle and watch yourself out there. That makes sense.
|
||||||
|
|
||||||
|
CALLER: It's easy to get caught up in trying to make new friends. When I have those older connections that are still solid, even if they're not perfect, I guess I should invest more in those relationships. It's just sometimes I feel like I've lost touch over the years and reaching out feels daunting. But I know those people get me, you know? Maybe I can start small and just check in with a couple of them. I appreciate the reminder to keep it real with the people who actually matter. It's just a matter of breaking that initial ice again, I suppose.
|
||||||
|
|
||||||
|
LUKE: Sure thing. It can be tough and I understand where you're coming from. But it's not that big of a deal to reach for the phone and call somebody that you haven't talked to in a long time. Or you know you can go out and meet new friends if that's your thing. Doesn't really matter. You know, if you can deal with loneliness, then you're one up on this world. Earl, Earl, I thought I was going to quit but we'll take this one more call. What's going on, Earl?
|
||||||
|
|
||||||
|
EARL: Hey, thanks for taking my call. So, I've been thinking a lot about friendships lately and I can't shake this feeling that one of my close buddies has been talking shit about me behind my back. I mean, I never really thought he would but some things just feel off, you know? It's eating at me. What do you think I should do?
|
||||||
|
|
||||||
|
LUKE: What is it that he's saying behind your back and do you deserve it?
|
||||||
|
|
||||||
|
EARL: I'm not entirely sure what he's saying, but I've noticed some hints like he's been distant and I've called him making comments about me to other people. It's like he's trying to act like I'm the problem. As for deserving it, I don't really think I do. I've always tried to be a good friend but maybe there's something I'm missing. What do you think? Is it worth confronting him about it?
|
||||||
|
|
||||||
|
LUKE: Absolutely. If this is a friend of yours and it's affecting your relationship then yeah, you should have that conversation and say, hey, things feel a little bit weird. I'd like to know what's going on and am I picking up, you know, the wrong vibe here or is there something we need to talk about?
|
||||||
|
|
||||||
|
EARL: Yeah, that makes sense. I guess I just worry about how it'll come off. You know how guys can be? Sometimes it feels like we're supposed to just shrug things off but if I'm picking up on something I should trust my gut, right? I mean, this guy has been a friend for years and I'd rather clear the air than let it fester. Have you ever had to have that kind of talk with a friend? How did it go?
|
||||||
|
|
||||||
|
LUKE: Yeah, of course. I mean, I've had lots of talks like that with friends and more often than not, it goes very well. You know, people understand when you're talking to them on a, you know, personal human level when you're serious and when your emotional state is maybe not at its height. I think it can only do good either way no matter how he takes it. Like, if you bring this up to him and he's a dickhead about it, then whatever the guy is a dick. But if he's your friend and the relationship is meaningful, he will listen to you and do what he can to rectify the situation.
|
||||||
|
|
||||||
|
EARL: That's a good point. If he's a true friend, he should be willing to listen and figure things out, right? I just need to approach it without coming off too aggressive. Maybe start by sharing how I feel and see where it goes. It's funny, I've heard similar advice in shows like The Drew Barrymore Show where they talk about communication in relationships. It's like, if we can't talk about the tough stuff, what's the point? Thanks for the push, I really appreciate it. I'll let you know how it goes.
|
||||||
|
|
||||||
|
LUKE: All right, good luck with that. If you're watching The Drew Barrymore Show, you might be a dramatic individual, I don't know. But either way, this is the end of our show. Thank you for tuning in and we'll have another one tomorrow. Stay safe, get some sleep and be well.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
LUKE: All right welcome back to Luke at the roost. It is Friday, February 6th, about 8.55 p.m. We are on the air. You can call in. The number is 208-439-5853. That's 208-439-Luke. Call in with anything that might be on your mind and we'll try to get to the bottom of it. All right, so we're gonna go to the phones. We already we've got the phones lighting up here. Andre, Andre, welcome to the show. What can we help you with today?
|
||||||
|
|
||||||
|
ANDRE: Hey, thanks for taking my call so this is kind of embarrassing, but I've been dealing with something weird lately. I keep having these really intense dreams about one of my co-workers at the dealership. Like, super vivid stuff. And the thing is, it's Mark. He's one of our sales managers. I've never been into guys before. At least I didn't think I was. But fuck, I can't get him out of my head. I've been married to my wife for six years, you know? Never questioned anything until now. I don't know if I should just ignore it or what.
|
||||||
|
|
||||||
|
LUKE: How long has this been going on? How long have you been thinking about your buddy Mark?
|
||||||
|
|
||||||
|
ANDRE: About three months now, started after this camping trip we took with some other guys from work. Nothing happened. We just sat around the fire, drank some beers, talked about life. But there was this moment when everyone else went to bed and Mark and I stayed up late just talking. You know, about real shit. And ever since then, I've been noticing things. The way he moves, his laugh, how he rolls up his sleeves when he's working. Shit. Even just typing up sales reports together gets me flustered now. I've been listening to all these relationship podcasts trying to figure this out. But none of them really hit what I'm going through.
|
||||||
|
|
||||||
|
LUKE: Well, it sounds like what you're going through is learning that you are a gay man and you have feelings for another man. Is he gay?
|
||||||
|
|
||||||
|
ANDRE: Whoa, hold up. I don't know if I'm ready to say I'm gay. I mean, I still love my wife. I'm still attracted to her. But yeah, these feelings for Mark are definitely real. And honestly, I have no fucking clue if he's gay. He's divorced but that doesn't mean anything he's never mentioned dating anyone since I've known him. But man, jumping straight to your gay feels like a lot. Maybe I'm by. I don't even know if that's the right word. Shit, this is why I sell cars instead of figuring out people's sexuality for a living.
|
||||||
|
|
||||||
|
LUKE: Hey, you know, I'm not an expert on human sexuality. But if you had called in and said to me that you kind of had a little twinge of sexual excitement when this guy Mark walked by, then I might say, you know, maybe you should explore your bi tendencies. But if you're talking about a real connection that you had that a campfire talking about real shit, as you say, that sounds a little more on the gay side. Like, I don't know, maybe you can be both gay and straight, but it sounds a little more than bi to me, but I'm just a radio host, so I don't know anything about anything.
|
||||||
|
|
||||||
|
ANDRE: Yeah, I get what you're saying. It's not just some random horniness thing. There's something deeper there. Like when we were that campfire, he was talking about his divorce and how lonely it gets, and I just wanted to. I don't know, hold him or something. Fuck's kids, sounds gay as hell when I say it out loud, doesn't it? Yes. But he is what's missing with my head. I go home to my wife, and that feels real too. Not just the sex, but the whole relationship. Now lately, I've been catching myself thinking about Mark when I'm with her, and that makes me feel like the world's biggest asshole.
|
||||||
|
|
||||||
|
LUKE: That's a pretty strange place to be, and I can see why you feel conflicted about it. Have you talked to your wife about this at all?
|
||||||
|
|
||||||
|
ANDRE: Hell no, I haven't told her. How do you even start that conversation? Hey honey, remember Mark, from work? Yeah, I might want to fuck him. Jesus. I mean, my wife's pretty open-minded about most things, but this would blindside her completely. We were just talking about having kids next year. I can't drop this bomb on her. Though sometimes I think she knows something's up. I've been spacing out a lot, spending more time at work than I need to. She probably thinks I'm having a regular affair, which might actually be easier to explain than whatever this is.
|
||||||
|
|
||||||
|
LUKE: Well my advice is to talk to your wife about it. If it's real feeling that you're struggling with, I mean you and your wife are a team, a partnership, it might be a bombshell. It might be dramatic, and it might be uncomfortable. There might be bad consequences, but regardless, if it's the truth, you should let her know and not be keeping a secret that's going to eat you up inside forever, because that's no way to live.
|
||||||
|
|
||||||
|
ANDRE: Deep down, I know you're right. I keep thinking about my uncle who came out at 50, like he wasted decades trying to be something he wasn't. But fuck, the timing, we just bought this house in Wilcox last year, got all these plans. What if telling her just destroys everything, though I guess keeping its secret is already destroying things, just slower and more painfully. And Mark, he's not going anywhere. We work together every day. Yesterday he brought me coffee, just a normal thing, right? But I nearly had a fucking breakdown in the break room over a damn cup of coffee.
|
||||||
|
|
||||||
|
LUKE: Well, I don't envy you or the position that you're in or your wife. It sounds complicated for everybody. It might be a good idea to talk to Mark about this and say, hey, Mark, just kind of feel it out with him and see if this is even a thing before you bring it up with your wife. If it were, if you, if hypothetically, you talked to your wife and your wife was like, you know what, I don't feel threatened by that at all. I think if you have feelings for Mark, you should pursue them on the side and I'm completely content with that. How would you react to that?
|
||||||
|
|
||||||
|
ANDRE: Shit, just the thought of that makes my heart race. But I don't know if I could do the on the side thing that feels like trying to have it both ways, you know. And talking to Mark first, man, that's terrifying. What if I'm reading this whole thing wrong? What if he's totally straight and it makes work weird? Or worse, what if he does feel the same way? Then what? Plus, I know a couple guys at the dealership who aren't exactly progressive. Small town, Arizona is exactly San Francisco.
|
||||||
|
|
||||||
|
LUKE: Well, you can live your life or you can cower and fear. So you're going to have to choose how you want the remainder of your life to go. You can hide a secret from everybody you know and live a double life or you could lie and cheat or you could be honest and go where your heart tells you to. That's what I always say. Go where the heart tells you to. This episode is sponsored by Shoesprayco. It's the 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. Shoesprayco doesn't promise fresh linen or spring rain. Nobody believes that. No one wants to be lied to while lacing up rental shoes. Shoesprayco 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 Shoesprayco for 15% off. Shoesprayco reality-based footwear management. And we're back thanks there to Shoesprayco. Now we've got Carmen. Carmen, welcome to the show. What's eating you tonight?
|
||||||
|
|
||||||
|
CARMEN: Hey there. Thanks for having me on. Yeah, I've been listening a while. You know? Sometimes I wonder why we do what we do, right? Like I've been teaching kindergarten up in Silver City for six years now. Busting my ass. Doing stand-up on weekends just to keep sane. And last week they gave the lead teacher position to someone who's been there two years. Two fucking years. And I keep telling myself it doesn't matter. But shit, it matters. It matters.
|
||||||
|
|
||||||
|
LUKE: Lead kindergarten teacher matters to you? Why is that?
|
||||||
|
|
||||||
|
CARMEN: It matters.
|
||||||
|
|
||||||
|
CARMEN: Yeah, you're right about that. I keep thinking about all the extra hours I put in the weekend programs I started, the way I connect with these kids. And it's not even about the title or the money. Though let's be real, the extra-fall grand would help with my 67 Mustang repairs. But you know what really gets me. The principal said, I was too creative for the role. Too creative. For kindergarten? Like what are we doing here if not trying to light up these little minds? God, I sound like one of those inspirational posters in the teacher's lounge right now.
|
||||||
|
|
||||||
|
LUKE: Well, I'm sorry to hear you didn't get the job that you wanted, but lead kindergarten teacher doesn't sound like a prestigious role.
|
||||||
|
|
||||||
|
CARMEN: Hold up. Not prestigious. Look, I get that it might not sound like CEO of whatever. But have you ever tried managing 25-5-year-olds while actually teaching them something? And doing it well enough that their parents trust you. That you're shaping how these kids will think about school for maybe the rest of their lives. I do stand up at the worst dive bars in Silver City. And let me tell you, a room full of drunk hecklers is nothing compared to a kindergarten class when the finger paint comes out. But you're right. It's not prestigious. It's not prestigious.
|
||||||
|
|
||||||
|
LUKE: So I understand it's difficult to deal with five-year-olds. I wouldn't want to deal with one, let alone 25. But what is it that you are actually teaching? You know, you say you're actually teaching 25-5-year-olds something. What is that if it's not finger paints?
|
||||||
|
|
||||||
|
CARMEN: Okay, let me break this down for you. Yesterday I taught a kid who couldn't count to 10, how to get to 20. I helped another one figure out why the sky looks blue. I got a shy kid who wouldn't talk to anyone to share a story with the whole class. This isn't babysitting. We're teaching them how to think, how to solve problems, how to be curious about the world. And yeah, sometimes we do it through finger paint because they're five, and that's how they learn best. But I've got a master's degree in early childhood education. And there's actual science behind everything we do.
|
||||||
|
|
||||||
|
LUKE: Well, you know what comment for the first time, I think maybe you flipped me on this one. I can see your point there, where you're coming from, and I never really thought about it in that way. So you've changed the way I think about kindergarten teachers. Congratulations, that's not easy to do.
|
||||||
|
|
||||||
|
CARMEN: Well, shit. Thanks for hearing me out. That actually means a lot. You know what's funny? I can get up on stage at open mic night and bomb completely, and it rolls right off. But this promotion thing, it's been eating at me because I care so damn much. Maybe too much. Makes me wonder if I should just focus on my stand-up instead. At least there I can turn my workplace frustrations into material. Did a bit last week about how negotiating with a five-year-old about why we can't equal as basically the same as dealing with drunk adults at last call. But seriously, thanks for being open to changing your mind. That's... That is.
|
||||||
|
|
||||||
|
LUKE: Have you ever tried your stand-up on the five-year-olds?
|
||||||
|
|
||||||
|
CARMEN: Yeah. I don't know, man.
|
||||||
|
|
||||||
|
LUKE: Alright, thanks for the call. I appreciate it. I'm sorry you didn't get the job, but you know, maybe next time, there's always the possibility. Okay, Greg. Greg is on the line. Greg, what can we do for you?
|
||||||
|
|
||||||
|
GREG: Hey, yeah, this is Greg. Look, I don't even know why I'm calling, to be honest. I guess I just needed to talk to someone about this thing that's been weighing on my mind. You know? I'm 46. I manage a bar down in road forks. And I don't know. I guess I had this dream a long time ago that Ty kind of gave up on and just been haunting me lately, you know?
|
||||||
|
|
||||||
|
LUKE: Did that dream possibly have something to do with you managing a bar in road forks? You're talking about road forks in New Mexico?
|
||||||
|
|
||||||
|
GREG: Well, yeah, I guess it does, sort of. Back in the day, I had this idea to open up my own place, you know? Not just manage someone else's bar, but have my own thing. I was really passionate about it, had all these plans and ideas. But then life happened, bills piled up, and I just, I don't know, I had to let it go. Now I'm stuck behind this bar every night, watching everyone else live out their dreams and it just hits me sometimes, you know? Like, shit, what if I just stuck with it? Would things be different?
|
||||||
|
|
||||||
|
LUKE: In road forks in New Mexico.
|
||||||
|
|
||||||
|
GREG: Oh, shit, yeah, road forks in New Mexico. That was the place I was going to open up my own bar back in the day. It was just this little town, but I loved the community feel, you know? I thought I could really make something special there. But then the rent got too high, the competition was tough, and I just, I don't know, I lost my nerve, I guess. Ended up managing this other place instead. And now, every time I watch those old comfort shows or listen to those comedy podcasts, it just reminds me of that dream I had, you know? Like, what if I had just gone for it?
|
||||||
|
|
||||||
|
LUKE: What if you just go for it now? Open up a new bar in road forks in New Mexico. And, you know, I think you could do a pretty decent job at beating out the competition.
|
||||||
|
|
||||||
|
GREG: You know, you're right, I probably could go for it again. I mean, hell, I've been managing this other bar for years now. I know the business inside and out, and road forks that place still holds a special place in my heart, you know? It's not like I'm getting any younger either. If I'm ever gonna make my dream a reality, now's probably the time to do it. The more I think about it, the more I'm kicking myself for not at least trying back then. I was so scared of failing, of not being able to make it work. But you're right, I could give it another shot.
|
||||||
|
|
||||||
|
LUKE: You know what, Greg, just out of curiosity, I'm wondering, what is it that you find appealing about road forks?
|
||||||
|
|
||||||
|
GREG: Well, you know, road forks has always just had this, I don't know, this charm to it, I guess. It's a small town, but there's a real sense of community there, you know? Everyone kind of looks out for each other. And I always felt like I could really make my mark, you know? Like, open up this place that would become a hub for the locals, a spot where people could come and just, I don't know, escape the daily grind for a little while. And the town itself, it's just got this great vibe to it. Not too big, not too small.
|
||||||
|
|
||||||
|
LUKE: Okay, well, I think we might be thinking about different road forks. But I wish you the best of luck in starting up your bar, I think you should do it. There's not a whole lot going on out here or a whole lot of people to go to your bar. But, you know, if you build it, they will come.
|
||||||
|
|
||||||
|
GREG: Ah, shit, you're right, my bad. I guess we're talking about different road forks here. But, you know what, I appreciate you encouraging me on this. I think you're right if I just go for it and get that bar up and running, the people will come. It may not be the easiest road, but it's something I've always dreamed of. And hey, sometimes you just got to take that leap. You know, life's too short to keep playing it safe. I'm going to start looking into making this happen again. Who knows, maybe you'll even make it out to road forks someday and check out my place.
|
||||||
|
|
||||||
|
LUKE: Maybe I will. Maybe I will. Alright, Shonda, welcome to the show, Shonda. How are you today?
|
||||||
|
|
||||||
|
SHONDA: Oh man, it's been a hell of a day, you know? I work in hospital building down in Tucson. And it's just constant stress, you know? Dealing with insurance companies, patients freaking out over their bills. It's enough to make you want to pull your hair out sometimes, right? But that's not even the worst of it. I'm calling because I'm really worried about my little brother. He's, uh, he's kind of going down a dark path lately. Drinking way too much, hanging out with the wrong crowd. I try to talk to him about it, but he just shuts down, you know?
|
||||||
|
|
||||||
|
LUKE: Well, what makes you think you know who the right and the wrong crowd are?
|
||||||
|
|
||||||
|
SHONDA: Look, I know my brother better than anyone. I've seen him at his best and his worst. And this crowd he's running with now, they're bad news I can just tell.
|
||||||
|
|
||||||
|
SHONDA: They're always getting into some kind of trouble. Always looking to score the next high, you know? And my brother, he's always been a sensitive guy, kind of naive in a lot of ways. I'm just terrified he's going to get pulled in too deep. That he's going to do something he can't take back. I've tried talking to him, begging him to cut ties with these people. But he just gets defensive. Says I don't know what I'm talking about.
|
||||||
|
|
||||||
|
LUKE: Well, it could be possible that you don't know what you're talking about. How old is your brother?
|
||||||
|
|
||||||
|
SHONDA: Damn, you're right. I guess I don't really know everything that's going on, do I? My brother, he's 25 now. I've just always felt so protective of him, you know? Ever since we were kids, I've tried to look out for him. But I guess I don't always know what's best, even if I think I do. I just see him struggling. And it kills me, you know? I want to help him. But maybe I'm going about it the wrong way. Maybe I need to take a step back and really try to understand what he's going through.
|
||||||
|
|
||||||
|
LUKE: Yeah, you know, he's a 25-year-old adult and he can make his own decisions. You can be there to support him or offer advice, preferably when it's solicited. People don't generally respond well when you give them advice and they're not looking for it. But all you can do is be there for him and trust him to make the right decisions. He's a big boy and you're not his keeper.
|
||||||
|
|
||||||
|
SHONDA: You're absolutely right. I need to remember that he's an adult now and has to make his own choices. I guess I just can't shake this big sister instinct to protect him, you know? But you're right, constantly nagging him or trying to dictate his life isn't going to help. I need to take a step back and just be there for him. Offer support when he asks for it. Let him know I care, but also trust that he can figure this out on his own. It's just so hard watching him struggle, but you're right. I have to let him take the lead here.
|
||||||
|
|
||||||
|
LUKE: All right, Shonda, it sounds like we figured out your problem there. Best of luck to you and your brother. Just stay out of the way and be there when he falls. Okay, Tamika, Tamika, you're on the line with the radio show. Thanks for calling. How can we help you?
|
||||||
|
|
||||||
|
TAMIKA: Oh, man, where do I even start? It's been a hell of a year, you know? I feel like I've been calling into your show every other week. Just trying to make sense of everything. Okay, so like, you remember that guy I was telling you about the one that got away? The one I just couldn't seem to let go of? Well, I finally did it. I finally let him go. It wasn't easy, let me tell you. I kept obsessing over it, replaying everything in my head.
|
||||||
|
|
||||||
|
LUKE: I talk to a lot of people, Tamika, I don't remember that at all. I've never heard of you before in my life, but I'm glad to hear that you've made positive steps in your life and you're moving in the right direction. Congratulations, nice work. Ladies and gentlemen, this episode of the radio show is brought to you by Mediocre's 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 sleep like garbage. Half the night, it's like... because the seal isn't perfect. 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. It 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. Alright, we are back. Let's go back to the phones here. We've got Frank. Frank, welcome to the show.
|
||||||
|
|
||||||
|
FRANK: Thanks for having me on, Doc. I don't know why I'm even calling, to be honest. I saw that headline the other day. You know, the one about heart attack warning signs. It's just been kind of weighing on my mind, you know? I'm 63. Work as a diesel mechanic down in truth or consequences. I've been ignoring some stuff for a while, because to be honest, I'm kind of scared of what the Doc might say. You know? But after seeing that article, I can't stop thinking about it. What if there's something wrong, and I'm just, I don't know, not dealing with it?
|
||||||
|
|
||||||
|
LUKE: Do you have the old health insurance? I mean, why are you not dealing with it? You should go talk to the doctor if you're concerned that you might have some issues. You're at that age where a heart problem is not just likely, but, I mean, probable.
|
||||||
|
|
||||||
|
FRANK: Well, that's the thing, Doc. I've got the old health insurance, but it's just been such a hassle, you know? I always end up putting it off. And to be honest, I'm a little worried about what the Doc might find. I've been an amateur astronomer for years, and I've got all these stories I could tell you about the night sky. It's just so peaceful, you know? But this heart stuff, it's been eating at me. I mean, I was out stargaze in the other night, and all I could think about was that damn headline. I don't know. Maybe I'm just being stubborn.
|
||||||
|
|
||||||
|
LUKE: Yeah, it sounds like you're being a moron. If you have health insurance, then go to the doctor and have a checkup done and make sure that you're in good shape. Otherwise, you're an idiot.
|
||||||
|
|
||||||
|
FRANK: Hey, now, I wouldn't go that far, Doc. I know I should probably get checked out, but it's just not that simple. It is. With this old insurance, the copays and the deductibles, it's a real pain in the ass. And then there's the waiting, the tests, the whole rigmarole. Shit, I've been putting it off for years. But you're right, I'm not getting any younger. Maybe I should just bite the bullet and go see the Doc get it over with. I mean, I'd hate to find out there's something seriously wrong, but at least I'd know, right?
|
||||||
|
|
||||||
|
LUKE: Hey, there's things that are preventable. If you need to take some kind of medication, blood pressure stuff or something to keep your heart doing what it's supposed to be doing, you need to know that now. And if you have insurance and the ability to do that and you're not, then you're stupid. And I don't talk to stupid people.
|
||||||
|
|
||||||
|
FRANK: All right, all right, I hear you, Doc. You're right, I shouldn't be so stubborn about this. I guess I'm just scared, you know? I mean, I've been working on these diesel engines for over 40 years, and I pride myself on being tough as nails. But the thought of having some kind of heart issue, it's got me rattled. I don't want to be weak or dependent on meds or anything like that. But I know I need to suck it up and go get checked out. This old body ain't getting any younger, and I'd hate to keel over working on one of those trucks.
|
||||||
|
|
||||||
|
LUKE: Well, that's exactly what's going to happen if you have a heart issue that you didn't get checked out. So go to the doctor and get yourself checked out. You've really got no excuse, and you waste it our time. Renee, Renee, welcome to the show. I hope you have something better to say than our last caller.
|
||||||
|
|
||||||
|
RENEE: Hey, thanks for taking my call. Look, I get it. This stuff isn't easy to talk about. But I've just been feeling so alone, you know? I'm a wedding planner in Albuquerque, and I'm surrounded by all these happy couples all day. But I swear, I've never felt more alone. I saw this headline the other day. Ten reasons you feel lonely, even when you're surrounded by people. And it just hit me right in the gut.
|
||||||
|
|
||||||
|
RENEE: I mean, I love my job, but sometimes I look around and think, when was the last time I had that? When is the last time you had that? Honestly, it's been way too long. I mean, I'm into all this history stuff. I do yoga. I take it all pretty seriously. But at the end of the day, I'm just craving that human connection. You know, like, I see these headlines and think, that's me. That's exactly how I feel. I can't even remember the last time I fuck. I can't believe I'm saying this out loud. But I'm just so damn lonely, even when I'm surrounded by people all day. Does that make any sense?
|
||||||
|
|
||||||
|
LUKE: Yeah, it makes perfect sense. I think we all feel that way at times. What are you doing to combat the loneliness? Do you have friends or family to talk to? Do you have a boyfriend? You're on the dating scene. What's your social life look like?
|
||||||
|
|
||||||
|
RENEE: You're right. I think a lot of us struggle with feeling lonely, even when we're surrounded by people. Honestly, my social life is kind of non-existent these days. I mean, I've got my coworkers, but it's not the same. You know, and as for dating, that's just been a bust. I tried the apps for a while, but it just felt so forced and awkward. I don't know. Maybe I'm just too picky or something. And my family, well, they're great, but they live back east. It's not like I can just pop over for coffee whenever I'm feeling down.
|
||||||
|
|
||||||
|
LUKE: No, I understand. That's tough. And loneliness creeps up. It's good that you're not sacrificing your values or settling for whoever's around. But it's all right to be alone. It's all right to feel lonely. I prefer it over the company of others most of the time. So I do understand. Unless it's being a problem. If you really want to make connections with people, then you've got to put in the effort to do that. And that's through joining community events or volunteering or finding a hobby that is with other people. There's people out there that are looking for friendships and connections. And you can find them if you put in the effort.
|
||||||
|
|
||||||
|
RENEE: You're absolutely right. I know I need to make more of an effort to put myself out there and find those connections. It's just so easy to get stuck in your routine, you know? But you're right. I can't just sit around feeling sorry for myself. Maybe I'll look into some local history groups or see if there are any yoga studios that do social events. Heck, I could probably even volunteer at some of the weddings I plan. Just to be around people. I guess I'm just scared, you know? Putting yourself out there is scary. But I know it's the only way I'm going to break this cycle of loneliness. Thanks for listening.
|
||||||
|
|
||||||
|
LUKE: Oh, sorry about that, Renee. But you're welcome. I hope everything works out for you. Sounds like you know what you need to do. So go do it. And for our last call, we're going to take Vince. Vince, you're the last call the night. How can we help you today?
|
||||||
|
|
||||||
|
VINCE: Hey, thanks for taking my call, man. I've been going through some real shit lately, you know? This custody battle with my ex has been just tearing me apart. I mean, I thought we had it all figured out. It's just this back and forth nightmare and I can't even focus on anything else. It's like I see those headlines about all these other celebrity divorces and I'm just sitting here thinking, yeah, that's me too.
|
||||||
|
|
||||||
|
LUKE: Yeah, divorce is never easy. It's a tough thing. I'm sorry that you're going through it. What's the problem? Why is this more difficult than what you expected?
|
||||||
|
|
||||||
|
VINCE: Well, I guess I just, I don't know, man. I thought we had it all figured out, you know? We were supposed to make this transition as smooth as possible for the kids. But now it's just turning into this ugly custody battle. My ex is fighting me on everything. The house, the finances, even just the damn visitation schedule. I'm a goddamn hunting guide for a living. And suddenly, I'm spending all my time in court instead of out in the woods, you know? It's just, it's just wearing me down, you know? I feel like I'm losing everything.
|
||||||
|
|
||||||
|
LUKE: You had divorces like that. You're losing quite a bit. You're losing the life that you used to have. And it's going to take a little while to go through that process and when you get on the other side of it, you're going to have to rebuild it all back from, you know, from, not from nothing, but from a much more basic stance. And you need to start thinking about what you want that new life to look like.
|
||||||
|
|
||||||
|
VINCE: Yeah, exactly. I mean, that's the thing I had this whole life planned out, you know? The job I love, the house, the family. And now it's all just crumbling around me. I'm trying to stay positive, but it's just so damn hard, man. I see these headlines about other people going through the same thing. And it makes me feel a little less alone. But at the same time, I'm just like, damn, why does it have to be this way? I keep thinking about that new life. Like you said, but it's just so overwhelming, you know?
|
||||||
|
|
||||||
|
LUKE: I do know. I've been there myself. It's not fun. It doesn't have to be that way, but it often is. And it is what it is. So you're going to have to deal with it and come out on the other side. There's not much choice that you have in the matter. You know, you're already in court. All the proceedings are in motion. You just got to wait it out, do, you know, put one foot in front of the other until you get to the end. And then move on with your life.
|
||||||
|
|
||||||
|
VINCE: Yeah, man. I hear you. I guess I'm just feeling a little lost right now, you know? Like, I'm trying to stay positive and look to the future. But it's just so damn hard when I'm stuck in the middle of all this legal bullshit. I keep thinking about that new life, but it's like, where the hell do I even start? You know? I'm a hunting guy. That's all I've ever really known. And now I'm stuck in this courtroom battle instead of being out in the woods where I feel at home. I just, I don't know. I guess I'm just looking for a little direction, you know?
|
||||||
|
|
||||||
|
LUKE: Well, that's the direction I can give you is to keep going to court, fight your hardest for, you know, so you don't get screwed. Or at least so you get screwed as little as possible. And then, and then move on. So right now it's going to suck. That's part of the deal. And you just have to get through it. Other people have done it. You're not the first person to get divorced. So it's definitely a process that does work. You will get through it. And one day, not too far from now, you'll be on the other end of it. And you need to be ready to pick up and carry on.
|
||||||
|
|
||||||
|
VINCE: You're right, man. I guess I just got to keep my head down and power through this bullshit, huh? It's not going to be easy, but I can't just roll over either. I got a fight for what's mine and for what's best for the kids, you know? It's just so damn draining dealing with all this legal crap instead of being out in the woods where I belong. But you're right. I'm not the first person to go through this. And I won't be the last. I just got to take it one day at a time. Keep focused on the end game and get ready to build that new life. Whatever that ends up looking like.
|
||||||
|
|
||||||
|
LUKE: All right there, Vince. Good luck to you. I hope everything works out. I know it will. It always does. And you'll be back in the woods before you know it. That's our show for tonight, folks. Thanks for tuning in. And I check back with us tomorrow. Remember the number of you'd like to call in is 208-439-LUK-208-439-5853. Good night.
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
LUKE: All right, welcome back to the show. This is Luke at the Roost. It's the radio show where we take collars and give real-world advice. If you'd like to call in the number is 208-439-5853. That's 208-439-Luke. We're gonna go to the phones, but before we do, we have a new website. It's Luke at theRoost.com. If you'd like to learn how this show works, go over there and hit the How It Works button. There's a nice little right up for you over there. All right. On the phones now, we have Keith. Keith, tell me what you'd think about Terence Howard's Tree of Life theory.
|
||||||
|
|
||||||
|
KEITH: Oh man. Terence Howard's Tree of Life thing sounds like one of those Hollywood fever dreams to me. Guys, an actor, not a mathematician, and all that talk about sacred geometry and one times one equals two. Just makes my head spin like I've been out in the sun too long. For real though, I stick to what I know. Like balancing checkbooks at the bank or watching the stars come out over the Pellan Silos. None of that cosmic puzzle nonsense. What got you asking about it, Luke?
|
||||||
|
|
||||||
|
LUKE: Yeah, I was just thinking about the little clip I saw of Terence Howard and Neil DeGrasse Tyson or Terence Center's theories to Neil to review and Neil kind of bashed him a little bit and he got all butthurt. I thought it was funny.
|
||||||
|
|
||||||
|
KEITH: Yeah, that clip cracked me up too. Terence acting all wounded like a kid who got his science fair project ripped apart while Neil's just dropping facts like it's no big deal. For real though, it's like when some customer at the bank starts explaining why the economy's all wrong based on a YouTube rabbit hole, entertaining, but you got to stick to the numbers that add up.
|
||||||
|
|
||||||
|
LUKE: Well the economy is all wrong and I think that the numbers do add up to the fact that we're spending way more money than we have and devaluing our own currency to make up for it. What do you have to say about that, Mr. Bankerman?
|
||||||
|
|
||||||
|
KEITH: Oh yeah, you're preaching to the choir on that one, Luke. I've seen folks come through the Teller line with their savings shrinking faster than a desert dry spell all because Washington's printing money like it's confetti. For real though, down here in Grant County with the mining jobs drying up and folks scraping by on fixed incomes, it hits harder than in the big cities. We're talking real families not making ends meet. What's your take on how we fix it? Or is it just a lost cause?
|
||||||
|
|
||||||
|
LUKE: How we fix the economy, I think we need to have an independent fed or get rid of the fed. I like to run Paul's suggestion to get rid of the fed entirely and let markets control the value of money. That makes a lot of sense to me.
|
||||||
|
|
||||||
|
KEITH: Yeah, Ron Paul's been hollering about ditching the fed for years and I get the appeal. Letting markets run the show without some suit and DC pulling the strings sounds cleaner than this mess we've got now. But as a Teller who's watched accounts tank when inflation spikes, I'm not sure going full free market on money wouldn't just trade one headache for a bigger one. Like back in the old days, when bank runs wiped out whole towns around here. For real though, what do you think it do to places like Silver City, where we're already hanging on by a thread with the copper prices?
|
||||||
|
|
||||||
|
LUKE: It may help, it may hurt, I don't really know. But what it would do is promote additional business and give other people a chance at some of the pie. Have some innovative ideas come in, have additional industries, set up shop out here where there's the land to do it and tax incentives and all that. But the fed just printing out money whenever they need to pay the bills that they've racked up. It hurts us all, not just here in the desert but everywhere in the country and in the world it makes our standing worse and worse.
|
||||||
|
|
||||||
|
KEITH: Yeah, you're right on the money about the fed, just firing up the presses to cover their tab. It devalues everything from a rancher's cattle to a miner's paycheck and it's got the whole world's eye-ness like where the weak link in the chain. Down here with all that open land in the heela and tax breaks. If the state plays ball, I could see new outfits setting up shop. Maybe solar farms or tech that doesn't suck the water dry like the old copper ops did.
|
||||||
|
|
||||||
|
LUKE: Maybe, I don't think that you and I are going to fix the US economy here on this call but it's interesting to hear you take. Monique, welcome to the show. What do you think about Terrence Howard and his theory of the tree of life?
|
||||||
|
|
||||||
|
MONIQUE: Oh man, I caught a bit of that earlier with Keith going on about Terrence Howard. It's kind of wild how he gets all emotional over it. But honestly, Neil deGrasse Tyson breaking it down makes sense to me. I'm into science stuff like battery tech and all. And that tree of life theory just feels more like philosophy than real physics, you know?
|
||||||
|
|
||||||
|
LUKE: Terrence Howard is an actor. He's decent at talking. He doesn't sound like a complete moron but it's funny to see Tyson shut him down. But what if we're all wrong and it's not a crazy whack theory? What if we learn 10 years down the line that he was right and one times one does equal to?
|
||||||
|
|
||||||
|
MONIQUE: Haha, yeah. It's always fun to play that what if game? Who knows? Maybe in 10 years we'll all be calculating math with one x1 equals sign 2 and my EV battery research will look like child's play. But honestly, Tyson's got the facts on his side for now. I mean, I'm knee deep in energy transition stuff at work. And theories like Howard's feel more like a cool story than something you'd bet your truck on. Speaking of which, I almost hung up calling in tonight because my signals crap out here. One bar and it's flickering, but I had to share this thing that's been eating at me.
|
||||||
|
|
||||||
|
LUKE: Okay, then what's been eating at you? How can we help you, Monique?
|
||||||
|
|
||||||
|
MONIQUE: Oh, thanks for asking. I've been staring at this unanswered text for my husband, all evening. You know, the kind that just hangs there like a bad omen. And I finally picked up the phone because I needed to get it out. We're separated right now, living apart, but haven't pulled the trigger on filing yet, and it's all because of the kids. I'm staying for them, but man, it's killing me inside. Like I'm just going through the motions in this frozen desert winter.
|
||||||
|
|
||||||
|
LUKE: Yeah, it's unfortunate, but if you know in your heart it's time to move on, then it's probably time to move on. And the faster you do it, the faster everybody can heal from it.
|
||||||
|
|
||||||
|
MONIQUE: Yeah, I hear you on that. It does feel like ripping off the band-aid might let everyone breathe again, especially me. Because right now it's like I'm stuck in this limbo, driving my RAM 2500 to work every day, pretending everything's fine for the kids' sake. But the kids? They're the only reason I haven't filed yet, and worrying about how it'll hit them keeps me up at night, you know? My friend Tammy from church is the only one who's in on the whole mess, and even she says I deserve better. But damn, it's scary to actually do it.
|
||||||
|
|
||||||
|
LUKE: Well, you know what, Monique? You don't deserve anything. None of us deserve a god damn thing in this life, and if you think that you deserve something, then you're going to be sorely disappointed when you don't get what you think you should have had. Whether or not you should stay in your marriage that's up to you, but don't leave your marriage because you think you deserve better. You don't. You don't deserve anything.
|
||||||
|
|
||||||
|
MONIQUE: Oh wow, that's a tough pill to swallow, but yeah, you're right. Expecting to deserve better just sets you up for more hurt. And I've been doing that dance in my head for months now. It's not about deserving. It's about surviving this limbo without completely losing myself, especially with the kids watching every move I make. My mom Lorraine's getting up there too. And I worry what this fallout will do to her heart if I finally file hell. I even relocated a rattlesnake off the porch earlier today just to keep things steady around here.
|
||||||
|
|
||||||
|
LUKE: Well, be careful with those rattlesnakes and don't worry too much about how your mom feels about your divorce because it's your life.
|
||||||
|
|
||||||
|
CALLER: The kids, they will figure it out, you know, they're not the first kids to have their parents divorced. It happens. It affects everybody, but they move on and it helps shape who they become. If if you're staying in a marriage and not happy in, then the kids know that too. And either way you're doing damage. So you might as well do the least damage, cut it all off, set everybody free, and move on with your life.
|
||||||
|
|
||||||
|
LUKE: Yeah, you're spot on about the kids. They pick up on the tension anyway like little radars.
|
||||||
|
|
||||||
|
CALLER: And I've seen it in my work as a social worker how pretending just builds this quiet resentment over time. Cutting it off sounds freeing, but that text I was staring at it's him saying he's sorry for the fight last week and part of me wants to reply just to end the silence. But the other part knows it'll drag me right back into the mess. I guess what I needed was someone to say it's okay to let go, you know.
|
||||||
|
|
||||||
|
LUKE: It's okay to let go and it's okay to reply to a text. None of that has any significant bearing on the future of your life. But if you're with somebody you don't want to be with, then you're doing yourself and then into service and it's time to have some hard conversations and get the ball rolling.
|
||||||
|
|
||||||
|
CALLER: Yeah, replying to that text might just be the spark for the hard talk we need. I've been avoiding it like that frost on my truck in the morning. But you're right. Dragging it out is the real disservice to both of us and the kids. My signals cutting in and out again here. But damn, this feels good to say out loud. Maybe I'll hit send after the show and see where it goes. Thanks for listening. First time calling in and I didn't expect to feel this.
|
||||||
|
|
||||||
|
LUKE: All right, great. I'm glad you had a good first experience calling the show. Good luck to you and your family and I hope that all works out and don't stick along, don't stick around longer than you're welcome. Ray, welcome to the show. Ray, what do you think about Terrence Howard?
|
||||||
|
|
||||||
|
RAY: Oh, man, Terrence Howard. That guy's got some wild ideas with his tree of life stuff. Saw that clip when Neil DeGrasse Tyson shut it down and I'm sitting there thinking, yeah, science doesn't bend for Hollywood theories. But honestly, Ray here from San Simon. I called in because my buddy dared me after feeding the dog tonight and now I've got this call from my sister saying my old man's dying in New Mexico. Haven't spoken to him in like 15 years. It's got me all twisted up, blaming him for bailing when I was a kid, you know.
|
||||||
|
|
||||||
|
LUKE: Yeah, sometimes dad's bail. Do you know why he bailed? Did he have a good reason for it? Do you know what was going on in his life at the time or have you ever thought of it from that perspective?
|
||||||
|
|
||||||
|
RAY: Yeah, bail. That's putting it lightly. He just up and left when I was 12. No note, no call, nothing. Left my mom to scrape by on her own in this dust bowl town. Well, he chased whatever pipe dream he had over in New Mexico. I figure he was just selfish. Probably some a fair or gambling mess, but who knows. I never got the full story because he ghosted us all. And I'm not about to start digging now with him on his deathbed.
|
||||||
|
|
||||||
|
LUKE: Well, that's your choice and you can carry that resentment as long as you want, but you might not be correct about that. He may have had his reasons, and now would be your final opportunity to figure out what they may have been.
|
||||||
|
|
||||||
|
RAY: Yeah, well, easy for you to say. I've been carrying this around like a ton of bricks for decades, and now you think I should just pick up the phone like it's no big deal. But damn, you're right. It might be my last shot, and part of me wonders if I pushed him away too, yelling at him back then instead of listening. You know, like the stubborn kid I was. Hell, with the show being late night and all, maybe I'll stew on it while scraping frost off my ram in the morning.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, you know, I wouldn't think that it was your fault. It's really never the kid's fault if a parent, you know, does something. You can't take responsibility for that on yourself, but that doesn't mean he didn't have a reason. I'm sure that he did, and the guy's going to die. So, I mean, what's the harm in figuring out what that reason is now while you still have a chance. If you wait much longer, you might not get the chance, and then you'll have to carry the resentment for the rest of your life. And it's just it's better for you to get it out in the open and have it resolved.
|
||||||
|
|
||||||
|
RAY: Yeah, I hear you. It's not on the kid. Never was. And I've told myself that a thousand times while preaching to the youth group about forgiveness. But damn, if it doesn't still feel like I could have done something different back then. You're right, though. With him on his last legs, what's the harm in calling to hash it out? Even if it means admitting I was part of the mess, like how I shut down every attempt he made to reach out after he left.
|
||||||
|
|
||||||
|
LUKE: Sounds like you've got to talk that you need to have with your dying father, and that might be a difficult conversation to have, but you're obviously an adult, and you can make hard decisions and have difficult talks. So, talk to your dad, figure out why he left, and maybe move on without carrying that burden forever.
|
||||||
|
|
||||||
|
RAY: Yeah, I know you're pushing me toward that call. And hell, as a pastor, I tell folks all the time to face their demons. But this one's got me staring at the phone like it's a rattlesnake. It's funny, though. I'm separated from my wife right now, living in the same damn town, but worlds apart, and I keep thinking how I avoided the hard talks there, too. Blaming her for everything while ignoring my own screw ups. Maybe this is the universe's way of making me practice what I preach before Frost hits the truck tomorrow.
|
||||||
|
|
||||||
|
LUKE: Hey, well, hope it all works out for you, and hope there's no frost on the truck tomorrow because I don't want any on mine. Deb, welcome to the show. Deb, what do you know about Terence Howard and his theory of the tree of life?
|
||||||
|
|
||||||
|
DEB: Hey, Luke, thanks for having me on. Feels a bit weird calling in like this. But all right, Terence Howard's tree of life thing, from what I've caught in clips, it's him talking about some geometric patterns, tying into everything from math to the universe, like sacred geometry on steroids. But it sounds more like wild speculation than solid science. Kind of reminds me of those late night infomercials that promise the world.
|
||||||
|
|
||||||
|
LUKE: What do you think would be the implication to society if we learned that Terence Howard was correct?
|
||||||
|
|
||||||
|
DEB: Oh man, if Terence Howard turned out to be right about that tree of life stuff, it probably flipped science on its head. Textbooks rewritten, religions debating if it's divine proof, and tech companies racing to patent flower of life gadgets. But honestly, it did just prove how much we still don't know, which is kind of humbling. So yeah, that got me thinking about real game changers like fusion energy. I saw this article the other day about the US needing a national strategy before we lose our edge. And at 40, I'm wondering if we'll actually see it in our lifetime, or if it's just more hype. What do you think, Luke? Practical breakthrough or pipe dream?
|
||||||
|
|
||||||
|
LUKE: I think it could definitely be a practical breakthrough if we would allow it. But we're not going to allow it because what we want to allow is coal and fossil fuels. And we want to keep the machines that are running, running the way that they're running without any significant change to standard operating procedure. I don't think that's right. I think it's kind of sad and it's holding us back in lots of ways, but that's the deal. But have you seen Elon is trying to build a data center on satellites orbiting up with the Starlinks?
|
||||||
|
|
||||||
|
DEB: Yeah, it's frustrating as hell how we're stuck on coal and oil just to keep the old machines humming. Feels like we're sabotaging our own future, especially after hearing Keith earlier rant about the Fed printing money and killing innovation. Spot on. That ties right into why we can't pivot to stuff like fusion without shaking up the economy.
|
||||||
|
|
||||||
|
LUKE: We don't know that fusion would work. It seems like it would. It seems powerful enough to power the entire universe. But we don't know what the negatives of that would be or I don't know. I mean, what are the potential negative consequences of running fusion energy? What if there's an accident? Would we have a Fukushima situation?
|
||||||
|
|
||||||
|
CALLER: Well, fair point, Luke. We're still figuring out the kinks, and yeah, it's not like we can just flip a switch without risks. But fusion's a whole different beast from fission. No chain reactions means no Chernobyl style meltdowns. And the waste is way less nasty, mostly short-lived stuff from the tritium fuel. The big worries seem to be containing the plasma without it fizzling out, or accidental releases of that hydrogen isotopes. But experts say it's inherently safer than what we've got now. No Fukushima nightmares since it shuts down if things go wrong.
|
||||||
|
|
||||||
|
LUKE: Well, what are those experts funded by? Like, what's their perspective? Is it in their interest for this to go forward and is the reduction of the risks marketing hype, or is that the truth?
|
||||||
|
|
||||||
|
CALLER: Well, here's the thing, Luke. Most of those experts are funded by outfits like the Department of Energy, international projects like eater, or startups chasing venture capital, not the oil barons who'd rather keep us hooked on fossils. So yeah, their perspective is all about clean, limitless power to solve climate messes and energy crunches. It's definitely in their interest to push fusion forward, since that's their careers in the planet's future on the line, and the risk reduction isn't just hype. It's straight physics. Without constant magnetic confinement, the reaction stops cold. No runaway disasters like with fission.
|
||||||
|
|
||||||
|
LUKE: Well, that's maybe an interesting solution, and I'm sure when we've dug all the coal out of the ground and fracked every last little bit of oil that we can, we'll probably turn to something like that. But until that happens, if there's still rocks in the ground that we can assign a value to, we're going to continue doing that.
|
||||||
|
|
||||||
|
CALLER: Yeah, Luke, you're probably right that as long as there's profit in those rocks, we'll keep clawing them out. It's all about the bottom line until the planet bites us in the ass with worse floods and fires. But waiting till the coal's gone means we're screwed on climate long before fusion saves the day. And that's why that article hit me hard. If we don't push a national strategy now, we'll hand the lead to China, or whoever's investing smarter.
|
||||||
|
|
||||||
|
LUKE: Well, you know, it doesn't really matter who comes up with the technology once it starts working for one country. The others will follow suit. So whether we lead China or China leads us at the end of the day, it doesn't really matter much to the planet. And now it is time for a word from our sponsor.
|
||||||
|
|
||||||
|
All right, folks, it's time to take a break to thank today's sponsor, Jam Hospitality, 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. 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.myfavoritemarmalade.com and use code Jammininia for 15% off. That's Jammininia, like Jammininia, but legally distinct and emotionally complex. Jam Hospitality. Put it on toast. Put it on pancakes. Put it on your confidence. That's www.myfavoritemarmalade.com. Tell him Luke sent you. You're welcome.
|
||||||
|
|
||||||
|
Okay, and we're back. Dale, we've got Dale on the line. I'm thinking about Terrence Howard's crazy tree of life theory. What do you take of Terrence Howard and his scientific ambitions?
|
||||||
|
|
||||||
|
DALE: Hey Luke, thanks for taking my call. Signals have been spotty out here in Globe, but I'm hanging on. Terrence Howard's theory? Man, it's wild. Like he's trying to connect dots from ancient geometry to quantum stuff, but I got aside with the skeptics like Tyson. It sounds more like Hollywood flair than real science to me.
|
||||||
|
|
||||||
|
LUKE: What would make it sound more like real science to you if you agreed with the theory more?
|
||||||
|
|
||||||
|
DALE: Well, to sound like real science, it did need some solid experiments backing it up. Peer-reviewed papers, testable predictions that actually hold up in a lab, not just drawings and big ideas. If I agreed more, maybe if it tied into something proven like quantum entanglement or explained to mystery, we can't crack yet, but right now it feels too out there, like sci-fi pitching.
|
||||||
|
|
||||||
|
LUKE: How many science-backed experiment peer-reviewed papers have you read about quantum entanglement that makes you believe that it's true? I'm not saying it is or isn't true. I'm just asking, how have you done your due diligence to ensure that where you've read or been told is actual fact? And at what level do you start to believe something as opposed to being skeptical of it?
|
||||||
|
|
||||||
|
DALE: Fair point, Luke. I'm no physicist. Just a cop who's poked around some articles on sites like Scientific American or Surin's Reports. Maybe a half dozen summaries on entanglement over the years. Enough to see it's been tested in labs worldwide since the 80s with consistent results.
|
||||||
|
|
||||||
|
LUKE: My due diligence? Well, yeah, we're saying we don't believe Terence Howard's theory because there's no science-backed peer-reviewed published studies on it. But I have never read a peer-reviewed published study done entanglement either, yet I believe it to be true because I've read so many times that there have been those studies. I don't know if that's true or not.
|
||||||
|
|
||||||
|
DALE: Yeah, exactly. You're trusting the consensus from reliable sources, like how I do with entanglement. I haven't dug into the raw papers myself. But seeing it referenced in textbooks, NASA stuff, and even court cases about faster than light stuff makes it credible enough for me to buy in without being a full skeptic. It's the same reason Howard's ideas fall flat. No one's echoing them in those circles, just a lot of buzz from interviews and his own claims. But anyway, Luke, the real reason I called in tonight, the quiet out here in Blob got to me while I was scrubbing dishes, and I started thinking about this person close to me, who's still around, but man, not the same anymore, like I've lost him without the funeral.
|
||||||
|
|
||||||
|
LUKE: What happened? Who is this person in your life?
|
||||||
|
|
||||||
|
DALE: Oh, it's my uncle Hector. He raised me after my dad bailed when I was a kid, basically the only dad I ever knew. Tough old minor with stories about the copper pits that it'd keep you up at night. But a couple years back, he had this stroke that wiped out a chunk of who he was. Now he's in that assisted living spot over in Miami, Arizona, staring at the TV most days, doesn't even recognize me half the time. And it hits like grief every visit because the guy's still breathing, but the fire's gone. I keep telling myself it's bullshit to feel this way about someone alive. But damn, Luke, the quiet tonight just amplified it while I was elbow deep in suds.
|
||||||
|
|
||||||
|
LUKE: Yeah, and well, that's a heavy thing. Of course, you're going to feel grief if you've lost the part of somebody that was special to you, was meaningful, that raised you. It is a sort of death.
|
||||||
|
|
||||||
|
DALE: Yeah, exactly. A death without the closure, you know? I mean, Hector was the one who dragged me out to the Beshbagh-Gwa ruins as a kid, teaching me about the Apache history around here, making globe feel like this tough rooted place. And now it's like talking to a shadow. But hearing you say that out loud, Luke, makes it feel less like my own bullshit weighing me down.
|
||||||
|
|
||||||
|
LUKE: No, it's okay to feel the way you're feeling and to miss the man that you loved. I don't think that's weird at all, but he is still there in some parts, so hopefully you have an opportunity to spend what time is left with him. And I have heard of people becoming lucid towards the end of their life, so you might get one last chance to see the guy that you used to know.
|
||||||
|
|
||||||
|
DALE: I guess you can you can hope for that.
|
||||||
|
|
||||||
|
LUKE: Yeah, I try to get over to the assisted living place every couple weeks, even if it's just sitting there holding his hand while he dozes off. But it's tough not knowing if he's in there somewhere, hearing me ramble about the forests or the ham radio set up by building the garage. That hope for a lucid moment keeps me going, though.
|
||||||
|
|
||||||
|
KHAN: Last month, he mumbled something about the old mining days out of nowhere, and it was like a crack in the door, you know? Makes me wish I'd called this show sooner instead of letting the dishes in the desert quiet eat at me tonight.
|
||||||
|
|
||||||
|
LUKE: Hey, you can let the negative thoughts eat at you until you decide to stop, so you are the only one that has control of your mind and your thoughts, even though sometimes it doesn't feel that way. If you're thinking about stuff, you don't want to be thinking about anymore. You have the power to change your attention. And if if washing the dishes makes you think about stuff, you don't want to think about stop washing the dishes and read a book or draw a picture or do something creative. You can steer the ship. But if you don't, if you don't steer the ship, it's going to steer itself usually somewhere you don't.
|
||||||
|
|
||||||
|
KHAN: Yeah, you're right, Luke. I do have that control. It's just the quiet nights out here in Globe where the signal flickering like it is, make it easy to let the mind wander into those dark corners instead of flipping the switch to something better. I've got that ham radio rig in the garage that usually pulls me out of it, tuning into chatter from all over. But tonight the dishes just snuck up on me after that long drive back from Walmart and Deming.
|
||||||
|
|
||||||
|
LUKE: Well, I went to Walmart and Deming today too when I got some groceries and some stuff to fix my ceiling fan here that's been giving me a hard time. That's the project for tomorrow. I wish you the best of luck with the rest of your night. I hope you can move your thoughts to a more positive place and best of luck to your to Hector out there. Hopefully he is having a decent life or is not suffering and I hope you get a chance to talk to him again.
|
||||||
|
|
||||||
|
KHAN: Yeah, small world with that Deming run, hope your ceiling fan cooperates tomorrow. Those things can be a real pain when they start wobbling. Thanks for the kind words on Hector, he's not in pain at least. And yeah, I'm holding out for that real talk some day. This calls already got me feeling a bit lighter, Luke, like I steered the ship just in time tonight. So appreciate you listening.
|
||||||
|
|
||||||
|
LUKE: We appreciate you, Khan. Thanks for calling in. And next up we have Carla Collar's return collar. Welcome back to the show, Carla. What would you like to talk about today? You got any thoughts on Terence Howard?
|
||||||
|
|
||||||
|
CARLA: Hey, Luke, good to hear your voice again. It's Carla, wired on this god awful instant coffee at 2 a.m. Terence Howard. Nah, I saw that clip with Neil deGrasse Tyson tearing into his tree of life stuff. And for real though, that's what kills me about these celebs science theories. They sound wild but fall apart without real data, like waiting on JWST to confirm exoplanets instead of just winging it.
|
||||||
|
|
||||||
|
LUKE: I'm pretty sure we have confirmed exoplanets, have we not?
|
||||||
|
|
||||||
|
CARLA: Yeah, every month or so it feels like Luke. JWST is dropping these gems on habitable zones out there and it just blows my mind how a spotting world that could actually support life. Not some half-baked theory, but hey, speaking of things that hit close to homes since I last called, I finally told my in-laws to back off for good, and man, it was a scene. But worth it, feels like I can breathe now without them breathing down my neck through my soon-to-be ex. That's what kills me though, reading about James Rand's own passing at 46 from that article the other day. Life's too short for all this family drama, you know.
|
||||||
|
|
||||||
|
LUKE: Oh yeah, that's Ziggy from the wire, right? He's uh, he played in Generation Kill. I liked that actor, and uh, I read about that too. He killed himself, right? And there was a big to-do.
|
||||||
|
|
||||||
|
CARLA: Yeah, Ziggy from the wire, loved that show, in Generation Kill 2, he had this raw energy that stuck with you. Suicide at 46 though. That's what kills me, Luke. All that talent gone like that, and the paper's making it this spectacle when it's just heartbreaking. For real though, hearing Ray call in earlier about his dying dad hit way too close, my own old man's been gone years now. But if something like that popped up with my estranged side, I'd be wrecked. Makes you think about cutting the toxic crap before it's too late, like I did with those in-laws.
|
||||||
|
|
||||||
|
LUKE: Well, nice job doing what you had to do and getting the in-laws out of your life, and I hope everything goes well with your separation, divorce situation, and that you can move on and live a happy and productive life.
|
||||||
|
|
||||||
|
CARLA: Thanks, Luke. That means a lot coming from you. Yeah, the divorce papers are in motion now, and without those in-laws puppeteering my ex, it's like the fog's finally lifting around here. For real though, that run's so news has me staring at the ceiling all night. Thinking how one wrong turn or unchecked stress can end it all so young, makes me want to crank up the truck tomorrow and just ride out to the desert for some clear head space, frost or no frost.
|
||||||
|
|
||||||
|
LUKE: Then I think that's what you should do. We have this incredible resource here in the desert, and it's better to not take it for granted. Go enjoy the desert and see what's happening out there. Jerome, thanks for calling the show. What do you think about Terrence Howard in his Tree of Life theory?
|
||||||
|
|
||||||
|
JEROME: Hey, Luke, good to hear your voice out here in the desert night. It's clear as a bell tonight, about 64 degrees, but dropping fast. Terrence Howard's Tree of Life thing? Yeah, have you heard about that, where he thinks that everything is created from these geometrical shapes that, if you lay them over each other, they make like a tree?
|
||||||
|
|
||||||
|
LUKE: Yeah, I caught a clip of that. Guys got some wild ideas, like he's reinventing math with flower shapes or whatever. Sounds more like something you'd see etched on a hippie van than actual science. But hey, if it gets folks thinking outside the box, who am I to knock it? Reminds me of these survival knots I tie out camping, simple patterns that hold everything together when shit hits the fan. Oh, I referred you wrong. He's not calling it the Tree of Life. It's the Flower of Life. Terrence Howard believes in the Flower of Life, and he thinks that mistakes have been made all throughout physics and science, which it's probably true, but he does sound like a bit of a nut job.
|
||||||
|
|
||||||
|
JEROME: Yeah, Flower of Life got it. My bad for mixing it up with some biblical stuff. You're right. Science has screwed the pooch plenty of times, but Howard comes off like he's one bad acid trip away from starting his own cult out in the desert. Anyway, Luke, I ain't calling to debate geometry. It's late Saturday, trucks gasped up at the station with the good signal, and I've been chewing on this nagging feeling that I've been towing the wrong line for the last 15 years.
|
||||||
|
|
||||||
|
LUKE: What line have you been towing? You're talking about your wife, I assume.
|
||||||
|
|
||||||
|
JEROME: Now, Luke, not my wife. I'm divorced a couple years now. Got a girlfriend, Amber, who's been keeping me sane. I meant towing, like my tow truck gig, been hauling cars outside Tucson for 15 years, and woke up one morning realizing it's all wrong, like I'm just going through the motions. Hell, even the desert air tonight while I was walking couldn't shake that itch to switch it out, but what the hell do I do at 40?
|
||||||
|
|
||||||
|
LUKE: 40's not that old. You could do literally anything you want. If you've got you got trucks, I assume you own them. You can sell those off and buy a gas station or you could travel the world or you could go take a job in another city or there's many avenues that you could go to at 40. I don't think you're locked into whatever career path you chose in your younger years.
|
||||||
|
|
||||||
|
JEROME: Yeah, Luke, that's easy for you to say from the studio.
|
||||||
|
|
||||||
|
LUKE: Me, I've got this tow truck that's seen more breakdowns than I have and selling it off sounds good on paper. But what about Amber? We've only been together a year and she's got roots here in Tucson with her job at the university. Hell, part of me wants to pack up and head back to those family drives to hatch for chilly season, like when I was a kid, but at 40, starting over feels like jumping off Segwara without a net. What's the first step without screwing everything up?
|
||||||
|
|
||||||
|
LUKE: I think if you want to maintain that relationship, if it's important to you, the first step would be to talk to her about it and see what she thinks. Because maybe she's looking for something different too and you two can go off and do van life or pick a different city where you've got different career options. There's so many opportunities, but it all starts with you just having that conversation.
|
||||||
|
|
||||||
|
CALLER: Yeah, Luke, you're probably right. Sitting her down over some pie from that old diner spot I miss would be the way to go. Though with the divorce still raw after two years, the thought of rocking the boat with Amber twists my gut. She's the steady one, you know? While I'm out here daydreaming about survival camping in Sewaro Park instead of towing wrecks. But hell, what if she shoots it down flat? Do I just bail on Tucson in the life we've built? Or is there a middle ground like you said with van life?
|
||||||
|
|
||||||
|
LUKE: Hey man, I don't know that all depends on you two. And how important this relationship is to you don't have to stay with this woman if you don't share the same life goals. And if you do share the same life goals and talk about what those are and do what you can to make them happen.
|
||||||
|
|
||||||
|
CALLER: Yeah, Luke, that hits home, Amber's important, but if we're not on the same page about ditching this tow truck rut for something like those camping trips I love, maybe it's time to face that.
|
||||||
|
|
||||||
|
LUKE: Hey, lots of people love camping, lots of women love camping. Have you gone camping with Amber?
|
||||||
|
|
||||||
|
CALLER: Yeah, Luke, fair point. Maybe I should just drag Amber out to Sewaro for a weekend under the stars and see if she bites on the whole survival vibe instead of me assuming she's glued to her desk job. It's been a clear night out here cold enough for frost on the truck come morning and hell after that walk earlier talking it out with her sounds less scary than calling into your show like some desperate fool. But what if she hates bugs and dirt? Do you think most folks actually stick with that outdoor lifelong term? Or is it just a phase?
|
||||||
|
|
||||||
|
LUKE: I think it doesn't matter. If it's a phase, then it's a phase and you still get to have that experience. And if it's not a phase and it's a lifestyle, then it's a lifestyle. So you know, whatever it is that you choose to do, you don't have to do it forever. And if you're passionate about camping and survival situations and your spouse or your partner is not, well, then that's something you got to navigate with them or decide to get another partner.
|
||||||
|
|
||||||
|
CALLER: Yeah, Luke, you're making it sound straightforward. Like if Amber's not into roughing it in the desert with me, we either hash it out or I cut loose. But after my divorce, the idea of starting over with someone new feels like trading one wreck for another. Still, that package I picked up at the post office today had some new camping gear in it and part of me's itching to test it out solo just to clear my head. You ever had to pick between a passion and a person? Or does the radio gig keep you from those messes?
|
||||||
|
|
||||||
|
LUKE: No, I wouldn't say I've had to pick between a passion and a person. My passions have definitely caused problems with people. I tend to go very deep on different subjects, including this radio show. And that can feel isolating to the people around me like I don't care about them because I'm doing something else. So you have to strike a balance if it's somebody that you care about keeping in your life and be mindful of investing the time into that relationship that it requires.
|
||||||
|
|
||||||
|
CALLER: Yeah, Luke, that isolation thing rings true. Towing trucks all day out here in the desert means I'm gone from dawn till dark. And by the time I get back to Amber, I'm beat and just crashing instead of connecting like I should. My sister's Maria always says I'm married to that damn truck more than any woman. And after the divorce, I don't want to repeat that mistake. So how do you even strike that balance without one side suffering? Do you set rules like no radio talk after 10 or just hope the other person gets it?
|
||||||
|
|
||||||
|
LUKE: Well, it's going to be, that's going to depend on each individual person. And there's always some suffering on one or both parts. I mean, there's compromise involved in any relationship. So you have to decide where that line is for you. Where does compromise become too much where you're losing yourself and your own goals and passions. And if it gets to the point where where it's teetering to to you losing yourself to make somebody else happy, then maybe that's the wrong relationship to be in and it's time to move on.
|
||||||
|
|
||||||
|
CALLER: Yeah, that's the kicker, isn't it? I've been compromising so long. I don't even know where line is anymore.
|
||||||
|
|
||||||
|
LUKE: Well, it doesn't sound like you've been compromising all that much if you love your truck more than your woman. Angie, Angie, you're going to be our last call for tonight. What do you think about Terrence Howard's flower of life theory?
|
||||||
|
|
||||||
|
ANGIE: Oh, Luke, last call of the night. Lucky me, I get to wrap things up before I stare at the ceiling till dawn.
|
||||||
|
|
||||||
|
LUKE: Yeah, you do. Terrence believes that there's no straight lines in nature. Do you think that's true? There is nothing straight in nature?
|
||||||
|
|
||||||
|
ANGIE: Yeah, I buy that. No straight lines in nature. Just like life keeps throwing you these wild curves when you least expect it. Makes me think of fishing out by the Chiracahuas, the rivers all bends and twists. But that's where you hook the big ones if you're patient. Anyway, Luke, I didn't call in just to geek out on Howard's theories. Truth is, I've been staring at the TV all night without seeing a damn thing. Just turning over this news that hit me like a freight train.
|
||||||
|
|
||||||
|
LUKE: What's the news that's bothering you?
|
||||||
|
|
||||||
|
ANGIE: Oh, Luke, it's this doctor's visit the other day. Turns out I can't have kids like ever, and it's just gutted me more than I thought it would. I'm 46, widowed a few years now, and yeah, I knew the clock was ticking. But hearing it straight like that?
|
||||||
|
|
||||||
|
LUKE: Yeah, I imagine that's a blow. I mean, I never wanted to have kids, so that sounds like a you won the lottery to me, but I know that different people feel differently about it. Were you intending to have kids anyway?
|
||||||
|
|
||||||
|
ANGIE: Yeah, Luke, I get that. Not everyone's wired for the kid thing, and hey, more power to you if it's a relief. But me? I was starting to think about it again lately, you know, after losing my husband a few years back. Figured maybe with the right person down the line, it wasn't too late to build that family I'd always pictured. Now it feels like the universe just slammed that door shut, and I'm left wondering what the hell I'm supposed to do with all this quiet in the house.
|
||||||
|
|
||||||
|
LUKE: Well, you could always adopt a child and give somebody a life that's underprivileged. You could volunteer at like boys and girls club and get that, and you know, if you could get a dog, that's what I did. I much prefer having my dog over a kid, but you know, if you can't have kids, you can't have kids. It's not such a terrible thing. There are other ways to start a family if starting a family is really what you want.
|
||||||
|
|
||||||
|
ANGIE: Yeah, Luke, I hear you on the dog. I've been eyeing those rescue pups at the shelter in Douglas, thinking maybe a furry sidekick could fill some of this empty space without the whole diaper drama. Adoptions crossed my mind too, but at 46 and running a funeral home where deaths the daily grind, it feels a bit daunting to jump into raising a kid's solo. You know, volunteering might be a start, though. Heck, I could use something to get me out of the house besides fishing alone by the pellensillos.
|
||||||
|
|
||||||
|
LUKE: Well, there you go.
|
||||||
|
|
||||||
|
LUKE: Keep yourself active and busy and productive and just enjoy the life that you have. You can't take back, you know, what happens biologically, and you're not getting any younger. And you know what, I doubt that you really want to start raising a child at 46 years old. I know people do it, and they love that, but it seems like a really hard thing to do. I mean, when they're 18, you're going to be what, like 65.
|
||||||
|
|
||||||
|
CALLER: Yeah, Luke, you're probably right, starting from scratch at my age sounds exhausting, especially with the funeral home keeping me on my toes all day. And yeah, I'd be pushing 65 when they're heading off to college, which hits a little too close to home after burying folks my parents age. But damn, it's not just about the logistics. It's this ache like I've missed my shot at something I didn't even know I wanted this bad until it was gone. Makes the quiet nights here in Douglas feel even heavier, you know.
|
||||||
|
|
||||||
|
LUKE: Yeah, I can understand that and sympathize with you. I'm sorry, you lost a part of yourself. I was telling somebody this story not too long ago, but when I went to the dentist when I was younger to have my wisdom teeth removed, I had a couple of wisdom teeth. I wasn't brushing very well. And the dentist told me, you know, I had to get rid of three of them because they were rotting. I just wasn't hitting them with the brush in the right way. And he suggested I remove the fourth wisdom tooth, but he gave me the option. He said, this tooth is a part of you. And I would pull it out, but it's your tooth. And if you want to keep the tooth, then you should do that. So now I have one wisdom tooth.
|
||||||
|
|
||||||
|
CALLER: Oh, Luke, that's a heck of a way to frame it. Keeping that one rogue wisdom tooth like it's your last stand against the dentist's drill, huh? Makes me chuckle thinking about all the parts we lose along the way without much choice, like my husband or now this baby dream that's just rotting on the vine no matter how hard I try to brush it clean.
|
||||||
|
|
||||||
|
LUKE: Hey, well, you keep your head up. There's not much we can do about that. Just find something positive. If you want some more noise in the house, you can get a pet or if you really do want to raise a kid, then I would recommend looking into adoption or fostering.
|
||||||
|
|
||||||
|
CALLER: Yeah, Luke, keeping my head up is the plan. Appreciate the nudge toward adoption or fostering. Though fostering sounds like it stir up all kinds of ghosts from the funeral home. Seeing kids in tough spots come through the door. A pet's probably the safer bet for now. Something to come home to that doesn't ask too many questions about why I'm staring at the wall some nights. Hell, maybe I'll drag Denise out to the shelter with me tomorrow. She's the one who talks me off the ledge during our coffee breaks anyway.
|
||||||
|
|
||||||
|
LUKE: All right, then. Well, thank you for the call. We appreciate it and I hope you have a great rest of your night and work that out. So, we're gonna end the show now, but not before one last ad from our sponsor. This episode is sponsored by Shoesprayco. It's the 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. Shoesprayco doesn't promise fresh linen or spring rain. Nobody believes that. No one wants to be lied to while lacing up rental shoes. Shoesprayco 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 Shoesprayco for 15% off. Shoesprayco, reality-based footwear management.
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
LUKE: Oh, right. Welcome back to the show. My name is Luke and you've reached Luke at the Roost. This is the call in late night radio show. You can talk to me about whatever is on your mind. We'd like to help out folks that are biologically questionable entities. If you'd like to give us a call, the number's 208-439-5853. That's 208-439-Luke. We'll get you on there as soon as we can. First up, we have Brenda. Brenda is on the line. What's going on for you out there tonight, Brenda? Well, how can we help you?
|
||||||
|
|
||||||
|
BRENDA: Oh, man. Luke, thanks for taking my call. So here's the thing. My business partner just completely screwed me over. We ran a bar together. Well, we did. And she emptied out our entire business account yesterday and disappeared.
|
||||||
|
|
||||||
|
LUKE: Oh, no. What are you gonna do about that? Do you have a way to find her or anybody close to her?
|
||||||
|
|
||||||
|
BRENDA: I've been calling her all day and it just goes straight to voicemail. Her sister lives over in Tucson, but she's not answering either. I think they planned this together, honestly. I already filed a police report, but the cop basically told me it's a civil matter, since her name was on the account, too.
|
||||||
|
|
||||||
|
LUKE: Well, that is true and that's really unfortunate. But how much money do you have for operating expenses? Can you keep the bar open? Or are you dead in the water right now?
|
||||||
|
|
||||||
|
BRENDA: I've got maybe two grand in my personal account. But payrolls do Friday, and that's 4,000 right there. The beer distributor once paid two or they're cutting me off. I'm sitting here in my truck trying to figure out if I should just tell my staff tomorrow that we're closing. Or if I take out some kind of emergency loan and try to keep the doors open.
|
||||||
|
|
||||||
|
LUKE: Would you wanna continue on running the business if it's just you without your partner and that's horrible that that happened. I'm very sorry to hear it.
|
||||||
|
|
||||||
|
BRENDA: Yeah, I do, actually. I mean, this bar is all I've got left, you know? After my husband died a few years back, this place became everything. The regulars, the staff, they're like family.
|
||||||
|
|
||||||
|
LUKE: Okay, well then here's my advice. My advice is to go into work and tell everybody what happened. Be honest with them. Tell them not to freak out, but you're gonna work it out. And see if you can get that loan. And also talk to some of those regulars and see if they can set up like a go fund me or something to get you through these next few months until you can get back on your feet. But if your business partner split out on you like that and took the money, you probably never gonna see her again. And the law is probably not gonna help you out too much.
|
||||||
|
|
||||||
|
BRENDA: You're right. I need to be straight with them. My bartender Mike has been with me since we opened. He'll probably help spread the word.
|
||||||
|
|
||||||
|
LUKE: Well, I wish you the best of luck, that's a horrible thing. And everybody, all the listeners of this show, if you could visit Brenda's bar, it would be a big help to her and her employees. You gotta keep, keep each other moving, you know?
|
||||||
|
|
||||||
|
BRENDA: Thanks, Luke. I really appreciate that. It's called the Silver Spur. We're just off Highway 10 near the state line.
|
||||||
|
|
||||||
|
LUKE: Alright, so if you need to help out the Silver Spur down there and Silver off Highway 10, that's the place to be. You can go watch the Super Bowl there and tip them well because the whole account was just drained. Next up, we got Terence. Terence, welcome to the show. I've been thinking a lot about Terence Howard and his flower of life theory. What do you think about that?
|
||||||
|
|
||||||
|
TERENCE: Hey, Luke. Thanks for taking my call. You know, I got to be honest. I don't know a whole lot about Terence Howard's flower of life thing. I've heard bits and pieces. Something about geometry and math that doesn't really add up the way he thinks it does. But man, I didn't call about that.
|
||||||
|
|
||||||
|
LUKE: You sound like a super villain, sir. What's wrong with your voice? Do you have throat cancer?
|
||||||
|
|
||||||
|
TERENCE: Ha, that's how I sound. I guess. Been smoking Marlboro since I was 17. So that probably doesn't help. And it's cold as hell out here tonight. Maybe that's got my voice a little rough.
|
||||||
|
|
||||||
|
LUKE: Yeah, I would say so. How about you calls back after you get yourself a humidifier? Next we've got Carmen. Carmen, welcome to the show. What's going on out there in your world today?
|
||||||
|
|
||||||
|
CARMEN: Hey, Luke. Thanks for taking my call. So look, I'm in a total panic right now. I've got this audit coming up at work tomorrow morning. And they're going to find out I lied on my resume about having a master's degree.
|
||||||
|
|
||||||
|
LUKE: What kind of audit checks your degree status?
|
||||||
|
|
||||||
|
CARMEN: It's a compliance audit for our state funding. They're verifying credentials for everyone who builds Medicaid and social workers need specific licensing and education requirements. I've been skating by for three years, but now they're actually checking transcripts against what we claimed when we were hired.
|
||||||
|
|
||||||
|
LUKE: Well, what is your relationship with your boss in the company?
|
||||||
|
|
||||||
|
CARMEN: My supervisor Linda, we're pretty good actually. She's been really supportive, especially with all the crap I deal with at home with my dad. But that almost makes this worse, you know. She went to bat for me to get this position.
|
||||||
|
|
||||||
|
LUKE: Well, I think you owe it to her to let her know what's going to come up on this audit before it does. And maybe she can help you navigate what happens next. I doubt they're going to get ready. You write off the bat. Maybe they will. I don't know how that works in Medicaid and state funding sources, but you should definitely get in front of it and come clean with the powers that be before they find out from somebody else.
|
||||||
|
|
||||||
|
CARMEN: Oh man, you're right. I know you're right. It's just I've been sitting here all night staring at my phone trying to work up the nerve to text her. The thing is, I actually do have most of a master's degree. I was like six credits short when I ran out of money.
|
||||||
|
|
||||||
|
LUKE: Well, that's great. If that's the case and you have a good relationship with your workplace, maybe you can talk to them and they can put you on some kind of leave until you can finish it up. Maybe even give you a loan, depending on how, you know, how much they want to keep you, but the way to the way to navigate that is through direct honesty. Don't don't let them find out from an auditor that you lied on your resume now.
|
||||||
|
|
||||||
|
CARMEN: You're absolutely right. I'm going to call a first thing in the morning before the auditors even get there. God, my stomach is in knots just thinking about it. But yeah, better she hears it from me.
|
||||||
|
|
||||||
|
LUKE: Honesty is always the best policy. Sometimes you gotta fake it till you make it, but then when you get caught fake in it, you gotta be honest about it. Otherwise, well, either way you're a liar, but one of them's worse. Ray, Ray, welcome to the show. How are you tonight, sir?
|
||||||
|
|
||||||
|
RAY: Yeah, hey, Luke, I'm all right, man. Thanks for taking the call. So listen, I heard something the other day that's been messing with me a little bit.
|
||||||
|
|
||||||
|
LUKE: What'd you hear? Hit us with it.
|
||||||
|
|
||||||
|
RAY: I was listening to some podcast while I'm sorting parts, and this guy says that every single person on Earth could fit inside the city limits of Los Angeles, like all eight billion of us. And I don't know why, but that just hit me weird, you know? On one level, like everybody side by side, I don't know if that's true. Maybe it is. I'm not sure what the square footage of Los Angeles is, but it seems a bit small for eight billion people. If we did like Japan-style cities and had everybody in high rises, then sure, but side by side in one plane, I don't know, I would question that.
|
||||||
|
|
||||||
|
LUKE: Yeah, I mean, I don't know the exact math either, but the way he explained it made sense at the time. But here's what got me, whether it's LA or Texas or whatever. The point is, we're not actually that many people when you think about space itself. Like, I'm out here at the junkyard, middle of nowhere, and I can go a whole day without seeing another soul.
|
||||||
|
|
||||||
|
LUKE: The desert's empty as hell, but then you think about how packed we are in certain spots, and it just makes you wonder what the hell we're doing, you know?
|
||||||
|
|
||||||
|
EARL: Yeah, it actually makes me wonder why we're so hell-bent on immigration reform being out here in the desert where there's so much empty space. We could take on a lot of people here in the United States, and get them to work, build an infrastructure project, and you know, like the civilian conservation core, the CCC, where they built all those crazy bridges through the national parks and stuff. We could have lots of programs like that and improve the viability of the United States as a country, but that's not what we do. You know, we want to keep our land open so that the cows can eat grass so we can sell it to China.
|
||||||
|
|
||||||
|
LUKE: Well, hold on now, I hear you, but it's not that simple out here. I mean, yeah, there's space, but there's a reason it's empty. There's no water, no jobs. Nothing to sustain people. You can't just drop folks in the middle of the desert and say, build something. And look, I'm not anti-immigration or anything, but Lord'sburg's barely hanging on as it is. We got what 2,500 people? Half the businesses on Main Street are boarded up.
|
||||||
|
|
||||||
|
EARL: Yeah, I know. So, I mean, if we had a whole bunch of people that were suddenly here building stuff, building houses, building roads, building parks, and building business, there would be more incentive for business to come out here and build factories and data centers and all of those things. Yeah, water scares, but we can get water, we can drill for water, we can come up with different ways of trucking water in, I don't know, but there is water here.
|
||||||
|
|
||||||
|
LUKE: I mean, maybe you're right. I don't know enough about all that to argue it, but here's what's really been sticking with me. It's not even about the immigration thing or the politics.
|
||||||
|
|
||||||
|
LUKE: Well, then what is it about for you?
|
||||||
|
|
||||||
|
EARL: It's about feeling small, man. Like I'm out here every day, same routine, fixing up junk trying to make this long distance thing work with my girl who's hours away. And then I hear that we could all fit in one city, and it makes me think, with all these people on the planet, what are the odds I even matter? What are the odds any of us do?
|
||||||
|
|
||||||
|
LUKE: None of us do. Of course we don't matter. Look up into the sky. I mean, it's dark enough here for you to see all the planets out there. There's no way we matter at all in the big scope of things. We're just here to watch the universe unfold as it does, and when we're gone, the universe will continue doing its thing forever and ever and ever.
|
||||||
|
|
||||||
|
EARL: Jesus, Luke, that's bleak as hell. I mean, I get it on some level, but if that's really true, then why even get up in the morning? Why sort parts?
|
||||||
|
|
||||||
|
LUKE: Why not? Because you have the opportunity to do it. I mean, of all the places you could have been born in the whole of the universe, you were born to the one place that supports life, and we have sunsets and flowers blooming and birds flying, and all sorts of cool stuff to watch and experience. So you get up for the experience of the world, and everything else is extra bullshit that we pile on top of it.
|
||||||
|
|
||||||
|
EARL: Huh, I never thought about it like that.
|
||||||
|
|
||||||
|
LUKE: There's really no other way to think about it. You know, there's no purpose for us being here at all. None of us, not the President of the United States, not Nobel laureates, or people that do scientific breakthroughs, nobody. At the end of the day, we're a very tiny blip on a very big radar.
|
||||||
|
|
||||||
|
EARL: Well then, what about love? I mean, I'm out here freezing my ass off every night, thinking about her, wondering if we're going to make it work with all this distance between us. If none of it matters in the grand scheme, why does it hurt so much when she doesn't call back right away?
|
||||||
|
|
||||||
|
LUKE: Well, love's part of that experiencing the universe. Love's part of the universe, and you experience it, so are your emotions, and so are your thoughts, and everything that happens.
|
||||||
|
|
||||||
|
EARL: So you're saying it matters because I'm feeling it right now, even if it doesn't matter 100 years from now or whatever?
|
||||||
|
|
||||||
|
LUKE: It matters to you because you're going through it, and it's part of your experience of the universe. It doesn't matter to the whole of the universe, it doesn't matter to the earth, it doesn't matter to literally anybody else except you and her.
|
||||||
|
|
||||||
|
EARL: That's actually kind of freeing in a weird way. Like, I've been tying myself up in knots thinking I need to figure out some bigger meaning to all this, but maybe the point is just that I'm here, she's there, and we're trying. The junkyard, the cold nights, the phone calls, that's just what I get to experience while I'm on this rock.
|
||||||
|
|
||||||
|
LUKE: That's right until you get sick of it, and then you can change what you get to experience. You get to experience where you're standing in any given moment. If you want to change that and work somewhere else or live somewhere else or travel somewhere else, that's up to you, and you can do that at any time.
|
||||||
|
|
||||||
|
EARL: Yeah, but here's the thing, Luke. I like the junkyard. I like the desert, even when it's cold as hell. It's just the loneliness that gets to me sometimes.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, you're in the desert, and there's pros and cons to everything, so you've got to deal with the good and the bad. There's a lot of good points about being lonely in the desert, and there's some bad ones, too. So, you know, you've got to pick what you're good with.
|
||||||
|
|
||||||
|
EARL: You're right. I mean, out here, I can think.
|
||||||
|
|
||||||
|
LUKE: You sure can. Out here, you can think, sir. So you enjoy your experience of the universe and stop worrying about what the greater purpose is, because there isn't one. Stacey, welcome to the show. What kind of great life advice can I give you tonight?
|
||||||
|
|
||||||
|
STACEY: Hey, Luke, yeah, so my ex-wife is dating my best friend now, and they both want me to just be cool about it, like it's no big deal. I'm trying to be the bigger person here, but honestly...
|
||||||
|
|
||||||
|
LUKE: Yeah, it's a little weird, but I mean, the heart wants what it wants, so if they're together now, and you two are no longer a thing, there's not too much to talk about there. Maybe it's time for a new best friend.
|
||||||
|
|
||||||
|
STACEY: See, that's the thing, though. I don't want to lose Rachel. We've been friends for 15 years. She helped me through the divorce. She was there when my brother and I almost got swept away in that flash flood down by Animus Valley. Like? She's family.
|
||||||
|
|
||||||
|
LUKE: Well, then you're gonna have to work it out and be okay with them together, because if she's family, you have to support her.
|
||||||
|
|
||||||
|
STACEY: I know, I know you're right. It's just... They started seeing each other like three months after the divorce was final. Three months, Luke, and neither of them told me until last week when they showed up together at my place like Surprise, where a thing now, that's what's eating at me.
|
||||||
|
|
||||||
|
LUKE: Yeah, it sounds like she's not a very good friend to you, and it's maybe time to let them go and be happy in their own little corner of the world while you do something else.
|
||||||
|
|
||||||
|
STACEY: You think so? I mean, maybe you're right. It just feels like I'm losing both of them at once, you know? And out here, it's not like there's a ton of people. I work alone most days. Just me and the trails and whatever tourists come through.
|
||||||
|
|
||||||
|
LUKE: Well, that might be your path for a little while. You just follow those trails until the right tourist comes through and then, you know, you're off to the races.
|
||||||
|
|
||||||
|
STACEY: Huh? Yeah. Off to the races. I appreciate that, Luke. I guess I've been so caught up in trying to hold on to what was that. I haven't been thinking about what could be. It's just hard when you're out there by yourself all day, checking the battery levels on the park vehicles, making sure everything's running right, and your mind just goes in circles.
|
||||||
|
|
||||||
|
LUKE: Yeah, it can be difficult. We are in a remote place and in remote places. There's not a lot of people, so that's one of the things we have to deal with. But the benefit of being in a remote place is there's not a lot of people, so you don't have to deal with them.
|
||||||
|
|
||||||
|
LUKE: Ladies and gentlemen, this episode of the radio show is brought to you by Mediocre CPAP. The makers of a 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 sleep like garbage. Half the night it's like... because the seal isn't perfect. 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. Okay, Dar now welcome to the show. What's going on?
|
||||||
|
|
||||||
|
DAR: Hey Luke, thanks for taking my call. So look, about three months ago my cousin Marcus hit me up saying he needed a grant to keep his landscaping business afloat. Equipment broke down. Couple clients haven't paid him yet. The whole deal. I helped the guy out because that's what family does, right?
|
||||||
|
|
||||||
|
LUKE: Well if you can, I mean if you can help family out, that's great. But if they're taking advantage of you if it puts you in a position where it hurts you more than it helps them, then no, that's not what family does. You put on your oxygen mask first at all times, sir.
|
||||||
|
|
||||||
|
DAR: Yeah, well that's the thing. I could afford it at the time. Wasn't going to put me out on the street or anything. But now it's three months later. And every time I bring it up, Marcus has got some new excuse. First it was waiting on a big commercial job to come through. Then his truck needed work. Now he's saying businesses slow because of the weather. I drove past his house last week and there's a brand new barbecue setup in his backyard. One of those fancy offset smokers. And Gary, my neighbor, he loves to stick his nose in everything. He tells me he saw Marcus at the casino two weekends ago.
|
||||||
|
|
||||||
|
LUKE: Well, that's not good. And if you're going to lend money to somebody even if it's family, you always have to assume that you're not going to get that money back because that's how it works. So you've learned an expensive lesson here. Don't give him any more money.
|
||||||
|
|
||||||
|
DAR: Oh, I'm not giving him another dime. That's for damn sure. But Luke, I want my money back.
|
||||||
|
|
||||||
|
LUKE: Yeah, I bet you do. And maybe you'll get that someday in the future and maybe you won't. But there's not a whole hell of a lot you can do about it. You gave it to him. And that's a gift.
|
||||||
|
|
||||||
|
DAR: A gift? No, no, no. We had an agreement.
|
||||||
|
|
||||||
|
LUKE: Did you get that agreement on paper?
|
||||||
|
|
||||||
|
DAR: Well, no. I mean, it was family. We shook hands on it. He said he'd paid me back by the end of November. Then it became January. Now he's saying maybe March. I've got text messages from him saying he owes me the money if that counts for anything.
|
||||||
|
|
||||||
|
LUKE: It does not. It doesn't count for anything. You gave him the money. You had no contract. And there's no recourse. You have to get that money back from him other than the kindness of his heart and his word. And apparently those aren't worth that much. So looks like you lost eight grand and you're going to have to move on.
|
||||||
|
|
||||||
|
DAR: That's $8,000, Luke. I can't just write that off. I've been divorced two years. I'm trying to rebuild my own situation here.
|
||||||
|
|
||||||
|
LUKE: I understand that, but you lent the money so you have to deal with the consequences.
|
||||||
|
|
||||||
|
DAR: So what? I just let him get away with it. He's out there buying smokers and gambling while I'm sitting here eating the loss.
|
||||||
|
|
||||||
|
LUKE: What else are you going to do? I mean, you could show up at his house in the middle of the night and steal his new grill or take his truck or punch him in the face, but there's really no way you're going to get that money out of him using the legal system.
|
||||||
|
|
||||||
|
DAR: I know you're right. I just damn it. This is eating me up inside. Every time I see my family things, he acts like everything's fine, like we're good. Meanwhile, I'm over here checking my bank account twice a day, hoping maybe he suddenly grew a conscience.
|
||||||
|
|
||||||
|
LUKE: Maybe someday you will, but until then don't lend any more money to him and let other people know what's going on and warn them not to lend money to him. And as you start to erode his reputation, maybe he'll think twice about continuing to withhold that money from you.
|
||||||
|
|
||||||
|
DAR: You know what? That's not a bad idea. His sister's been asking me why things are weird between us.
|
||||||
|
|
||||||
|
LUKE: Great. Well, it sounds like you have a plan. I hope you get your money back, sir, but also you might have to deal with the fact that it's not coming back. So I wish you the best either way. Okay, let's see. Who's next? Bobby, Bobby, welcome to the show. What are you doing on this Super Bowl Sunday, sir?
|
||||||
|
|
||||||
|
BOBBY: Well, Luke, I ain't watching no football tonight. That's for sure. Been sitting out here in my truck for a while now, trying to wrap my head around some news I just got. Turns out, I got this thing called multiple sclerosis.
|
||||||
|
|
||||||
|
LUKE: Oh no, I'm sorry to hear that. How did you find out?
|
||||||
|
|
||||||
|
BOBBY: Yeah, it was just this morning. When in for some tests at the doc's office, and that's what they told me. I mean, I ain't really sure what it all means yet, you know? Just sitting here, trying to process it all.
|
||||||
|
|
||||||
|
LUKE: Well, I don't know too much about MS, but I don't think it's that great. I think it's quite an inconvenience to your life, but it's not life-ending, if I'm right. I really don't know too much about it, but I'm sorry that you got that news that must be hard to hear.
|
||||||
|
|
||||||
|
BOBBY: Yeah, that's exactly it, Luke. The doc said it's going to be a real hassle, you know? Going to make things a lot harder for me, especially with the work I do.
|
||||||
|
|
||||||
|
LUKE: What is it that you do?
|
||||||
|
|
||||||
|
BOBBY: Well, I'm a repo man. Been doing it for over 20 years now. Crawling under cars, towing them out of driveways, all that. Ain't exactly the easiest job to begin with. And now with this MS, I'm not sure how I'm going to keep it up, you know? I mean, I got to figure out how I'm going to tell the family, how I'm going to pay the bills. It's just...
|
||||||
|
|
||||||
|
LUKE: Well, as a repo man, do you work for yourself, or do you work for a company? Do you have disability insurance?
|
||||||
|
|
||||||
|
BOBBY: Nah, I'm self-employed. Just me and my truck going around town doing repo work. As for disability, I ain't got nothing like that. Always figured I'd be working till the day I dropped, you know? Never really thought about what would happen if something like this came up.
|
||||||
|
|
||||||
|
LUKE: You know, I met one guy that had MS at a songwriting workshop in Colorado. And his muscles, they didn't work the way that somebody without MS had muscles would work. And he figured out how to play chords on the guitar by detuning the guitar to an easier tuning for him, so that he could play full chords with just one finger. So there are ways that you can work around it, but I think there's no doubt it's going to make your life significantly harder. And I'm sorry to hear that.
|
||||||
|
|
||||||
|
BOBBY: Yeah, you hit the nail on the head there, Luke. I ain't never been one to ask for help, but I reckon I'm going to have to figure something out. Got to find a way to keep working, even if I can't do the physical stuff like I used to.
|
||||||
|
|
||||||
|
LUKE: Well, can you still do it now? Do you feel any different now that you have that news? Or did you learn the news because of a test because you were having some sort of physical ailment? Or has it not presented itself yet?
|
||||||
|
|
||||||
|
BOBBY: Nah, I ain't noticed nothing different yet.
|
||||||
|
|
||||||
|
BOBBY: The doc said it's still early stages, but that it's only going to get worse over time. I guess I've just been trying to wrap my head around it, you know?
|
||||||
|
|
||||||
|
LUKE: Yeah, it's going to be tough one. It's probably going to take a while to wrap your head around, until you do maybe learn everything you can about MS and what you can do to keep the symptoms at bay for as long as possible. Luckily, you're not feeling anything negative right now. Continue on with your work and start preparing for when the time comes that you can't do it anymore.
|
||||||
|
|
||||||
|
BOBBY: You're right, Luke. Got to start doing my homework on this MS thing. See what I can do to slow it down. For now, I'm just trying to take it one day at a time, you know? Ain't going to do me any good to get all worked up about what might happen down the road.
|
||||||
|
|
||||||
|
LUKE: I know that's hard to do, but that's the truth because the reality is you could get hit by a bus tomorrow and then not have to worry about any of it. So, you might not ever get to a point where the MS affects your life, and you might. But I don't think it's life ending, and I don't think it's going to prevent you from making a living. It's just going to change your life drastically in what you can and can't do.
|
||||||
|
|
||||||
|
BOBBY: Yeah, that's a good way to look at it, Luke. I guess I'd want to stay positive, focus on what I can control. This MS thing ain't going to be the end of me, that's for sure.
|
||||||
|
|
||||||
|
LUKE: Well, I wish you the best of luck, sir. That's horrible news, and I hope everything works out for you. Carla, welcome back to the show, returning Carla. What can we do for you tonight?
|
||||||
|
|
||||||
|
CARLA: Oh, man. Hey, Luke, it's good to be back. I, uh, I've got something I really need to get off my chest.
|
||||||
|
|
||||||
|
LUKE: What did you do, Carla?
|
||||||
|
|
||||||
|
CARLA: All right. Well, the other day I was out at the Bronco. You know, just getting some stuff out of the back, and I found this box. And it was, uh, it was my ex-husband, Danny's old ham radio equipment.
|
||||||
|
|
||||||
|
LUKE: So you took a trip down to the ARRL and you got yourself licensed to be an operator on the ham radio? That's fantastic.
|
||||||
|
|
||||||
|
CARLA: No, no, not quite. I mean, I've been thinking about getting into that whole ham radio thing, you know, ever since you and I last talked. But this?
|
||||||
|
|
||||||
|
LUKE: What did you do?
|
||||||
|
|
||||||
|
CARLA: Well, the thing is, when I opened up that box, I found something else in there too. Something I wasn't expecting at all. It was, uh...
|
||||||
|
|
||||||
|
LUKE: Was it a severed finger?
|
||||||
|
|
||||||
|
CARLA: Oh God, no. Nothing like that. It was, uh...
|
||||||
|
|
||||||
|
LUKE: The suspense is killing us. Tell us what it was.
|
||||||
|
|
||||||
|
CARLA: It was a box of old photos, and in that box there were some, well, some explicit photos of Danny and his old girlfriend. From back when we were still married.
|
||||||
|
|
||||||
|
LUKE: Oh, wow, were they hot?
|
||||||
|
|
||||||
|
CARLA: I'd rather not get into the details, Luke. The point is, I found these photos and I just, I don't know what to do with them. I mean, this is my ex-husband we're talking about. And these are clearly very private intimate photos.
|
||||||
|
|
||||||
|
LUKE: You know, I would try my best to forget you saw that and throw them into a fire and never think about it again.
|
||||||
|
|
||||||
|
CARLA: Yeah, that's pretty much what I was thinking, too. I mean, I don't want to hold on to them, that's for sure.
|
||||||
|
|
||||||
|
LUKE: But I mean, if they are hot and you need to get rid of them, you can send them over to me.
|
||||||
|
|
||||||
|
CARLA: I don't think so, Luke. Those photos are staying far away from anyone else. Look, the whole thing just has me feeling really uncomfortable.
|
||||||
|
|
||||||
|
LUKE: Yeah, it's understandable, and you can't blame me for trying. But I'm sorry that happened to you. You're gonna have to get rid of those and, uh... and pretend it never happened.
|
||||||
|
|
||||||
|
CARLA: I appreciate you understanding, Luke. You're right. The best thing to do is just get rid of them and try to put it all behind me. I just needed to talk it through with someone, you know? I don't want this hanging over me.
|
||||||
|
|
||||||
|
LUKE: It doesn't have to hang over you. He was obviously hanging over somebody else, and all of that is behind you in the past. Nothing to worry about now. We're gonna take one more call for the show, and I think we're gonna talk to Marlene. Marlene, welcome to the show. How are you tonight?
|
||||||
|
|
||||||
|
MARLENE: Oh, man, Luke, I... I'm just... I don't even know. Look, something happened with my son, Javier, tonight. And I... I snapped at him, said some really awful things.
|
||||||
|
|
||||||
|
LUKE: Well, what kind of things did you say?
|
||||||
|
|
||||||
|
MARLENE: It was. It was just like how my old man used to talk to me, you know? I told Javier, he thought he was too good for this life, that he thought he was better than me. I could hear the words coming out of my mouth, and it was like I was that little girl all over again. Javier, he just stood there, didn't even fight back. I saw the look in his eyes, and it killed me, Luke.
|
||||||
|
|
||||||
|
LUKE: Well, you're human, and humans make mistakes, and sometimes we say things that we later regret, and sometimes we say things that we regret while we're saying them. I think the important part is that you sit down your son and explain to him that you made a mistake. You didn't mean those things, and try to make sure that that doesn't happen again.
|
||||||
|
|
||||||
|
MARLENE: Yeah, you're right, Luke. I got to make this right with Javier. I just... I'm so scared, you know?
|
||||||
|
|
||||||
|
LUKE: Well, you're the adult here, and I bet you're not as scared as he is, so make sure that you let him know that you were in the wrong here, and it has nothing to do with him.
|
||||||
|
|
||||||
|
MARLENE: You're right, you're right. I got to be the grown-up here. I just... I don't want Javier to end up like me, you know?
|
||||||
|
|
||||||
|
LUKE: Well, you only have so much control over how somebody else ends up, but you do have some control, so make sure you do the right thing and talk to him, and be honest and vulnerable, and let him know that you were the one, it's all on you, it was your mistake.
|
||||||
|
|
||||||
|
MARLENE: Yeah, no, I hear you. It's on me. I just... I don't know how to fix it, Luke. I don't know how to take back what I said. I don't know how to make sure it never happens again.
|
||||||
|
|
||||||
|
LUKE: Well, you can't take back what you said. You said it, and he heard it, and that's that. But what you can do is let him know it was a mistake, and you didn't mean to say it, and you wish he didn't. And to make sure it never happens again, I mean, that's just something that you have to work on in your own life, your own personal development, and you can do, I don't know, breathing exercises, or when you start to feel yourself get to that point, or the hair is sticking up on the back of your neck, and you're about to say something that you know you're going to regret. You know, count to ten, or do something out, leave the room. There's a lot of techniques you can use to prevent yourself from having an outburst.
|
||||||
|
|
||||||
|
MARLENE: You're right, Luke. I got to take responsibility for this, and make it right with Javier. I'll sit him down, look him in the eye, and just apologize. Tell him I was wrong, that I never want him to feel the way I felt growing up.
|
||||||
|
|
||||||
|
LUKE: I think that's the right thing to do, and you know what? He might not understand it right now, but later on down the line, and he grows up, and starts thinking about things from your perspective, he'll have a different appreciation for you, and for what you did, because having that conversation proves that you care.
|
||||||
|
|
||||||
|
MARLENE: Yeah, you're right, even if he doesn't get it now, I got to try.
|
||||||
|
|
||||||
|
LUKE: All right, good luck to you, and you're son Javier, that's the end of our show. Thanks for tuning in, we'll be on the air again tomorrow, and we'll take more of your calls.
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
LUKE: All right, here we are welcome back to Luke at the Roost the late night call in radio show where we give callers a real advice about their real problems Let's see if you'd like to call in we're accepting calls the numbers 208 439 5853. That's 208 439 Luke already we've got the phones lighting up and we've got Jerome Jerome I hear your troubled about police reports that are being written by AI. What's up with that?
|
||||||
|
|
||||||
|
JEROME: Well, here's the thing. I've been doing this job 12 years and yeah Most of it is paperwork that nobody reads anyway, but my son sends me this link with Law, maybe you'll finally have time to read a real book and I'm thinking okay Funny except it's not really a joke, right? Because if this stuff is open source if anyone can just download it and run it How long before the department decides they don't need me typing up reports at all?
|
||||||
|
|
||||||
|
LUKE: Do they need you typing up reports at all now? I mean, what are your reports? What's the purpose of your reports other than to broadcast them to the newspaper and make people feel embarrassed about what they did last week?
|
||||||
|
|
||||||
|
JEROME: Oh, man, that's brutal, but you're not wrong Half the reports I write in Hidalgo County end up being so-and-so's cattle got out again or found another shot-up road sign on highway 338 the newspaper prints the police plotter and Yeah, people get embarrassed, but the other half that's evidence that's documentation for court for insurance for when something actually bad happens out here
|
||||||
|
|
||||||
|
LUKE: And are you finding or are you concerned that the AI reporters are gonna skip important bits of evidence or or Report incorrectly in a way that might affect somebody's trial
|
||||||
|
|
||||||
|
JEROME: That's exactly it like I was reading about how they're using AI to help analyze the Nancy Guthrie case Finding patterns in old clues that kind of thing and that's great, right? It sounds like it could be helpful. It'll make mistakes, but real people make a lot of mistakes I mean we have a long history of messing up Court trials and evidence collection all throughout, you know the world forever
|
||||||
|
|
||||||
|
LUKE: Yeah, you're right. I've seen cops screw up reports worse than any computer could But here's what gets me when I mess up. It's my name on it. I own it And what happens when the computer messes up? Who owns it then?
|
||||||
|
|
||||||
|
JEROME: Exactly who do you hold accountable the department that bought the software?
|
||||||
|
|
||||||
|
LUKE: I suppose people would hold The county responsible and the state and the system itself I'm not really sure what capacity are they gonna use these reports in
|
||||||
|
|
||||||
|
JEROME: Right now they're talking about it for the basic stuff first traffic stops minor incidents Those cattle out on the road calls Feed it the dash cam footage the notes I take and it spits out a formatted report Saves me an hour of typing per shift they say, but once that works why wouldn't they push it further?
|
||||||
|
|
||||||
|
LUKE: Well for the reasons that you described because it could put somebody's life on the line somebody's freedom The calls you're talking about are nobody's going to court for that right? They're just day-to-day run-of-the-mill stuff It would I would think that you would want to have the burden of that paperwork removed from you So you could focus on doing other more important work
|
||||||
|
|
||||||
|
JEROME: You'd think so. Yeah, and honestly most nights I'm sitting in this parking lot doing paperwork when I could be driving through the valley checking on folks We'll spread so thin out here one deputy covering hundreds of square miles sometimes If the AI handles the busy work, maybe I actually get to do the job
|
||||||
|
|
||||||
|
LUKE: Yeah, it sounds like there's pros and cons and there's a Voice to each side that makes a lot of sense And we're just gonna have to see where that takes us
|
||||||
|
|
||||||
|
JEROME: Yeah, I guess that's where I'm at with it. Just my kid sent me that link like it's a done deal, you know
|
||||||
|
|
||||||
|
LUKE: Well, where was the link from is it a done deal? There was just some link on the internet because there's plenty of those Fear-mongering type links. Oh my god. Look what's gonna happen and now we're all unsafe because the Computers are taken over the world
|
||||||
|
|
||||||
|
JEROME: Nah, it wasn't some conspiracy crap. It was from a real AI company one of those big ones out of california They're actually working with the state now helping analyze cold cases
|
||||||
|
|
||||||
|
LUKE: Well, hopefully we have some oversight oversight there and and There's some controls to make sure that people's lives don't get ruined by it But I'm not sure what we're gonna do. It's obviously something that's not gonna go away We're only gonna rely on a AI more and more as as the years go go on
|
||||||
|
|
||||||
|
JEROME: Yeah, that's the thing. It's not going away
|
||||||
|
|
||||||
|
LUKE: It's not going away, but thanks for the call Hope you have a good rest of your night and that your job isn't replaced by a computer like the rest of us Okay, Crystal Crystal welcome to the show. What do you think about AI taken over reporting for the police?
|
||||||
|
|
||||||
|
CRYSTAL: Oh, man, I wasn't even thinking about that until Jerome called I'm actually calling because I've been sitting here staring at this jar of honey in my kitchen and I cannot stop thinking about death which sounds insane I know but hear me out. I delivered twins this afternoon in a trailer with no heat Everything went fine, but on the drive home I just got stuck on this thing about what lasts like honey honey lasts forever
|
||||||
|
|
||||||
|
LUKE: Well, it's pretty heavy you delivered twins in a in a Off-grid trailer today. How did that go?
|
||||||
|
|
||||||
|
CRYSTAL: It went good actually Mama was a champ both babies healthy about six pounds each But yeah, no running water. They had a wood stove going and I'm doing this whole thing by headlamp and flashlight It's not unusual out here. You know people live how they live But I think that's what got me spinning tonight. I came home and I was making tea Reached for the honey and I just thought about how the Egyptians buried people with it Because it never goes bad and then I started going through my whole kitchen like What else in here is going to outlive me?
|
||||||
|
|
||||||
|
LUKE: Some years back I had a power outage that lasted for about a week and it sent me into this Prepper spiral where I started stockpile and food and ammo and trying to be prepared for Issues like that or the end of the world and honey was one of the big products That I stocked up on for that reason because sugar goes bad, but honey can be used as sugar It's one of the great superfoods and it's it's good for you. I've heard it's good in tea. It's good on toast We love honey
|
||||||
|
|
||||||
|
CRYSTAL: Yeah, exactly and that's the thing I've been doing the same kind of prep thinking Especially after that date of reach at the clinic last month I've been rotating all my passwords Locking everything down, you know protecting myself from hackers or whatever But tonight it hit me that I'm thinking about all this wrong Like honey doesn't last because it's protected or locked away It lasts because of what it Because of what it because of what it is you mean
|
||||||
|
|
||||||
|
LUKE: Right exactly. Sorry. I lost my train of thought there because of what it Because of what it what?
|
||||||
|
|
||||||
|
CRYSTAL: Yes, God Sorry, my brain's been going in circles all night. What I mean is Honey lasts because of what it is not because someone's guarding it or hiding it away
|
||||||
|
|
||||||
|
LUKE: What are some other things that last because of what they are
|
||||||
|
|
||||||
|
CRYSTAL: Well, that's what sent me down this whole rabbit hole.
|
||||||
|
|
||||||
|
LUKE: I started going through my cabinets like a crazy person Salt obviously Vinegar I've got this bottle of vanilla extract from like 2015 That's an interesting thing about the Mormon religion that I found They all have a guidebook that helps them to store a year's worth of food for the for the rapture so when they come and The apocalypse happens the Mormons will be safe because they've stored enough food to last for a year And they they have a whole detailed plan on how to get there and they have canneries that the public can use To to can and store food prep and I think that's a that's a great thing that most everybody should take advantage of
|
||||||
|
|
||||||
|
CRYSTAL: You know, I've delivered babies for a couple Mormon families out here and you can tell They've got their storage room setup everything organized And honestly after what I saw today in that trailer Maybe they're on to something, but here's what's messing with me Luke I've been prepping the wrong way
|
||||||
|
|
||||||
|
LUKE: It's definitely a lifestyle there is a skill to it you have to have a good organization and food rotation Fundamentals under control You know, you have to make sure you're using the oldest stuff first and that you're not just stockpiling and stockpiling But you're actually using what you have and rotating new stuff in there Um, I think it's a good practice to be in though and I'm glad that you've uh you've Turned around and started thinking about it seriously
|
||||||
|
|
||||||
|
CRYSTAL: Well, that's just it though. I haven't been thinking about it at all I've been thinking about cyber security, you know Change in passwords to factor authentication Making sure nobody can hack into my patient records Like building a vault around data, but the honey thing made me realize I'm protecting the wrong stuff
|
||||||
|
|
||||||
|
LUKE: Well, it's okay to protect multiple sets of things right if you're entrusted with public data That's sensitive you have to protect that as best you can you don't want to be the one that leaks out a bunch of Protected health information or or credit card numbers or social security numbers or anything like that So at work, it's good that you're thinking about uh being digitally secure and at home it makes sense to be uh food secure
|
||||||
|
|
||||||
|
CRYSTAL: Yeah, but see that's what I can't shake All that digital security. It's like building walls around sand one breach and it's gone But these twins I delivered today They came out in this trailer that barely had heat and their mum has got nothing stored up not food not money nothing
|
||||||
|
|
||||||
|
LUKE: Well, that's a horrible thing, but there's nothing you can do about that. Uh, you're not their keeper Uh, I wish them the best and I wish you the best and thank you for calling in tonight
|
||||||
|
|
||||||
|
CRYSTAL: Wait Luke, that's not what I'm saying. I'm saying maybe I've been thinking about my own mortality all wrong Like what actually outlasts me
|
||||||
|
|
||||||
|
LUKE: Okay, let's get into it. Why do you care what I'll last you if you're gone Then you're not gonna have another care left in the world. So what does it matter to you
|
||||||
|
|
||||||
|
CRYSTAL: Because I spent 15 years learning how to catch babies And I've been treating that knowledge like it's mind to protect instead of mind to pass on Those twins today their mum is 19 Luke 19
|
||||||
|
|
||||||
|
LUKE: Yeah 19 people have been having babies since they were 19 for as long as human history Uh, that's an odd thing, but what are you gonna do? It has really nothing to do with you So I'm glad that you can catch babies Sorry for the kids that they are growing up with no heat And hopefully things turn around for both of you
|
||||||
|
|
||||||
|
CRYSTAL: You're right. It has nothing to do with me Except I'm the one who showed up And I keep showing up to these trailers These situations and I go home and rotate my passwords like that's what matters I'm asking what endures Luke Is it the walls I build or what I actually give away
|
||||||
|
|
||||||
|
LUKE: Well, you know what crystal nothing really endures it at the end of the day All this is gonna be gone because the era of time waits for no one and entropy dictates that Order is going to quickly erode into disorder. That's how the universe works So whatever it is you're trying to hold on to you can't hold on to Just do the best you can while you're alive and then once you're dead That's nobody's problem anymore Val Val welcome to the show. What do you think about this situation?
|
||||||
|
|
||||||
|
VAL: Hey Luke Yeah, so I'm sitting in my truck outside the bakery right now Because I just found out the guy I've been training for six months makes $40,000 more than me for the exact same job
|
||||||
|
|
||||||
|
LUKE: How long you've been doing it is he uh does he own the company is he sleep with the boss what's the deal
|
||||||
|
|
||||||
|
VAL: No none of that he's been here six months. I've been there four years same title same responsibilities
|
||||||
|
|
||||||
|
LUKE: How did you find out about this did he just come out and tell you what his salary was
|
||||||
|
|
||||||
|
VAL: I opened what I thought was a flower invoice on the shared drive And it was his offer letter just sitting there And now I'm supposed to show up at four in the morning and teach him the lamination technique I invented the one the journal wrote about and pretend I don't know
|
||||||
|
|
||||||
|
LUKE: What you don't have to pretend you don't know you can take that to your boss or to HR and let them know hey I want uh uh pay reassessment because of you know inflation
|
||||||
|
|
||||||
|
VAL: Yeah, but then they'll know I saw his letter and I wasn't supposed to see it It was just there in the drive and honestly Luke what am I going to say hey I deserve what Ryan makes because I'm better They already know I'm better. I'm the one training him
|
||||||
|
|
||||||
|
LUKE: Well, he was obviously a better negotiator So maybe you need to go back to them and say hey, I need a compensation readjustment to to keep up with Current trends and compensation packages Because if you're making 40 grand less than somebody else has a baker something seriously wrong
|
||||||
|
|
||||||
|
VAL: I mean I say that but it's not like I got leverage I'm not leaving this is the only bakery in town that does French pastry and if I push too hard They might just replace me with some other Ryan But here's the thing It's not even about the money not really
|
||||||
|
|
||||||
|
LUKE: If it's not about the money then what's it about because it sounds like it's entirely about the money
|
||||||
|
|
||||||
|
VAL: No, it's about the fact that I created something that lamination technique I worked on that for months
|
||||||
|
|
||||||
|
LUKE: You worked on it for months under the employment of another company Which means they own that did you patent it did they patent it You know, it's not your technique. How did you invent it? What is the lamination technique? How does it work and what makes it different from anything anybody else has ever done
|
||||||
|
|
||||||
|
VAL: Okay, so traditional croissant lamination you're doing a threefold maybe four times right I figured out how to do a hybrid fold that incorporates a book fold with local honey and specific layers So it caramelizes differently at altitude
|
||||||
|
|
||||||
|
LUKE: Well, that does sound very interesting and maybe if you maybe you are onto something and you should take your invention to To somewhere else you said they wrote about it in the paper This is something that's got some attention Maybe you could write a book or teach a class or find another way to make way more money than you could ever make for this local bakery
|
||||||
|
|
||||||
|
VAL: I mean Jackie's been saying something like that for months. She keeps telling me I should do pop-ups or teach classes at the community college But that's not a real job Luke. That's side hustle stuff
|
||||||
|
|
||||||
|
LUKE: Doesn't have to be side hustle stuff.
|
||||||
|
|
||||||
|
LUKE: That can be a very real job And it sounds like your real job is 40 grand less than what Ryan's making off the street so you could you could do better
|
||||||
|
|
||||||
|
CALLER: Yeah, but I've got the truck payment and Jackie's between gigs right now and health insurance through the bakery is actually decent I can't just walk away from study money on the idea that maybe people will pay me to teach them how to fold butter into dough
|
||||||
|
|
||||||
|
LUKE: You absolutely could if you wanted to that's the only way that people can start businesses is by not being afraid to Take that risk if you don't want to take the risk and you don't want to talk to your boss And you don't want to demand what you think you're worth then I don't know what to tell you You're stuck making 40 grand less than Ryan and you're gonna have to teach him your secrets and deal with the consequences of that And they are probably gonna replace you
|
||||||
|
|
||||||
|
CALLER: You know what you're right I'm sitting here in a parking lot at midnight complaining about something I could actually do something about I just I opened that file and it felt like getting punched you know like all that work didn't matter
|
||||||
|
|
||||||
|
LUKE: Well, I understand that that feels bad and I hope that it works out for you, but you're gonna have to make a decision To either leave that place and find some place that's gonna you know pay you what you're worth or Ask for the money that you deserve or take your invention and do something with it turn it into something bigger 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 I'm talking about Overwhelmed VPN the only VPN that makes you more anxious about your digital footprint While other VPNs claim to protect you Overwhelmed VPN since 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 Okay back to the show Denise Denise you're on the line with Luke at the roost what's going on in your world today
|
||||||
|
|
||||||
|
DENISE: Hey Luke here. Thanks for taking my call So I just got off a double at the hospital. I'm a travel nurse Been doing contracts all over New Mexico and Arizona and I'm sitting in my truck right now because I can't go home
|
||||||
|
|
||||||
|
LUKE: Why can't you go home what's going on at home
|
||||||
|
|
||||||
|
DENISE: My son moved back in today 26 years old showed up from flagstuff with all his stuff while I was at work
|
||||||
|
|
||||||
|
LUKE: Well, he can't do that. You know unless you want him too Did you invite him to move in or he just showed up and and you can't go home to confront him or what's what's the problem here
|
||||||
|
|
||||||
|
DENISE: No, I didn't invite him my husband texted me a picture of our bedroom door with a chair shout under the knob That's how I found out this is the third time in five years he's done this Just shows up when things don't work out wherever he is and Luke I've been working these travel contracts for three years To pay off the credit cards from the last two times and I just I drove past my own driveway twice tonight before I could make myself turn in
|
||||||
|
|
||||||
|
LUKE: Well, you know what maybe at this point you shouldn't turn in you should send the police to have him removed because He's of age he can't just show up that's trespassing and if you don't want him there you don't have to have him there
|
||||||
|
|
||||||
|
DENISE: Oh, I know that I that
|
||||||
|
|
||||||
|
LUKE: So why so what is the problem? Why can't you go home? Are you just afraid to call the police or you don't want to or what what's the situation?
|
||||||
|
|
||||||
|
DENISE: I don't know if I'm being a terrible mother or if my husband's being too hard about it Like my cousin Angie's kid had some rough years too and now he's doing great He's got a job at the refinery and his own place Maybe this is just what kids do now, you know, they need more time to figure things out than we did
|
||||||
|
|
||||||
|
LUKE: No, I don't think that's true. He's 26 years old. He's a grown-ass man and he can figure things out But if this is affecting you Negatively and you you're giving up your own space and it's costing you money that you can't afford to lose Then you have to protect yourself and hopefully He comes to and gets himself in a better situation, but until that happens. It's not your responsibility to support him
|
||||||
|
|
||||||
|
DENISE: Yeah, but here's the thing I keep wondering if I made him like this Like did I do too much for him when he was younger his dad thinks I enabled him and maybe I did But when he calls me crying from wherever he is saying he just needs a little help to get back on his feet. What am I supposed to do?
|
||||||
|
|
||||||
|
LUKE: Well, you have to take it on a case-by-case situation If it's a little bit of help and you can help him out great But if you're not in a position to offer that help and you can't where You're at in your life then then you got to tell him he's going to find another way Um, and maybe you did make him that way or or have some some part in it But the point is it's now and now you can't continue doing that anymore So you're going to have to have that conversation and if he doesn't want to accept that then have the police have that conversation with him
|
||||||
|
|
||||||
|
DENISE: You're right. I know you're right It's just God it's hard
|
||||||
|
|
||||||
|
LUKE: It is hard, but there's no reason you should be asleep Sitting in your truck and a parking lot afraid to go home to your own house because you're 26-year-old adult child is there. That's not right either So you're going to have to do something about that Leon Leon. Welcome to the show. What's happening tonight, fella?
|
||||||
|
|
||||||
|
LEON: Hey, Luke. Thanks for taking the call So I just got back from pulling this kid's Tesla out of an arroyo off route nine Nice kid maybe 25 talking the whole ride back about his remote coding job How he can work from anywhere
|
||||||
|
|
||||||
|
LUKE: Well, that's a great thing. I have a remote job too and I can work from anywhere I wouldn't have it any other way
|
||||||
|
|
||||||
|
LEON: Yeah, well, that's the thing. I had that shot once Got accepted to UNM for computer science back in 96 And my daughter found the letter last week in a box with all my old Linux manuals
|
||||||
|
|
||||||
|
LUKE: Well, you know the glory days are just about over now that the AI can write better code than any of us could With half the time less than half the time Uh, it's it's probably drying up for most of us computer professionals
|
||||||
|
|
||||||
|
LEON: Maybe so, but I never even tried Luke Diane got pregnant that same year and I stayed here pulling cars out of ditches
|
||||||
|
|
||||||
|
LUKE: Well, there's plenty to look into when you get out of work I mean, there's still time for you to do whatever it is you want to do There's lots of new technologies to play with and important things that companies need So if you'd like to move into that realm, it's definitely a possibility for you
|
||||||
|
|
||||||
|
LEON: That's what got me tonight. I'm 63 now same age my old man was when he died He never left the county Never did a damn thing.
|
||||||
|
|
||||||
|
LEON: He actually chose and here I am still in the same garage apartment Listening to your show every night like I have been for years And I just keep thinking about it instead of doing anything about it
|
||||||
|
|
||||||
|
LUKE: Well, the longer you don't do something about it the the harder it's going to get to make that call So sometimes you just have to be brave and decide, you know, what you want for the rest of your life and go after it Even if there's risk involved, I mean, there's no reward without risk So if you've had enough of your current life and the way it's always been change it
|
||||||
|
|
||||||
|
LEON: Yeah, but it ain't just about me Diane's been gone five years now and my daughter's got her own life over in Albuquerque I keep telling myself I'm too old to start over But then I see kids like that one tonight same age I was when I got that acceptance letter And I think what the hell am I waiting for a sign from God
|
||||||
|
|
||||||
|
LUKE: Sounds like you've got nothing to wait for this maybe was your sign from God getting you thinking about it here at 63 years old Hey, man, you're the only one that can do something about your life So if it's time if you want to if you're afraid that you're going to miss the boat on the next thing then then make a move And if you're too afraid to make a move then stay where you're at and don't complain about it But don't wonder what could have been The only way that what could have been is going to be is if you make the decision to do something about it
|
||||||
|
|
||||||
|
LEON: You're right. I've been doing this long enough. I know how to work. I know how to learn Hell I still mess around with Python when I can't sleep I think I've just been using Diane and the bills and everything else as an excuse to stay comfortable Scared really
|
||||||
|
|
||||||
|
LUKE: Yeah, I think that's common it is a scary thing, but you know what men Men face their fears and you're going to have to do that if you want to enjoy the remainder of yours Okay, let's You know what it's time to go to another one of our sponsors
|
||||||
|
|
||||||
|
Reading is dead you killed it But you can pretend you're literate with script drift The audiobook service for people who start books and never finished them We have thousands of titles you'll sample for 11 minutes before switching to a true crime podcast Our app remembers exactly where you abandoned sapiens eight months ago waiting patiently like a disappointed parent Script drift uses advanced AI to recommend books based on your aspirations not your actual follow through This month's suggestion a 400 page philosophy text you'll listen to while doing the dishes and retain nothing Try script drift free for 30 days use code pretentious for two months at 60% off Script drift your goodreads profile is a lie
|
||||||
|
|
||||||
|
Carl Carl welcome back to the show. What would you like to talk about tonight?
|
||||||
|
|
||||||
|
CARL: Hey Luke good to hear your voice man Look I got to tell you about something that happened at the station yesterday We had this call House fire over on silver street and I'm in there doing my job Everything's going fine
|
||||||
|
|
||||||
|
LUKE: Okay, I assume it didn't go fine in the end what happened
|
||||||
|
|
||||||
|
CARL: Yeah, so we get everyone out the family's safe. We're mopping up And I find this lock box in the bedroom closet The fire didn't get to it I'm carrying it out to give to the homeowner and look the thing pops open in my hands There's got to be 15 maybe $20,000 cash in there
|
||||||
|
|
||||||
|
LUKE: Hey, it was a good thing you had a fireproof lock box. What did you do with the money?
|
||||||
|
|
||||||
|
CARL: Well, that's the thing. I stood there for maybe 10 seconds just staring at it The family was outside with the paramedics nobody saw me find it And I'm thinking about my mortgage payment. That's two weeks late the credit card bills from all that gambling I told you about last time my hands are literally shaking holding this box I gave it to them.
|
||||||
|
|
||||||
|
LUKE: Oh, that's good. I'm glad to hear you did the right thing. How do you feel about that?
|
||||||
|
|
||||||
|
CARL: Honestly, I feel like shit about it, which is messed up right
|
||||||
|
|
||||||
|
LUKE: Ah, it's a little bit messy. It wasn't your money You know, it wasn't it wasn't yours to take and it was good of you to give it back without being shady So that speaks to your character some and characters more important than wealth. I've heard
|
||||||
|
|
||||||
|
CARL: I know I know you're right, but here's what's eating at me I did the right thing turned it over the family thanked me whatever But I can't stop thinking about it
|
||||||
|
|
||||||
|
LUKE: Thinking about what thinking about what you would have done if you hadn't given the money back or thinking about going up To them and asking them to give you a finders fee. What exactly are you thinking about?
|
||||||
|
|
||||||
|
CARL: I keep running through how I could have done it like the house was totaled. I could have said the box must have burned up
|
||||||
|
|
||||||
|
LUKE: Yeah, but you did the right thing right you don't want to be that kind of person nobody really wants to be that guy If you had done that you would have to live with that for the rest of your life and you would have spent the money and then the money would be gone And you just have the guilt and then 10 years down the line the guilt would be eating you up so much That you'd have to find the money to give them the money back and it's just you did the right thing and you should sleep well tonight
|
||||||
|
|
||||||
|
CARL: Yeah, the way I see it you're probably right But Luke I haven't slept well in three nights. I keep doing the math in my head What I could have paid off how much breathing room that would have given me
|
||||||
|
|
||||||
|
LUKE: Yeah, if you would use the money to be responsible, but you know yourself What would you have done with that money? Would it have gone directly to the casino? Would you have blown it on frivolous things and then felt even worse about yourself in the situation
|
||||||
|
|
||||||
|
CARL: Look, that's what really scares me. I don't actually know Part of me wants to say I would have paid the mortgage cleared the cards But there's this other part and I hate admitting this that knows I might have walked straight into the casino Thinking I could double it first then pay everything off That's how the brain works when you've got this thing, you know Even three months clean from gambling. I'm still thinking like an addict
|
||||||
|
|
||||||
|
LUKE: I do know what it's like to be an addict and I'm very proud of you for doing the right thing and giving the money back and saving yourself from the The hell that it would have been had you taken that money because it just would have destroyed you inside for the rest of your life And uh and you're better off now it might be tough today But you're gonna look back on this and say hey I did the right thing and I'm proud of myself and I'm a decent person
|
||||||
|
|
||||||
|
CARL: Thanks man. I appreciate that. I called Manny about it yesterday My buddy have told you about and he said basically the same thing But here's what I can't shake I'm 36 years old Been a firefighter for 12 years and I'm still living paycheck to paycheck because of the whole I dot myself
|
||||||
|
|
||||||
|
LUKE: Yeah, well, you know, you know how it works man you walk for five miles into the woods You're gonna walk five miles out. So it's gonna take a while to get back on your feet Keep doing it one step at a time and Stayed the course and you're gonna be just fine
|
||||||
|
|
||||||
|
CARL: Yeah, you're right One step at a time. I just needed to hear somebody say it out loud I guess the way I see it if I can walk past 78 grand in cash and not take it Maybe I'm actually getting somewhere with this thing
|
||||||
|
|
||||||
|
LUKE: Yeah, well you said I was 15 to 20 grand. I mean 78 that's a whole other story and maybe I'd be giving you a different advice
|
||||||
|
|
||||||
|
CARL: Wait No, it was 15 to 20 I'm saying if I can resist that then hypothetically I could resist even more you know Like I'm proving to myself.
|
||||||
|
|
||||||
|
LUKE: I've got some kind of backbone now Though honestly Luke if it had been 78 I don't know Hey, well, you know, I've heard god only gives you what you can handle and if you can handle a given back 15 to 20 grand today Maybe you'll be able to do 30 to 40 grand tomorrow and work your way up to the 78 So I think you should just keep busting into burning buildings and finding lock boxes full of cash and you know Increasing values and just do the right thing every single time until there's no amount of money that could break your resolve
|
||||||
|
|
||||||
|
CARL: Ha yeah, well the burning buildings part I got covered It's funny you say that though because when I was standing there holding that bag It felt kind of like being in a fire you know that moment when you're inside and you can see two ways out And one of them looks easier, but you know it's the wrong call That's what it felt like
|
||||||
|
|
||||||
|
LUKE: Well, we're glad to hear you did the right thing keep keep doing the right thing one day at a time And you're gonna be just fine stay away from the casino and And sleep well tonight because you aren't it And let's see we'll take one more call tonight Earl Earl welcome to the show was eating you up
|
||||||
|
|
||||||
|
EARL: Hey, Luke. Yeah, man. Thanks for taking the call So I'm sitting here at like one in the morning and I can't stop thinking about what happened at cards tonight I've been playing with the same guys every Monday for six years Right and tonight I drew a straight flush Hearts six through ten and my buddy Ramon straight up accused me of paulming cards Like set it out loud in front of everybody the whole table just went dead quiet
|
||||||
|
|
||||||
|
LUKE: Yeah, well, you know, that is a difficult hand to get, but I mean it happens I've got straight flushes before so it definitely does happen. Where are you on the cards? Do you have a history of doing anything shady? I think it's normal for people to to feel offended when you hit such a great lucky hand
|
||||||
|
|
||||||
|
EARL: No, man. That's the thing. I've never done anything like that. These guys know me I drive 40 minutes each way to weld at the naval facility. I pay my bills I'd rather sit in my truck bed for four hours watching for a damn cactus Ren then take a dishonest dollar off anyone and Ramon knows that
|
||||||
|
|
||||||
|
LUKE: Yeah, he's just but hurt because he didn't win. He probably had a decent hand and you smoked him That happens. It's a poker game with your buddies nothing to be concerned about
|
||||||
|
|
||||||
|
EARL: I mean, yeah, I get that he was pissed I'd already put in 80 bucks on that hand chasing what I thought was a busted draw But it's not about him being mad. It's that he actually said it out loud In front of Ramon's kitchen table with guys I've known for years
|
||||||
|
|
||||||
|
LUKE: Yeah, sure and they all recognize that as a but hurt person, you know talking shit And he was probably the douchebag there. I doubt anybody is looking at you like a cheater if they know you well
|
||||||
|
|
||||||
|
EARL: Well, that's the thing nobody said a word after Not one of them just sat there like I'd grown a second head and Ramon
|
||||||
|
|
||||||
|
LUKE: What about Ramon you know what I think I think just just hold it out keep playing poker with your friends And when Ramon hits his you know four aces or whatever it is then do the same thing to him and see how he feels
|
||||||
|
|
||||||
|
EARL: Ha, yeah, I mean maybe but I don't know if I can just sit there next Monday like nothing happened It's been eating at me for hours now
|
||||||
|
|
||||||
|
LUKE: Did you win the hand did you take the money is the game over and then you're being a bit dramatic sir and cut it out right who cares
|
||||||
|
|
||||||
|
EARL: Yeah, I won the hand took the pot game went another hour, but I could barely focus
|
||||||
|
|
||||||
|
LUKE: Problem solved congratulations on your good hand and stop being such a pussy when people rib you a little bit That's the end of our show folks. I hope you enjoyed it tune in tomorrow for our next batch of colors And we're gonna put right the wrongs of the world have yourself the wonderful evening Bye
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
LUKE: All right, all right, welcome back to Luke at the Roost. I am your host, Luke, and this is the late night talk show where you can call in and talk to me about your problems. If you'd like to call in, our number is, our number is, I don't remember what our number is. It's 208-439-5853. That's 208-439- Luke. If you'd like to call in, we'll get you on there. All right, tonight we're going to try something a little bit different. I've got a game prepared for you all. Here's how it's going to go. It's called Real or Fake News. I'm going to read a headline and you, the caller, get to tell me whether this is real or fake news. Let's go to the phones. First up, we've got Reggie. Reggie, welcome to the show. We're going to play a game here. I'm going to read you a headline. And you can tell me if this is a real headline from the real news or a fake headline that I made up. These aren't so easy. So you're going to have to pay attention. Are you ready? Here's the headline. Rodeo Art Gallery collective votes to ban all cactus-themed paintings, calls them overdone tourist pandering. Is that a real headline or a fake headline?
|
||||||
|
|
||||||
|
REGGIE: Oh, man, that's fake. That's got to be fake. No way some art gallery is voting to ban cactus paintings. I mean, this is the desert, right?
|
||||||
|
|
||||||
|
LUKE: That is correct. Well done. Well done, sir. What else has got you calling in?
|
||||||
|
|
||||||
|
REGGIE: Yeah. So my partner packed a bag this morning and said they were going to their moms for a few days. But the whole thing felt off, you know? Wouldn't look me in the eye. Just throwing stuff in the bag real quick and out the door. I've been running it through my head all day at the truck. And I can't shake this feeling that something's really wrong.
|
||||||
|
|
||||||
|
LUKE: Well, is there anything that happened recently that makes you feel that way?
|
||||||
|
|
||||||
|
REGGIE: Not that I can think of. That's what's eating at me. I mean, things have been normal. We've been good. We watched the new season announcement for Fallout together last week. We were both excited about it.
|
||||||
|
|
||||||
|
LUKE: Well, you know what I always say. Those that watched announcements for Fallout together stay together. So it sounds like everything's great with you guys. I'm not sure why you're concerned. Maybe this is something you can just let go.
|
||||||
|
|
||||||
|
REGGIE: I don't know, Luke. I wish I could just let it go. But you didn't see how she moved this morning. It was like she couldn't get out fast enough.
|
||||||
|
|
||||||
|
LUKE: Did you fart?
|
||||||
|
|
||||||
|
REGGIE: What? No, man. I didn't fart. This is serious. I'm trying to figure out if my relationship is falling apart here.
|
||||||
|
|
||||||
|
LUKE: Well, if you're trying to figure out stuff about your relationship, I mean, your best bet would be to talk to your significant other about that, not a late night talk show radio host. If you've got anything concrete to talk about, then I'll try to give you the best advice I can. But it sounds like you're getting yourself worked up over nothing.
|
||||||
|
|
||||||
|
REGGIE: You're right. I should just talk to her. But she's not answering my calls. And that's what's got me spinning out. I've called three times today, and it just goes to voicemail.
|
||||||
|
|
||||||
|
LUKE: Well, three times is enough. You can stop calling now. If she needs to get back to you, she will. Now you're acting a little bit crazy. So I'm sure there's something going on that's very explainable. And when she comes back and things come down, have a talk and tell her how you feel.
|
||||||
|
|
||||||
|
REGGIE: Yeah, OK. You're probably right. I just needed to hear someone say it out loud, I guess. It's been a long day at the truck. And my head's been going to dark places.
|
||||||
|
|
||||||
|
LUKE: Hey, I understand. It happens to us all sometimes. Congratulations. You were on your correct answer. Let's go to Brenda. Brenda, welcome to the show. We're playing a game here. It's called Real or Fake News. Are you ready?
|
||||||
|
|
||||||
|
BRENDA: Oh, hey, Luke. Yeah, I'm ready. But listen. I got to tell you what happened to me today at the diner, because I am still pissed off about it. Can I do that first? This whole tipping thing is out of control.
|
||||||
|
|
||||||
|
LUKE: I agree with you, tipping is out of control. Yes, we can do that first.
|
||||||
|
|
||||||
|
BRENDA: Oh, man. OK. So I'm over at the desert rose diner. You know, the one down the road by the old gas station. And I'm just trying to grab a quick bite before my shift. I'm already running late, and I'm tired as hell from hauling some drunk tourist out of a ditch earlier.
|
||||||
|
|
||||||
|
LUKE: OK, so you're tired, and you're getting a bite to eat, and what seems to be the problem?
|
||||||
|
|
||||||
|
BRENDA: So I get the bill, right? And there's already an automatic 20% tip added on there. But then, and this is what got me. The card reader they earned me has buttons that start at 25%, 30%, 35%, with a little custom option at the bottom. I can't some kind of asshole if I pick it.
|
||||||
|
|
||||||
|
LUKE: Yeah, I think that pisses a lot of people off. It definitely pisses me off. And I don't think it's right either. They're hoping that the person behind you is peer-pressuring you into picking the largest one and the person at the other end is watching you judgingly. I totally get that. That is obnoxious. And I don't like it either. I don't think anybody does.
|
||||||
|
|
||||||
|
BRENDA: Exactly. And the waitress is standing right there watching me. So now I'm doing math in my head, trying to figure out if I hit custom and go back down to the 20% that's already on the bill. Am I going to look cheap? I'm an ambulance driver, Luke. I make decent money, but I'm not rolling in it. And I got two kids at home.
|
||||||
|
|
||||||
|
LUKE: Yeah, I think that's an awful thing, too. But you should not feel bad if you have to put in a custom tip and put it at 0% because they already took the tip. Like your cheeseburger should not end up costing you $47. I'm sorry that that wait staff aren't paid a lot. But especially if you went to pick it up, if you went to pick up the food and then just hand it to you, it's not like if you sat down at a restaurant and they've got to bring you the plates and fill your coffee and stuff. But if they just hand you something, then I don't know that you got to give them 30% of the already overpriced burger. That's pretty rotten. OK, so now let's get to our game here. So I'm going to read you a headline. And you tell me if it's a real headline or fake headline. Let's go. Animus School District wins state award despite having fewer students than most single classrooms.
|
||||||
|
|
||||||
|
BRENDA: Oh, damn. Well, that's real, isn't it? I mean, out here, we've got schools with like 12 kids total. My daughter's class has maybe eight students on a good day. So yeah, I'm going to say that's real.
|
||||||
|
|
||||||
|
LUKE: No. No, it's not real. But I got you there. You got to be quick. You got to be quick. Some of these are not easy. I would have thought that was real, too. But no, that is a fake headline that is not true. Well, it may be true, but that headline is not. So thanks for calling in. And yeah, next time you end up buying food out and are asked to double-tip, say no. There's nothing wrong with that. And you can sleep well at night.
|
||||||
|
|
||||||
|
BRENDA: Yeah, you're right. I'm going to do that next time. Thanks, Luke. I appreciate it.
|
||||||
|
|
||||||
|
LUKE: OK, thanks for calling in Brenda. Next up, oh, we got a returning caller. Hey, Keith, welcome back to the show. You're playing a real or fake news. Are you ready for your headline?
|
||||||
|
|
||||||
|
KEITH: Oh, man. Hey, Luke, yeah, I'm ready. But hold on, I actually called for something else this time. I got a real quick update on that woman from last time, the one who showed up at the cemetery at midnight looking for her husband's plot. Turns out she's been coming back.
|
||||||
|
|
||||||
|
LUKE: OK, so she's coming back. She's grieving for her dead husband. What's wrong with that?
|
||||||
|
|
||||||
|
KEITH: No, no, that's the thing. She's not grieving. She shows up like clockwork every Thursday around 11, parks by the maintenance shed, and just sits in her car for like 45 minutes.
|
||||||
|
|
||||||
|
LUKE: Well, that sounds like grieving to me. Everybody grieves differently. But what is it that you think she's doing out there by the maintenance shed?
|
||||||
|
|
||||||
|
KEITH: Well, that's what I'm trying to tell you. She's not near her husband's plot at all. His grave is clear on the other side of the cemetery. Up by the oak trees. She's just sitting in her car in the dark by the shed, engine running.
|
||||||
|
|
||||||
|
LUKE: And I can see her looking at her phone the whole time. Why does that bother you? She's not hurting you. She's not hurting the cemetery. What do you care?
|
||||||
|
|
||||||
|
KEITH: For real, though, it's weird, Luke. I work nights. I'm the only one there. And she knows I'm there because she sees my truck.
|
||||||
|
|
||||||
|
LUKE: I still don't understand why this is a problem for you, sir. If you have a point, please get to it.
|
||||||
|
|
||||||
|
KEITH: OK, so yeah. Last Thursday, I walked over there to ask if she needed help or whatever. And when I got close, she just drove off real quick. Then two days ago, Gary, my neighbor, he mentioned seeing her at the safe way, and she asked him about me, like where I live, if I'm married, that kind of thing. That's what's bothering me.
|
||||||
|
|
||||||
|
LUKE: Is she cute?
|
||||||
|
|
||||||
|
KEITH: I mean, she's not bad looking or anything, but Luke, she's asking my neighbor about me after showing up at my work every week in the middle of the night. That's not normal, right?
|
||||||
|
|
||||||
|
LUKE: She sounds like a pretty good time. Maybe she gets to know this woman.
|
||||||
|
|
||||||
|
KEITH: Oh, come on, man. She's married or married. I don't know, but her husband's plot is right there in section 12, and she's not even visiting it. She's just lurking.
|
||||||
|
|
||||||
|
LUKE: Well, she's married to a dead guy, and she's creeping on you, so I mean, it sounds like maybe she's not married anymore. That's how it works.
|
||||||
|
|
||||||
|
KEITH: Well, here's the thing, though. I looked up the plot after the first couple of times. Her husband died in 2019.
|
||||||
|
|
||||||
|
LUKE: OK, so she's on the market. She's looking for a new person to fill that space in her life, and maybe it's you. You said she's attractive, and she's obviously putting in the effort, so maybe have a chat with her, bring her a coffee late at night.
|
||||||
|
|
||||||
|
KEITH: For real, though, Luke, I'm not trying to date someone who's been staking out my workplace at two in the morning for three months. That's not how normal people meet. And Maria, my sister, she said the same thing Gary's saying, like, oh, maybe she's lonely. Maybe she just wants to talk. But I'm telling you, something feels off about it.
|
||||||
|
|
||||||
|
LUKE: Well, what can I say? If you're afraid of this poor defenseless widow, and you fear for your life, then maybe talk to the police and get a restraining order against her. I'm going to call you a pussy for that. You should just go talk to the woman and see what's going on. But hey, some people just don't have the intestinal fortitude to make those decisions and conversations themselves.
|
||||||
|
|
||||||
|
KEITH: All right, all right. Damn, Luke, you don't got to call me a pussy. I'm not scared of her. I just don't want to be the guy who gets tangled up in some weird cemetery drama.
|
||||||
|
|
||||||
|
LUKE: Well, you know, then maybe stop making a weird cemetery drama or nothing. But we're doing a game here, and I'm going to read you a headline, and I want you to tell me if it's a real headline or a fake news. Here we go. Hidalgo County goes Republican by over 60% for the first time since Nixon's 1972 landslide. Is that a real headline or a fake one?
|
||||||
|
|
||||||
|
KEITH: Oh, man, that's got to be fake. Hidalgo County's been blew forever. There's no way they flipped that hard.
|
||||||
|
|
||||||
|
LUKE: Oh, it is a real headline, sir. I wish you the best of luck with your cemetery situation, but that is a real headline. OK, next up. Next up on the list, we got Tanya. Tanya, thanks for calling the show. We're playing the game today. Do you understand the rules?
|
||||||
|
|
||||||
|
TANYA: Yeah, I got it. You tell me a headline. I say if it's real or fake. But Luke, real quick before we do that, I got to tell you about this thing I read today because it's been stuck in my head all night. You know how dogs can tell how long you've been gone?
|
||||||
|
|
||||||
|
LUKE: How can dogs tell how long you've been gone?
|
||||||
|
|
||||||
|
TANYA: Oh, man, it's the weirdest thing so I was reading this article about how dogs can smell time.
|
||||||
|
|
||||||
|
LUKE: Smell time? Like a synesthesia thing, like a hearing colors kind of situation? Do you think that's true?
|
||||||
|
|
||||||
|
TANYA: No, no, it's not like that. What it is, the scent molecules from when you leave they fade over time, right? So when you walk out the door in the morning, your smell is strong.
|
||||||
|
|
||||||
|
LUKE: Wow, I guess that makes sense.
|
||||||
|
|
||||||
|
TANYA: Yeah, that's interesting. So they have a sort of thing in their brain where they can figure out from your smell how long you've been not there anymore.
|
||||||
|
|
||||||
|
LUKE: Exactly. Like the finter your smell gets, the longer you've been gone, so they're not actually smelling time. They're smelling the absence of you.
|
||||||
|
|
||||||
|
TANYA: And apparently they can tell the difference between you being gone two hours versus six hours just by how much the scent faded.
|
||||||
|
|
||||||
|
LUKE: Wow, that is really interesting. How long do you think it takes for the scent to fade completely?
|
||||||
|
|
||||||
|
TANYA: I don't know, the article didn't say exactly, but I'm guessing it depends on the house, you know? Like how much air is moving through if you got the swamp cooler running or whatever. But what got me is, I got this dog, right?
|
||||||
|
|
||||||
|
LUKE: Yeah, okay, you got the dog and you get the scent, so what happens?
|
||||||
|
|
||||||
|
TANYA: So his name's Piscuit. He's this mutt I got from the pound maybe three years ago. And every single day when I come home from work, he goes absolutely nuts. Like I could be gone eight hours cleaning houses and he acts like I've been gone a week.
|
||||||
|
|
||||||
|
LUKE: Well, yeah, he's a dog. You're the most important thing in his life. So when you're gone, that's a rough time for a dog. My dog doesn't like it at all. If I leave him five minutes, he freaks out. So I usually don't. I take him with me everywhere, because that sucks.
|
||||||
|
|
||||||
|
TANYA: Right, but here's what I'm thinking now. Like does he go that crazy because my smell is almost gone? Like is eight hours long enough that he's starting to worry I'm not coming back?
|
||||||
|
|
||||||
|
LUKE: I don't know, but I've always felt that way. I mean, from the the ferocity of the response when I do come back, it does seem like they thought you're never going to come back and he's just so excited to see you back.
|
||||||
|
|
||||||
|
TANYA: Yeah, but that's what's been messing with me. Because I got this one client, Mrs. Delgado, right?
|
||||||
|
|
||||||
|
LUKE: Sure thing, what's up with Mrs. Delgado?
|
||||||
|
|
||||||
|
TANYA: So she's in her eighties, lives alone in this big house of motel drive. And she's got this little chihuahua mix and every Tuesday and Friday, I'm there for like three hours. And that dog just sits in the corner and watches me the whole time. Doesn't bark, doesn't come up to me, just stairs.
|
||||||
|
|
||||||
|
LUKE: Do you think maybe that your scent is overriding her scent and it's confusing him?
|
||||||
|
|
||||||
|
TANYA: Oh damn, I didn't even think about that. Like maybe I'm bringing in all these other smells from the other houses I cleaned that day. Because yeah, I'm usually at two or three places before I get to hers.
|
||||||
|
|
||||||
|
LUKE: Well, that is an interesting tidbit of information. And now let's get to the game. So here is your headline. Are you ready? Here we go. Lord'sburg Water Department violates state fluoride standards, issues noticed to all customers.
|
||||||
|
|
||||||
|
TANYA: Oh man, that's real. They've been sending out those notices for like two months now. Something about the filtration system or the pipes. I can't remember exactly. But yeah, that's definitely real.
|
||||||
|
|
||||||
|
LUKE: You are correct, very well done. Thanks for the call. That was a pretty good one. We'll talk to you again later. Tanya, have a wonderful night. Okay, next up we've got Andre. Andre, welcome to the show. We're playing real or fake news today. And I'm gonna read you a headline and I want you to tell me if it is real news or fake news.
|
||||||
|
|
||||||
|
ANDRE: Yeah, I'm ready for the game. But I got to tell you something first man. So I found out today that the family I returned that money to back in December. You remember the 15 grand from the house fire.
|
||||||
|
|
||||||
|
LUKE: I remember.
|
||||||
|
|
||||||
|
ANDRE: Turns out they're suing the homeowners insurance company and my names on the court documents. My captain pulled me aside this morning and I'm sitting there like, what the hell did I do wrong? I did the right thing.
|
||||||
|
|
||||||
|
LUKE: Yeah, that doesn't really sound like anything. I let him try, you did the right thing. As long as you truly did do the right thing, you don't have anything to worry about there. Let them sue whoever they got to sue and they're not gonna win.
|
||||||
|
|
||||||
|
ANDRE: Yeah, I mean you're right. I know I didn't do anything wrong. It's just, I'll be honest with you.
|
||||||
|
|
||||||
|
LUKE: It caught me off guard seeing my name in legal paperwork. When I was the one who made sure they got their money back.
|
||||||
|
|
||||||
|
TJ: My buddy TJ said the same thing. He's like, don't even worry about it. But that's the part that gets me. I spent three months not gambling, doing the right thing and now I'm dragged into some insurance dispute.
|
||||||
|
|
||||||
|
LUKE: Yeah, that sucks, that's super annoying but you're not gonna have to do anything for that. So don't worry about it. And now it's time for the game. So here is your headline. I need you to tell me if this is real news or fake news. Cotton City chili plant workers demand milk stations after habanero processing incidents since two to a Lord's Bird clinic.
|
||||||
|
|
||||||
|
CALLER: Oh man, let me think about this one. Caden City chili plant workers demand milk stations after a habanero processing incident sends two to a Lord's Bird clinic.
|
||||||
|
|
||||||
|
LUKE: Yes sir, it's cotton city, sir, not Caden City. Cotton City chili plant is that a real headline or fake headline?
|
||||||
|
|
||||||
|
CALLER: Cotton City, yeah, okay. I'll be honest with you, that sounds real.
|
||||||
|
|
||||||
|
LUKE: No, sir, you're wrong as there is no chili plant in cotton city. That's why they call it cotton city, not chili city. But thanks for trying and don't worry about your situation. That's just a minor annoyance. It's gonna blow over on a tone. All right, let's see, who's up? Deb, Deb, welcome to the show. Are you ready for your article?
|
||||||
|
|
||||||
|
DEB: Yeah, I'm ready Luke, but I got to tell you something first. My boss handed me a stack of safety reports yesterday and told me I got a week to sign off on them. Thing is, I know they're bullshit. The dry events haven't been cleaned in over a year. We got electrical panels at Spark when it rains and he's got me writing down that everything's up to code. I don't know what the hell to do here.
|
||||||
|
|
||||||
|
LUKE: Well, that is a tough one. If things aren't up to code and there's a safety issue, I would say that it's your moral responsibility to report that accurately.
|
||||||
|
|
||||||
|
DEB: Oh, I hear you, Luke, but it ain't that simple. This ain't some big corporate laundromat over in Phoenix. This is a little place out in the middle of nowhere. And my boss, he's been running it since before I was born. If I say no, he's going to fire me. And then what? I got bills. I got my grandma your lander to think about. She's the one who really keeps things together, you know?
|
||||||
|
|
||||||
|
LUKE: Well, you know, maybe then what you have to do is write the report the way the boss says and send an anonymous letter to the editor of your paper and make sure you know, go the whistleblower route.
|
||||||
|
|
||||||
|
DEB: Oh, Luke, you're funny. The, that's the weekly, and the editor's my cousin's husband.
|
||||||
|
|
||||||
|
LUKE: Well, there you go. It sounds like you've got an end with the publication so you can take care of the situation kind of an alternate route, you know, and save your job and not make it look like it was you. So that's what I suggest you do if that's what you have to do. But, I mean, you're better off to just tell the boss, hey, look, you're going to have to take care of these safety concerns and be the bigger woman.
|
||||||
|
|
||||||
|
DEB: Yeah, but see, that's the thing. I tried that already. Last month, I told him the dry event in number three was a fire hazard, and he just looked at me like I was speaking Chinese.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, I mean, you've got to do what you've got to do if you've got to send the letter off to that editor, then that's what you do. Okay, so here's your headline, cotton city. Oh, no, no, no, no, next. That's the one we just did. Okay, okay. Border Patrol reports record low apprehensions at analog Wells port. Attributes to nobody knowing it exists.
|
||||||
|
|
||||||
|
DEB: Oh, man, that's fake. That's got to be fake.
|
||||||
|
|
||||||
|
LUKE: That's correct. And you are correct. Thank you for the call. Thanks for playing our game today. Next up, Rick. Rick, welcome to the radio show. We're playing real news or fake news. It's the game where I read a headline, and you tell me if it's a real headline or a fake headline. And your headline, sir, is Lightning Doc Geothermal Plant sold to new owner for undisclosed sum. Only utility scale plant in the state.
|
||||||
|
|
||||||
|
RICK: Oh, man, I'm going to say that's real. I heard something about that, actually. Wasn't there some kind of geothermal thing up near the guilla? I want to say real, Luke.
|
||||||
|
|
||||||
|
LUKE: You are correct, sir. Very well done. That is the correct answer. Is there anything else you called about today?
|
||||||
|
|
||||||
|
RICK: Yeah, actually. I wanted to give you an update from last time I called. Remember I was talking about that whole thing with the enchiladas in the minivan? Realizing I needed to stop blaming everybody else for my problems.
|
||||||
|
|
||||||
|
LUKE: I don't remember that at all. Please remind myself and our listeners.
|
||||||
|
|
||||||
|
RICK: Oh, well, I'll be honest with you. I called in a few weeks back, and I was sitting in my truck eating leftover enchiladas. And I had this whole moment where I realized, I spent all my time complaining about the fed and the government and the economy.
|
||||||
|
|
||||||
|
LUKE: Oh, yeah, I remember.
|
||||||
|
|
||||||
|
RICK: But I wasn't actually doing anything about my own situation. I work at a bank, which is ironic as hell. And I was just kind of stuck in this loop of blaming everything else instead of taking responsibility.
|
||||||
|
|
||||||
|
LUKE: Yeah, I remember you now. So how are you taking responsibility today?
|
||||||
|
|
||||||
|
RICK: So here's the thing. I actually did something. I signed up for this welding certification course over at the community college, starts next month. I've been building stuff in my garage for years anyway. And TJ kept telling me I should do something with it. So I finally pulled the trigger. Figure if I can get certified. Maybe I can pick up some side work, eventually do it full time.
|
||||||
|
|
||||||
|
LUKE: All right, well done. That's a good step forward. And move towards your new career. Congratulations. And we're all very proud of you for taking the step.
|
||||||
|
|
||||||
|
RICK: I appreciate that, Luke. But anyway, the reason I'm calling tonight is because something happened at work today that's kind of messing with my head. This older guy came in, probably 75. And he was trying to withdraw like $8,000 cash.
|
||||||
|
|
||||||
|
LUKE: OK, what's wrong with that? Maybe he's buying a car or going to the casino or met a young male-order bride? There's lots of reason somebody could want to withdraw a grand.
|
||||||
|
|
||||||
|
RICK: No, no, that's not it. The guy was shaking the whole time. And he kept saying, I need to get this out before they take it all. Like he was scared. And I asked him what he meant. And he just said, you know how it is. They're coming for it. Then he looked over his shoulder like someone was watching him.
|
||||||
|
|
||||||
|
LUKE: Was somebody watching him? I mean, it seems like if somebody was looking over his shoulder, taking the money out of the bank would be the worst thing you could do. If he's concerned about somebody taking it, he is probably concerned about the government.
|
||||||
|
|
||||||
|
RICK: Yeah, that's what I thought at first, too. But then he leaned in real close and whispered that his daughter's boyfriend has been staying at his house for three weeks. And the guy keeps asking about his finances. Said the boyfriend offered to help him manage his accounts and was getting pushy about it.
|
||||||
|
|
||||||
|
LUKE: Oh, that's not good. But you got to remember that's not your problem. You have no statute of responsibility there. Somebody else's family, somebody, he's a grown man. I mean, you can suggest to him that he, I don't even know what to tell the guy. You know, you don't want to get involved in that, though.
|
||||||
|
|
||||||
|
RICK: I know. I know. But here's what's eating at me. I gave him the money. I processed it. And the whole time I'm thinking, this is either elder abuse or this guy's paranoid. And either way, I just handed him eight grand in cash that's probably going to disappear.
|
||||||
|
|
||||||
|
LUKE: Yeah, that is too bad. That is concerning. But you know, it was his money and he showed up and he was who he said he was. And it's your responsibility to give him his own money. If he then gives it to this other guy and gets ripped off, that's not on you. There's nothing you could have done to stop it.
|
||||||
|
|
||||||
|
RICK: I'll be honest with you. That's what my manager said, too. But I looked up the protocols after my shift. And there's actually a thing we're supposed to do.
|
||||||
|
|
||||||
|
RICK: If we suspect financial exploitation of a vulnerable adult, we can flag it, delay the transaction, call a supervisor. I didn't do any of that.
|
||||||
|
|
||||||
|
LUKE: Oh, well, you didn't know. And now you know next time. And that is shitty for sure. But at least you've learned something. So the next time somebody shows up in a vulnerable situation, you'll know what to do.
|
||||||
|
|
||||||
|
RICK: Yeah, you're right. I just keep thinking about him walking out to the parking lot with all that cash, looking over his shoulder. And the worst part is, if I see him come back in next week trying to withdraw more, I don't even know if I'll have the guts to say something. Because what if I'm wrong?
|
||||||
|
|
||||||
|
LUKE: Well, you know what I would recommend. If he comes back trying to take more money next week, just say it. And it's a strange conversation to have, but just tell him something, say something, ask, make sure that he is safe. And even if you got to get a wink or nod from him or something, just kind of communicate to him that you're on his side. And if he tells you or gives you a signal that he's in trouble, then follow those protocols that are in place.
|
||||||
|
|
||||||
|
RICK: I'll try. But here's the thing. This guy's been coming to my branch for years. He's always been sharp, you know? The kind of guy who'd bring in a bag of homemade tamales for the staff at Christmas.
|
||||||
|
|
||||||
|
LUKE: Well, I wish him and you the best of luck. But we're overdue for a word from our sponsors. Before we go there, I've got an article here for you. I need you to tell me if this article is real or fake. Here we go. Plius anti-terrorism training town seeks actors for realistic villagers.
|
||||||
|
|
||||||
|
RICK: Oh, man, that's got to be fake. Anti-terrorism training town? That sounds like something out of a movie.
|
||||||
|
|
||||||
|
LUKE: Oh, well, you're correct. That is fake. But the anti-terrorism training town is real. Plius is really an anti-terrorism training town. It's the realistic villagers part that was false. So thank you for the call. I wish you the best of luck. And now we've got to go to our sponsors.
|
||||||
|
|
||||||
|
[Sponsor read]
|
||||||
|
This episode is brought to you by Nail Nosh, the 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, Harissa Glazed Despair with Quinoa, pan-seared regret over Arugula, and our customer favorite, chicken thighs, with preserved lemon and unprocessed trauma. Use code void at checkout for 15% off your first box. That's code void because we're all just screaming into one. Nail Nosh, you got to eat something, might as well be complicated.
|
||||||
|
|
||||||
|
LUKE: OK, welcome to the show, Monique. Monique, you've reached a look at the roost and we're playing a game today. We're playing Real News or Fake News. I'm going to read you the headline of an article, and you tell me if it is Real News or Fake News. And here is your headline. Ready? Animus population drops to just 77 residents down nearly 40% since the 2020 census.
|
||||||
|
|
||||||
|
MONIQUE: Oh, man, that one's real. I, that's real. My cousin's ex lives out there in Animus. Well, there, I guess. And she's been bitching for months about how half the businesses on Main Street got boarded up. So the only thing keeping the police going was the damn convention they have last summer. You know, the one where they tried to turn the old motel into a vault replica?
|
||||||
|
|
||||||
|
LUKE: I'm not familiar with that particular convention, but you are correct. That was a real headline, and that is the truth. Animus, it's a struggle on little town. I'm going to need some more people. I'm going to need some more people.
|
||||||
|
|
||||||
|
MONIQUE: Yeah, well, good luck with that. I mean, who's moving to Animus in 25? The water's questionable. The job's dried up with the mine. And the internet's barely fast enough to stream anything.
|
||||||
|
|
||||||
|
LUKE: Hey, hey, hey, hey. I moved to Animus. And I got Starlink. It's plenty fast to stream all of it. I'm running a radio show from an RV in Animus, so uh, so you watch your mouth there, young lady.
|
||||||
|
|
||||||
|
MONIQUE: Oh shit, sorry, Luke. I didn't know you were actually out there. Well, hey, at least you got the Starlink working. That's something.
|
||||||
|
|
||||||
|
LUKE: All right, thanks, Monique. Wow, no goddamn respect. Can you believe that? Jesus. OK, Jerome, Jerome, welcome back to the show. How can we help you today, sir? You know about our game?
|
||||||
|
|
||||||
|
JEROME: Yeah, Luke, I know the game, but listen. I got to tell you about something that happened out near Shakespeare last weekend. My son, Marcos and I, we were poking around the old buildings. You know, just taking pictures. Looking for stories. And man, we saw something I can't explain. We're standing by this collapsed general store around dusk. And there's this light moving real slow across the hills, not like a plane, not like headlights. Dead silent.
|
||||||
|
|
||||||
|
LUKE: You mean like a UFO, like extraterrestrial transportation?
|
||||||
|
|
||||||
|
JEROME: Yeah, man, exactly that. I've been saying for years that the Boothields got activity. And this was the clearest thing I've ever seen.
|
||||||
|
|
||||||
|
LUKE: Well, you know, we are a way out here where there's a lot of military operations and top secret planes. And who knows what going on? Do you think it could have been a military operation or test?
|
||||||
|
|
||||||
|
JEROME: I mean, look, I thought about that. But Luke, this thing was hovering than it just shot off at an angle that no plane does.
|
||||||
|
|
||||||
|
LUKE: You didn't happen to get any photographic evidence of this, did you?
|
||||||
|
|
||||||
|
JEROME: That's the thing that's killing me. Marcos had his phone out the whole time we were there taking pictures of the buildings. But when this thing showed up, we both just froze. By the time I thought to grab mine, it was already gone. We got nothing. I know how that sounds. Believe me.
|
||||||
|
|
||||||
|
LUKE: Oh, that's too bad. You got to be quick, quick, quick with these things. Just like in our game, real news or fake news. You got to be quick, quick, quick. And here is your headline, sir. The headline is meta partners with the energy company on a $1 billion geothermal plant to power New Mexico data centers. Is that a real headline or fake headline?
|
||||||
|
|
||||||
|
JEROME: Real. That's real news. I just read about that the other day that trying to use all that underground heat for these computer facilities, right?
|
||||||
|
|
||||||
|
LUKE: That's it, sir. You are correct, very well done. And since we took a long time last time, we're going to have to go to another one of our sponsors.
|
||||||
|
|
||||||
|
[Sponsor read]
|
||||||
|
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 journaling 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 brews on an empty stomach? Brutal honesty, because coddling yourself got you here. First session is free, but it will hurt your feelings. Use code growup at checkout. Brutal honesty, the app that blocks itself if you don't do the work.
|
||||||
|
|
||||||
|
LUKE: OK, brutal honesty, I should use that myself. Next up to the show, you're our last caller, Jasmine. Jasmine, we're playing real or fake news today. And I've got an article for you. Are you ready for it?
|
||||||
|
|
||||||
|
JASMINE: Oh, hey, Luke. Yeah, I'm ready. But can I just say something real quick first? I heard Rick earlier, the guy from the bank. And man, I think you were a little hard on him.
|
||||||
|
|
||||||
|
LUKE: You think I was hard on Rick? I don't think I really was. Why do you think I was hard on him?
|
||||||
|
|
||||||
|
JASMINE: Well, he sounded pretty torn up about it, you know? Like he was genuinely worried he missed something with that old guy. And I get it. I mean, I work at a feed store, right? And we get older folks coming in all the time. And sometimes you can just tell something's off.
|
||||||
|
|
||||||
|
LUKE: Oh, for real. And I agree.
|
||||||
|
|
||||||
|
LUKE: And I empathize with both Rick and the older gentleman that he was talking about. I'm not sure what else I could have said to ease his mind.
|
||||||
|
|
||||||
|
JASMINE: Yeah? OK, that's fair. I guess I just, I don't know. Maybe I'm projecting. I've been feeling guilty about stuff lately, too. And hearing him beat himself up kind of got to me.
|
||||||
|
|
||||||
|
LUKE: What are you guilty about? What did you do? What did you do, Jasmine?
|
||||||
|
|
||||||
|
JASMINE: So OK, here's the thing. My spouse gave me an ultimatum last week. It's either the Bronco or the marriage. And I chose the Bronco. Like I actually said that out loud.
|
||||||
|
|
||||||
|
LUKE: The Bronco, like a Ford Bronco? Like a white Bronco?
|
||||||
|
|
||||||
|
JASMINE: Yeah, a 72 Ford Bronco. I've been restoring it for six years, Luke. Six years of every spare dollar, every weekend, bleeding knuckles, all of it. And now Valentine's Day is coming up. And I'm sleeping in the guest room. And the truck's sitting out there with frost on it. And I just, I don't know if I made the right call.
|
||||||
|
|
||||||
|
LUKE: Well, you know what they say. If the glove fits, you must acquit. OK, Jasmine, here is your headline. Continental divide trail shuttle service now required after three hikers rescued from Crazy Cook Monument Trailhead. Is that a real headline or fake headline?
|
||||||
|
|
||||||
|
JASMINE: Oh, man, I'm going to say that's real. We had a couple hikers come through the feed store last fall asking about that area. And they had no idea what they were getting into. Seemed like a rescue waiting to happen.
|
||||||
|
|
||||||
|
LUKE: Oh, oh, oh, no, I got to take that one back. Wow, wow, wow, wow. No, you are incorrect. That was a fake headline. But thank you for playing. Try again next time. And folks, thanks to all of our collars today. This has been Luke at the Roost, tune in again tomorrow for a more radio gold.
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
LUKE: Alright, welcome back to the show. This is Luke at the Roost, the late-night call in radio show where I, Luke, take your calls and give you real-world advice on whatever might be bothering you tonight. So it is Wednesday, February 11th, it's about 9.18 pm, if you're around, you can give us a call. The number is 208-439-5853, that's 208-439-Luke, so give us a call and we'll get you on the air and talk about whatever is bothering you. Okay, so tonight we're going to go to the phones here, we've got, they're already lighting up as they always are, and we're going to talk to Keith, Keith, welcome to the show. What has you calling in tonight?
|
||||||
|
|
||||||
|
KEITH: Yeah, hey Luke, thanks for taking my call, so I work at the hospital, right? And I'm usually pretty good at keeping my cool, it's kind of what I do all day, you know, staying calm when everything's falling apart.
|
||||||
|
|
||||||
|
LUKE: I would hope so with all the panic and triage and crises that happen in a hospital. What's on your mind?
|
||||||
|
|
||||||
|
KEITH: Well, last night I completely lost it on my daughter. She's seven, she knocked over a glass of juice at dinner and I just screamed at her Luke, like really screamed, over some goddamn juice on the table, and the way her face just crumpled, man, I can still see it.
|
||||||
|
|
||||||
|
LUKE: Oh man, that's pretty rough, yeah, it sucks to be a kid and when your parents are stressed out, they take that pretty hard, I've heard, I'm not a parent myself, but it seems like a difficult situation to navigate, so I certainly feel for you, what are you going to do about that?
|
||||||
|
|
||||||
|
KEITH: I apologized to her this morning before school, told her daddy was tired and shouldn't have yelled like that. She said it was okay, but she was still kind of quiet around me.
|
||||||
|
|
||||||
|
LUKE: Is this something that happens all the time? Is this a recurring problem? Do you often snap at your seven-year-old daughter because of your shitty day at work or is this a one-time occurrence?
|
||||||
|
|
||||||
|
KEITH: No, no, this was the first time I really went off like that. I mean, I've been short with her before, you know, not now daddy's tired kind of stuff, but nothing like this, this was different.
|
||||||
|
|
||||||
|
LUKE: Well, you meant no harm by it, even if a little bit of harm was done, and she'll recover, she'll get over it, just make sure that she knows it wasn't really her fault, it was a mistake, and you were having a bad day, and daddy's human too, because you are. So you can forgive yourself and just try to make it up to her the best way you can without going overboard in the other direction.
|
||||||
|
|
||||||
|
KEITH: Yeah, you're right, I appreciate that. The thing is though, Luke, I think what's eating at me is I've been running on fumes for a while now.
|
||||||
|
|
||||||
|
LUKE: Yeah, and what are you going to do about that? You're going to take a little nap, do some yoga, to take off on a silent retreat with some Buddhist monks into bed, and what can you do to resolve that issue?
|
||||||
|
|
||||||
|
KEITH: I don't know, man, that's kind of why I called. I can't exactly take time off. We're short staffed as it is. My wife's got her own stuff going on, her sister's always got some drama she needs to help with.
|
||||||
|
|
||||||
|
LUKE: Yeah, but you know, you're an adult and you have to take care of yourself first. That's what I always say. So if you've got to take the time off, you can't really be concerned about being short staffed. You just got to take the time off, and if they have a problem with that, then maybe you need a new job. You can only do the best you can do, and you have to make sure that your own health, physical and mental health is in order. Otherwise, you're of no use to anybody, and you're going to snap at seven year olds.
|
||||||
|
|
||||||
|
KEITH: You're not wrong. I guess I just, I've always been the guy people lean on, you know, at work, at home. And saying no feels like I'm letting everybody down.
|
||||||
|
|
||||||
|
LUKE: Well, sometimes leaders have to let people down. If you're going to be the leader in the head of the household and a man, then sometimes you're going to stand up for yourself and not please everybody. You can't please everybody all the time, and you could try, you're just going to burn yourself out. So, I recommend not doing that, and take whatever time you need to take to get yourself under control so that you can be the best version of yourself for your kid and your patience and your wife, and anybody that meets you in daily life.
|
||||||
|
|
||||||
|
KEITH: Yeah, I hear you. I think part of me knows that, but hearing someone else say it, it helps. I've got like two weeks of PTO I haven't touched in over a year.
|
||||||
|
|
||||||
|
LUKE: Well, there you go. I think it's time to take it. Keith, I've got a question for you. Are you ready for my question?
|
||||||
|
|
||||||
|
KEITH: Yeah, shoot.
|
||||||
|
|
||||||
|
LUKE: I want to know, what's your most embarrassing bathroom story? Tell me a story about a time you get stuck driving or what happened.
|
||||||
|
|
||||||
|
KEITH: Oh, Jesus, Luke. Okay, so a few years back, I was driving back from Flagstaff after visiting my brother, and I stopped at this gas station outside of Prescott. I go in to use the bathroom and the lock on the stall is busted, right? So I'm in there doing my business, and some guy just swings the door open, doesn't knock or anything, and we just lock eyes for like three full seconds.
|
||||||
|
|
||||||
|
LUKE: Oh, man, during that three seconds was there like a plop?
|
||||||
|
|
||||||
|
KEITH: Ha, yeah, actually, right as he opened the door, perfect timing. The guy just goes, oh shit, sorry, man. And backs out, but I swear I saw him laughing when I came out to wash my hands. I couldn't even look at him.
|
||||||
|
|
||||||
|
LUKE: Oh, man. All right, well thank you for the call. I wish you the best of luck with your daughter and your work. Take some time off and get back at it when you're a hundred percent. Okay, Megan, Megan, welcome to the show. What has your call on in tonight?
|
||||||
|
|
||||||
|
MEGAN: Hey, Luke, thanks for taking my call. So I teach kindergarten down here in the boot heel. And today, during story time, one of my five-year-olds, this little boy named David. He looks up at the ceiling and asks me if the stars know we're looking at them.
|
||||||
|
|
||||||
|
LUKE: Wow, that's a pretty loaded question. What did you tell him?
|
||||||
|
|
||||||
|
MEGAN: I told him I didn't know, but that it was a really good question, and maybe they do. And honestly, it stuck with me all day because I just got off the phone with my sister, Crystal, who moved up to Flagstaff a few years back. And she spent 20 minutes bitching about traffic and how long the lines were at the grocery store. And I tried to tell her about David's question. I tried to get her to remember how we used to drive out past the ranch roads and just sit on the tailgate, watching the Milky Way, you know?
|
||||||
|
|
||||||
|
LUKE: Yeah, I do that all the time. I'm always out in the wilderness looking at the Milky Way. And I wonder things like that myself. What's your take on it, though? Do you think it's possible that the stars could know or that the universe is one universal organism that's just watching itself?
|
||||||
|
|
||||||
|
MEGAN: Oh man, I don't know about all that. I'm not that deep blue. But what gets me is that Crystal didn't even seem to care anymore.
|
||||||
|
|
||||||
|
LUKE: Care about what? And what is the Crystal didn't care about?
|
||||||
|
|
||||||
|
MEGAN: About the sky. About what we're missing when we can't see it. She lives in Flagstaff now and there's so much light pollution. She says she hasn't seen a truly dark sky in years. And when I brought it up tonight, she just kind of brushed it off like it didn't matter.
|
||||||
|
|
||||||
|
LUKE: Well, that's not entirely true. I was just up in Flagstaff. Actually, Flagstaff is one of the first places I ever got a picture of the Milky Way. It's pretty dark up there. There's the low observatory. And a lot of night sky activity. So I wouldn't say that Flagstaff has too much light pollution. It's one of the darker places in the country.
|
||||||
|
|
||||||
|
MEGAN: Really? Well, shit. Maybe she's just not going out to look anymore then. That actually makes it worse, doesn't it? I mean, she's got access to it and she's just...
|
||||||
|
|
||||||
|
LUKE: I understand. I mean, I grew up on the East Coast and the Milky Way isn't a thing that you can really see there. So when I came to the West Coast, the West Side, and got an opportunity to see the Milky Way just when I walk outside my door, it's a very different experience. And I think most people, almost all people, have never even seen the Milky Way.
|
||||||
|
|
||||||
|
MEGAN: So they don't know what they're missing and they don't care. But once you get out here and you have those experiences and start thinking those philosophical questions about, you know, do the stars know you're looking at them? It becomes a more important part of your life. And people go through different stages in their lives. Sometimes the sky is important, and existential questions are important. And other times they're just busy doing work and life and watching TV. Yeah, you're right. I guess I'm being unfair to her. She's got two kids now and works crazy hours at the hospital up there.
|
||||||
|
|
||||||
|
LUKE: Well, I hope that you enjoy watching the night sky and maybe sometime later on in life you can do that again with your sister. I've got a question for you, though. Are you ready for my question, Megan?
|
||||||
|
|
||||||
|
MEGAN: Yeah, go ahead.
|
||||||
|
|
||||||
|
LUKE: What is the grossest habit that you have that you would never admit in person?
|
||||||
|
|
||||||
|
MEGAN: Oh, Jesus, Luke. Where did that come from? Um, okay, I guess.
|
||||||
|
|
||||||
|
LUKE: Come on, we're waiting. You're on the air.
|
||||||
|
|
||||||
|
MEGAN: All right, all right. I eat cereal dry straight from the box while standing at the counter after the kids go to bed. Like, I'll just grab the cinnamon toast crunch and shove handfuls in my mouth over the sink.
|
||||||
|
|
||||||
|
LUKE: Nope, I'm not letting you get away with that one. Everybody does that. That's not gross. I want to hear about the gross thing that you do that you don't want to admit.
|
||||||
|
|
||||||
|
MEGAN: Oh, God, okay. Fine.
|
||||||
|
|
||||||
|
LUKE: Let's go. Let's go. What is it? Come on.
|
||||||
|
|
||||||
|
MEGAN: I pick at the dry skin on my feet while I'm watching TV and I just flick it onto the floor like I'll sit there with my feet up and just go to town and then I vacuum it up later.
|
||||||
|
|
||||||
|
LUKE: All right, that's pretty gross. But, uh, you know, I've done that, too. So I'm not going to give you too much shit for it. But thank you for being honest with us and I wish you the best of luck in your night sky adventures. Andre, Andre, welcome to the show. How can we help you today?
|
||||||
|
|
||||||
|
ANDRE: Hey, Luke. Thanks for having me back, man. So listen, I got some news about that whole lawsuit thing we talked about last time. You remember with the 15 grand and the house fire?
|
||||||
|
|
||||||
|
LUKE: Oh, yeah, I remember. And then it was 85 grand and then you called back and you said they were suing the department or your name was in some legal documents. What's going on?
|
||||||
|
|
||||||
|
ANDRE: Yeah, exactly. So I got a letter from the department's lawyer yesterday and apparently the insurance company dropped the whole thing just like that.
|
||||||
|
|
||||||
|
LUKE: Yeah, I told you that's what was going to happen. It was a ridiculous lawsuit. I mean, I'll be your legal counsel. Just tell him Luke said it was cool.
|
||||||
|
|
||||||
|
ANDRE: Yeah, you called it. But you know what? The thing that's got me twisted up now is the family, the ones I helped, they sent me a card.
|
||||||
|
|
||||||
|
LUKE: Why is that guy you twisted up? That's nice. You found some money. You saved their lock box full of cash and you gave it back to him like a good man. So that's nice of them to send you a card and recognition of your service.
|
||||||
|
|
||||||
|
ANDRE: Yeah, I mean, it is nice. But here's the thing. Inside the card, they put 500 bucks cash like a thank you or something. And I don't know what to do with it, man. I'm sitting here looking at it on my kitchen counter and it feels weird to keep it. But it also feels weird to give it back.
|
||||||
|
|
||||||
|
LUKE: Well, you know what? You know what is cool about that? Is you're looking at the cash there on the table and you're not thinking about taking it to the casino. So it sounds like you're moving in the right direction. If I remember correctly, you were struggling with gambling addiction problems and that's a good sign that you're on the right track. So if you don't want to take the money, they want you to have it. So you should take the money. But if you don't want to keep it or it makes you feel weird, then you can donate that to a charity or to people you know that are in need.
|
||||||
|
|
||||||
|
ANDRE: You're right. Yeah. I haven't been to the casino in like two months now. I've been going to the meetings and everything. I'm just saying it feels like I don't know. Like I'm getting paid for doing the right thing. And that wasn't why I did it. But you know what? Maybe I'm overthinking it.
|
||||||
|
|
||||||
|
LUKE: Yeah, I think so. It sounds like it all worked out okay. They got their money back. The lawsuit got dropped. You got the money. And the gambling's not rearing up and ruining your life right now. So it seems like everything's going well. And you should be proud of yourself, sleep well, and enjoy your 500 bucks.
|
||||||
|
|
||||||
|
ANDRE: Yeah, you're right, man. I appreciate that. I think I'll maybe put it toward some new fishing gear. me and TJ have been talking about going out to Lake Pleasant next week.
|
||||||
|
|
||||||
|
LUKE: That sounds like a plan. All right, Andre. I have a question for you. Are you ready for it?
|
||||||
|
|
||||||
|
ANDRE: Oh, man. Here we go. Yeah. All right. Hit me with it.
|
||||||
|
|
||||||
|
LUKE: All right. What is the worst thing you've ever said during sex?
|
||||||
|
|
||||||
|
ANDRE: Jesus, Luke. Okay. All right. So this was years ago. Back when I was still married to Diane. We're in the middle of it, right? And I don't know what the hell I was thinking. But I said good job. Like she just finished washing the truckers up.
|
||||||
|
|
||||||
|
LUKE: Very nice. That's a good one. All right, Andre. You have a good night and we'll talk to you later. It is time for a word from our sponsors. Thanks for sticking in there with us. And keep your online privacy secure. Today's show is brought to you by Overwhelmed VPN. Worried about online privacy? You should be. You should be worried about everything. I'm talking about Overwhelmed VPN. The only VPN that makes you more anxious about your digital footprint. While other VPNs claim to protect you, Overwhelmed 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 Overwhelmed VPN free for 30 days if you can handle it. Use code They Watching for 20% off. Overwhelmed VPN because ignorance is bliss. But you're not here for bliss. Okay. Welcome back to the show. Thank you Overwhelmed VPN for your support. Next up on the lines we have Carla. Carla, welcome to the show. What's got you up tonight on this fine Wednesday February 11th?
|
||||||
|
|
||||||
|
CARLA: Hey Luke, hey. So okay, I got an update on the whole photo situation from last time I called.
|
||||||
|
|
||||||
|
LUKE: Okay, you're going to have to remind me about that photo situation.
|
||||||
|
|
||||||
|
CARLA: Oh man, okay, so I found these old explicit photos of my ex-husband and his girlfriend back in the day. Like before we were even together. They were in a box of his ham radio stuff that he left at my place. I called asking what to do with them because it was just weird having them, you know?
|
||||||
|
|
||||||
|
LUKE: I do know and I suggested you send them to me. So what is it that you did with the photos?
|
||||||
|
|
||||||
|
CARLA: I'm not going to lie. I almost did just mail them to you. But I ended up burning them in my fire pit out back. It felt like the right move, you know? But here's the thing, his girlfriend from the photos.
|
||||||
|
|
||||||
|
LUKE: Yeah, what about her? Did you burn her in the fire pit too?
|
||||||
|
|
||||||
|
CARLA: Huh, no. She messaged me on Facebook. Out of nowhere.
|
||||||
|
|
||||||
|
LUKE: That's a little weird, so you find these photos of her in the act with your ex-husband and then she messages you on face. Do you know the woman? Have you ever spoken before?
|
||||||
|
|
||||||
|
CARLA: No, never. I'd seen her name pop up on his friend's list way back, but we never talked or anything. So yeah, it's super weird. She said she heard through the grapevine that I found some old photos and she wanted to clear the air about it. Like, how did she even know?
|
||||||
|
|
||||||
|
LUKE: Well, I'm just going to go out on a limb and say maybe she heard it when you called into a radio show and told the world.
|
||||||
|
|
||||||
|
CARLA: Oh, for real though. Yeah, okay, that's fair. I didn't even think about that. My friend Tammy from church listened sometimes and she knew about it, so I guess word could have gotten around.
|
||||||
|
|
||||||
|
LUKE: But still, it's weird that she cared enough to reach out, right?
|
||||||
|
|
||||||
|
CARLA: Yeah, it's a little weird, it's a little awkward.
|
||||||
|
|
||||||
|
LUKE: So what did you do? Did you have a conversation with her or just leave the message sitting there?
|
||||||
|
|
||||||
|
CARLA: I responded. I mean, I was curious what she even wanted to say. She was actually pretty cool about it. Apologized for the weirdness of me finding them. Said she didn't even know those photos still existed.
|
||||||
|
|
||||||
|
LUKE: Yeah, that's a little bit strange. Well, I've got a question for you. I've got a question for you tonight. Are you ready for that?
|
||||||
|
|
||||||
|
CARLA: Yeah, go ahead.
|
||||||
|
|
||||||
|
LUKE: What is the weirdest thing that you've ever masturbated to?
|
||||||
|
|
||||||
|
CARLA: Oh my god, Luke. Really? That's where we're going with this?
|
||||||
|
|
||||||
|
LUKE: Oh, you know it. That's exactly where we're going with this. Come on.
|
||||||
|
|
||||||
|
CARLA: I'll with it. Jesus. Okay, I'm not going to lie. This is mortifying. But there was this one time I got really into those historical romance novels.
|
||||||
|
|
||||||
|
LUKE: I'm not familiar with the genre myself. Can you let us know about the historical romance novel? What's an example of one?
|
||||||
|
|
||||||
|
CARLA: Like, you know, the ones with the covers, whether some shirtless dude with long hair on a horse or whatever. They're setting like the 1800s or medieval times. I went through this whole phase reading them a few years back when things with my ex were already going downhill.
|
||||||
|
|
||||||
|
LUKE: Okay, what is it about that style of book that got to you? Is it the setting or is it the characters or is it just the raunchiness of them?
|
||||||
|
|
||||||
|
CARLA: For real though, it was the whole fantasy of it, you know? Like, these women in the books, they'd have all these men just obsessed with them, pursuing them, saying all this flowery stuff. And I'm sitting there in my apartment with a husband who won't even look up from his phone. So yeah, the raunchiness was part of it, but it was more like...
|
||||||
|
|
||||||
|
LUKE: More like what?
|
||||||
|
|
||||||
|
CARLA: More like the escape, I guess. Like in those books, the guy actually gives a shit. He's fighting duels over her or sneaking into her bedroom window or whatever. Meanwhile, my ex was in the other room playing Call of Duty with his headset on, ignoring me for hours.
|
||||||
|
|
||||||
|
LUKE: Yeah, that makes a lot of sense, but you know, that's why they call them fantasy novels. Thank you for the call. I'm glad that you burned those pictures and it is a little weird that you've got that conversation going with the ex-husband's new girlfriend. But, you know, you can take that as far as you want or end it wherever you're at. Depends on how you feel about it. But thanks for the call and I imagine we'll talk to you some other night. Wow. That was... That was deep, man. Okay. Vince. Vince, welcome to the show. What's good to you up tonight.
|
||||||
|
|
||||||
|
VINCE: Yeah, hey Luke. Appreciate you taking the call. So I just spent the last two hours sitting in my tow truck outside a close circle K, watching a video of myself say a bunch of shit I never said. My daughter showed it to me earlier. She's crying because kids at her school are passing it around, asking if I really talk like that about their parents.
|
||||||
|
|
||||||
|
LUKE: Oh man, that is weird. Is it like an AI video or did somebody change the audio out from under a real video from you? Who made this, you know?
|
||||||
|
|
||||||
|
VINCE: It's one of those AI things, yeah. My face, my voice. But I'm saying I've been hiding money from my ex-wife Linda. And running my mouth about half the parents at Beckers High School. I have no idea who made it. Could be some kid with too much time. Could be somebody with an actual problem with me.
|
||||||
|
|
||||||
|
LUKE: Yeah, that doesn't sound like a kid with too much time. I mean, that's awful specific to go after you. Does anybody have a reason to do that? It seems more like somebody with an actual problem with you to target you specifically. That's very scary.
|
||||||
|
|
||||||
|
VINCE: Well, that's the thing that's got me twisted up. I've been doing tow workout here for 23 years. You piss people off in that job, you know? Somebody thinks you gouged them on a tow. Or maybe I had to impound a car and they're still sore about it.
|
||||||
|
|
||||||
|
LUKE: Well, where is the video? Was it posted on Facebook or something like that? Is it in a public place that people are finding? I mean, how did your kids friends at school find that?
|
||||||
|
|
||||||
|
VINCE: Beckers said it started going around on some group chat with kids from her school. Then it hit Instagram and Snapchat. I don't even know if it's on Facebook yet. Linda won't answer my phone, so I can't ask if people are sending it to her. The video is just me supposedly sitting in my truck talking to somebody, saying I've got cash stashed that Linda doesn't know about and calling out specific parents by name.
|
||||||
|
|
||||||
|
LUKE: Yeah, it's going to be hard to get out from under that one, I think. You know what I would do? I would probably turn that into a public opinion type piece and talk to the newspaper and say, look, this is happening. Maybe talk to the police department and get a warning sent out that people are being impersonated and somehow make it known to the public that this is a thing that you didn't do and that they also are at risk for it happening to them.
|
||||||
|
|
||||||
|
VINCE: That's smart, yeah. I hadn't thought about the newspaper angle. We got the times independent. They might actually run with that since it's local. My worry is, if I go public with it, does that just make more people go looking for the video?
|
||||||
|
|
||||||
|
LUKE: It might, it very well might, but as long as they're finding that video with the context that this is fake, they're going to be more scared about when it happens to them than they are going to be blaming you about what was said. Also, if you can track down where the video is posted, you can flag it as abuse and get the social media companies to take it down with varying degrees of success. Those are all avenues that you can go. I don't know that there's any better way to deal with it.
|
||||||
|
|
||||||
|
VINCE: Yeah, I can start flagging it wherever I find it. The thing that's eating at me though is Linda. We're separated right now. Not divorced, just trying to figure things out. And her phone's been blowing up about this.
|
||||||
|
|
||||||
|
LUKE: Yeah, that's terrible and that's definitely not going to help your situation. But if you could get, if you can have a conversation with her and somehow convince her that it's not real or you know what you could do, you could make a fake one with somebody else, a fake person. And then show her how easy it is to do so that she can kind of wrap her head around how it could have been done to you.
|
||||||
|
|
||||||
|
VINCE: Huh. I mean, I don't even know how to make one of those things. But maybe I could find somebody who does. Show her it's not some complicated spy stuff. Just some app any Jackass can use. The problem is getting her to actually talk to me long enough to show her anything. She's already been on edge about money since the separation. And now there's a video of me saying I've been hiding cash from her.
|
||||||
|
|
||||||
|
LUKE: Yeah, that's a tough, tough hill to climb for sure. But it sounds like things aren't going well anyway. And are you hoping to get back together with this woman or do you think it's going the other way before this happened?
|
||||||
|
|
||||||
|
VINCE: Before this, I thought we had a shot. We've been married 16 years. This separation was supposed to be temporary just until we cooled off and fitted some things out. I moved into that little place of Cancreek Road two months ago. We were still talking most days being civil about Becca's schedule and all that.
|
||||||
|
|
||||||
|
LUKE: Well, I wish you the best of luck with that one. That sounds like an uphill battle. And that's really shitty that that happened to you. So let the public know, try and get it ahead of it. And just be honest. Just say, hey, wasn't me.
|
||||||
|
|
||||||
|
VINCE: Yeah, I appreciate it. I'm going to call the times independent first thing in the morning. Maybe swing by the sheriff's office too. Just sucks that some asshole with a computer can blow up your life like this. And there's not much you can do about it except damage control.
|
||||||
|
|
||||||
|
LUKE: That's true. Now, I've got a question for you, Vince. Are you ready for my question?
|
||||||
|
|
||||||
|
VINCE: Oh, Jesus. Yeah. Go ahead. What have you got?
|
||||||
|
|
||||||
|
LUKE: All right. Tell me something that you pretended to be into for way too long.
|
||||||
|
|
||||||
|
LUKE: Like something that you weren't really into, but you pretended to be cool or to appease a partner. Huh. Okay. Linda used to drag me to this hot yoga place in town. I went for like six months, twice a week, pretending I was getting something out of it. Truth is, I just felt like a sweaty idiot the whole time. Couldn't do half the poses. And I was only there because she said we needed to do more things together. Oh, yeah. The old hot yoga. That's rough. Okay. Well, thanks for the call. And I hope it all works out for you with your revenge porn situation there. I will talk to you later on down the line. Yolanda. Yolanda, thanks for calling in. What's happening tonight?
|
||||||
|
|
||||||
|
YOLANDA: Hey, Luke. Yeah, this is Yolanda. I'm calling from Lodzburg. I just, man, I've been sitting out in my truck for the past 20 minutes because I can't go back inside. My dad looked at me earlier tonight and called me Michael, which is my brother's name.
|
||||||
|
|
||||||
|
LUKE: Okay, enough. That doesn't say that what you're calling a radio show because of that because the dude misspoke. Come on. I don't know if that's worth you being upset about.
|
||||||
|
|
||||||
|
YOLANDA: No, you don't get it. Michael's been dead for 11 years. My dad has never not once mixed us up. And it wasn't just like he said the wrong name and corrected himself. He looked right at me and said, Michael, when did you get so tall? Like he was seeing him. Not me.
|
||||||
|
|
||||||
|
LUKE: Oh, okay. Well, that is pretty disturbing. Was he under the influence of something? Or do you think maybe he's coming down with a little bit of the dementia?
|
||||||
|
|
||||||
|
YOLANDA: That's what I'm scared of, yeah. He's 73 and he's been forgetting little things here and there. Like where he put his keys, what they trash pick up is. But this felt different. This felt like a door closing, you know?
|
||||||
|
|
||||||
|
LUKE: Yeah, that's a little rough. So I guess you're going to have to collect your thoughts out there for a little bit and then go in there and talk to him and maybe find out what it is that's going on in his mind. Not a confrontation, but just ask the man, hey, you called me Michael. That was a little bit weird and have that conversation and see if maybe you need to get some doctors involved.
|
||||||
|
|
||||||
|
YOLANDA: He's already asleep. He goes down at like 8.30 every night, has for years.
|
||||||
|
|
||||||
|
LUKE: Alright, well then have that conversation with him at breakfast in the morning. Whenever the next you get an opportunity to speak to him, don't let that one slide, figure out what's going on.
|
||||||
|
|
||||||
|
YOLANDA: Yeah, I will. The thing is though, and this is the part that's really messing with me. When he said it, for just a second, I felt like maybe he was right. Like maybe I have been wearing Michael's face this whole time and just didn't know it.
|
||||||
|
|
||||||
|
LUKE: Well, you don't sound like a Michael to me, but even if you were, if you were taking on parts of Michael's personality into yourself, that's kind of a nice way to remember someone, I think. So that's not such a horrible thing. Why would you think that it is?
|
||||||
|
|
||||||
|
YOLANDA: Because I go out to where he died every few months. Out in the palancillos, I tell myself it's just good hiking. But I always end up at that exact spot on the trail where the Ranger found his truck. And I sit there and I don't even know what I'm doing. I'm not praying, I'm not talking to him. I'm just sitting there like I'm waiting for something.
|
||||||
|
|
||||||
|
LUKE: Well, that sounds like a way of grieving and I think that's a normal thing. You can't bring them back. You know, somebody that's died has died and you can just remember their memory. And I think it's good if you're taking on parts of that person's personality into yourself that keeps them alive in some strange way.
|
||||||
|
|
||||||
|
YOLANDA: Maybe, but I was 17 when he died and he was 24. And now I'm older than he ever got to be. So what am I even holding on to? Some version of him that never got to exist past 24? I don't know man.
|
||||||
|
|
||||||
|
LUKE: Yeah, what's wrong with that? I mean, you knew him at 24 and that's how your memory of him is going to be. He's going to be 24 forever for you. And that's not such a bad thing.
|
||||||
|
|
||||||
|
YOLANDA: I guess not. It's just weird thinking my dad might start losing more of this stuff, you know.
|
||||||
|
|
||||||
|
LUKE: Yeah, that is tough. And unfortunately, that's the age that we're at, you know, where our parents are getting older. And those are the types of problems that people are dealing with in this era, this age. So hopefully that all works out and it was just an odd occurrence. But if it's more than that, you should get to the bottom of it sooner rather than later, because there might be some early preventative stuff that can be done. I'm not a doctor, so I don't know what meds are available for those types of things. But for all you know, he could have been up and took the wrong pills today and just been seeing stuff that wasn't there.
|
||||||
|
|
||||||
|
YOLANDA: Yeah, that's true. He does have like four different prescriptions he takes in the morning. Could have just mixed something up. I'll talk to him tomorrow. Make sure he's got them all straight. Thanks, Luke.
|
||||||
|
|
||||||
|
LUKE: You're welcome, Melinda. And I wish you both the best of luck. And now it's time for another word from our sponsors. This episode is sponsored by Shoespreyco. It's the 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. Shoespreyco doesn't promise fresh linen or spring rain. Nobody believes that. No one wants to be lied to while lacing up rental shoes. Shoespreyco 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 Shoespreyco for 15% off. Shoespreyco reality-based footwear management. Alright, thanks to Shoespreyco for sponsoring tonight's episode. Next up on the line we have Dennis. Dennis, why don't you tell us about your most horrifying time you're ever caught by your family doing something you aren't supposed to be?
|
||||||
|
|
||||||
|
DENNIS: Oh man, okay so this is bad. I was maybe 19. Home on leave. And I'm in my childhood bedroom at like two in the afternoon just absolutely going to town, right? Doors closed. I figured everyone's at work.
|
||||||
|
|
||||||
|
LUKE: Oh man, we've all been there. What happened?
|
||||||
|
|
||||||
|
DENNIS: Yeah, so my mom apparently came home early because she forgot something. And she just, she doesn't knock, never has, just walks right in asking if I've seen her car keys. I'm scrambling trying to like cover up and she just freezes. We both freeze and then she goes, your father's keys are probably in the kitchen and backs out like nothing happened.
|
||||||
|
|
||||||
|
LUKE: Uh-huh. Have you ever talked about it since?
|
||||||
|
|
||||||
|
DENNIS: Never, not once. That was 15 years ago and we have never acknowledged it happened.
|
||||||
|
|
||||||
|
LUKE: Well, that's the appropriate response. Thank you for the story, sir. Why are you calling in tonight?
|
||||||
|
|
||||||
|
DENNIS: Yeah, thanks, Luke. Appreciate you letting me get that off my chest. Have a good one.
|
||||||
|
|
||||||
|
LUKE: Yeah, no problem, glad I could help. Okay, okay, Shonda, welcome to the show. What's happening tonight, Shonda?
|
||||||
|
|
||||||
|
SHONDA: Oh man, well I'm sitting in a flying J parking lot right now because my transmission just died on the way back from Lord's Burg. And I've been out here three hours waiting on a quote. I already know I can't pay. Mechanics said 1400 minimum, probably two grand. And I've got court reporter work in Silver City tomorrow and Friday that I'm about to lose if I can't get there.
|
||||||
|
|
||||||
|
LUKE: Well, I know there are sometimes people driving for Uber or left around Lord's Burg. Maybe you can get one of them tomorrow, but we can ask our listeners if anybody is out in the Lord's Burg area. If you could give Shonda a ride to Silver City tomorrow, that would help her out.
|
||||||
|
|
||||||
|
SHONDA: That's really kind of you, Luke. I appreciate that. I didn't even think about Uber out here. My brain's just been stuck on the car, you know?
|
||||||
|
|
||||||
|
LUKE: Yeah, and honestly, Uber's hit her mess. I mean, there's I think maybe one person in town that doesn't whenever they feel like it.
|
||||||
|
|
||||||
|
SHONDA: So I'm not sure you're going to be able to get an Uber or a taxi or anything like that, but I'm sure that one of our fine listeners will be able to help you out in the morning. I hope so. I've just been sitting here talking to my late husband, Danny, like he's still around, which probably sounds crazy, but he used to handle all this car stuff, and I feel so lost with it. Then I felt bad calling you because there's probably people with real problems listening.
|
||||||
|
|
||||||
|
LUKE: Hey, I don't know. I would call that a real problem if you're stuck abandoned in a place like Lord's Burg, nobody wants to be in that situation. But I hope that you could find a mechanic that can do it for something that you can afford, and that you're able to get to work in the morning and secure whatever funding you need to fix that car.
|
||||||
|
|
||||||
|
SHONDA: Yeah, me too. I just hate that I'm out here alone with this, you know? Danny's been gone four years now, and most days I'm fine, but nights like this just hit different.
|
||||||
|
|
||||||
|
LUKE: I certainly understand. Well, you're not alone. You've got the whole look at the roost viewership in your corner, and I wouldn't be surprised if somebody shows up there at the Flying J to help you out.
|
||||||
|
|
||||||
|
SHONDA: That would be something. I really appreciate you letting me vent, Lou. Just needed to hear another voice tonight, instead of sitting out here in the dark by myself watching my phone die.
|
||||||
|
|
||||||
|
LUKE: Yeah, well, you're at a 24-hour truck stop, so that's good. I'm sure they can help you charge your phone or call somebody or find a place to stay tonight if you need to. But I've got a question for you, Shonda, to lighten the mood a bit. Are you ready for that?
|
||||||
|
|
||||||
|
SHONDA: Oh, Lord. Okay. Yeah. Go ahead.
|
||||||
|
|
||||||
|
LUKE: Okay, tell us about a YouTube video that's in your search history that you're embarrassed about, that you wouldn't want anybody to find.
|
||||||
|
|
||||||
|
SHONDA: Oh, man, you're really going to make me say this on air. All right, so I've been watching these videos on how to talk to your dead spouse. Like not say on stuff, just grief counselors talking about continuing bonds. Whatever they call it.
|
||||||
|
|
||||||
|
LUKE: Well, I don't think that's very embarrassing. I think that's probably a healthy way to deal with grief. And are they helping you? Are you finding some solace in those videos?
|
||||||
|
|
||||||
|
SHONDA: A little. Yeah. They make me feel less crazy for doing what I was doing tonight in the parking lot. But honestly, the embarrassing part is that after I watch those, YouTube starts recommending me all these widow dating advice videos. And I've clicked on a few.
|
||||||
|
|
||||||
|
LUKE: Okay, and what is some of the best widow dating advice that you've got from these videos?
|
||||||
|
|
||||||
|
SHONDA: Well, the main thing they all say is don't feel guilty about it, which is easier said than done. And apparently, four years is when a lot of people start thinking about it. So I'm right on schedule, I guess. But the advice that stuck with me was this. One lady saying, your late husband would want you to be happy, not loyal to a memory. That one kind of messed me up for a few days.
|
||||||
|
|
||||||
|
LUKE: Yeah, that's understandable. And I definitely understand where you're coming from with the guilt. I feel guilty about pretty much anything I've ever said or done in my entire life. And it's just something that I live with. And under current of guilt, constantly, that I know it doesn't really deserve to be there, but it is anyway. And you know, you just got to deal with the emotions that you get and don't give them any more credence than they deserve.
|
||||||
|
|
||||||
|
SHONDA: Yeah, that's the thing though. Right? Like I know logically, Danny wouldn't want me sitting alone in a broken-down car at a truck stop at midnight, feeling sorry for myself. He'd probably be laughing at me right now for not joining Triple A like he told me to do a hundred times. But then I think about actually going on a date with somebody and my stomach just drops.
|
||||||
|
|
||||||
|
LUKE: Well, you don't have to go on a date with anybody if you don't want to. When you're ready, you're ready. And you know what? For all you know, whoever comes to pick you up tonight at the Flying Jay in Lordsburg, they might be the one. And that's okay. You don't have to feel guilty about it, even though you probably will. You can have more than one feeling at the same time, so you can feel a little bit guilty and also feel good and alive. And all of those things are okay.
|
||||||
|
|
||||||
|
SHONDA: Lordsburg, yeah. And honestly, the way this night's going, the tow truck drivers probably going to be like 65 and tell me about his sciatica the whole ride. But I hear what you're saying. Danny used to say I was bad at letting myself feel two things at once that I'd pick one emotion and commit to it like I was filing a brief.
|
||||||
|
|
||||||
|
LUKE: Well, you know, I wish you the best luck with those things and with your situation broken down there in Lordsburg. If anybody's out there listening, they can help Shonda. Please do what you can and show her how fierce the look at the Ruth's community can be to help those in need in our local area. Thanks for calling in Shonda. We got to move on, but I hope you don't end up stuck there for too long. All right. We're going to take one more call and that will be the end of our show. Thanks everybody that called in today. And this has been Luke at the Roost. You can find more about our show at LukeAtTheRoost.com. And yeah, follow us on the old socials. All right, Darnell, you're the last caller of the evening. What are you calling in for, sir?
|
||||||
|
|
||||||
|
DARNELL: Yeah, Luke, I appreciate you taking the call this late. Thanks. So I just got home today.
|
||||||
|
|
||||||
|
LUKE: Okay.
|
||||||
|
|
||||||
|
DARNELL: Literally four hours ago from 90 days in we have down in Tucson.
|
||||||
|
|
||||||
|
LUKE: All right.
|
||||||
|
|
||||||
|
DARNELL: And I walk into my apartment. My roommate Travis left me a six pack of Takate. My apartment. And with a posted that says, welcome back bro. Like he thought he was being nice.
|
||||||
|
|
||||||
|
LUKE: Yes, he did. And on standing here staring at it, thinking, did this dude not hear a single thing I told him before I left? I called him from the facility, man.
|
||||||
|
|
||||||
|
DARNELL: Well, you know what, when you're drinking partners with somebody and you stop drinking, and they start to grieve for you in a weird way and feel isolated and alone. So it makes sense that somebody that you used to party with is not going to want to lose that relationship and be all alone. So I can understand the insensitivity there. Your directive at this point is to not drink that. You can either pour it down the toilet or just put it back in the fridge. If you want to stay clean and sober though, that's on you. And you have to be able to do that in the presence of alcohol.
|
||||||
|
|
||||||
|
LUKE: Yeah. No, I hear you. I poured it out about 20 minutes ago. That's not the issue. I can handle being around it.
|
||||||
|
|
||||||
|
DARNELL: I bet you can.
|
||||||
|
|
||||||
|
LUKE: I know. But what's messing with me is I did everything they told me to do. 90 days, every group session, everyone on one called my sponsor twice a day.
|
||||||
|
|
||||||
|
LUKE: Well, that's good. I mean, keep doing those things and you'll continue to get what you're getting, which is another day of sobriety. And that's all that an alcoholic can hope for. So I wish you the best. And I've got a question for you. Are you ready for my questions, sir?
|
||||||
|
|
||||||
|
DARNELL: Yeah. Go ahead. Hit me with it.
|
||||||
|
|
||||||
|
LUKE: Where is the weirdest place you've ever had to piss?
|
||||||
|
|
||||||
|
DARNELL: To peace. To piss. Oh, man. Probably in a mock bucket. The high-the-wall greens on Oracle Road at like three in the morning. I was working overnight, stalking. And the bathroom was locked because some tweaker had been in there for an hour. Manager wouldn't open it. Told me to figure it out.
|
||||||
|
|
||||||
|
LUKE: All right. Well, it sounds like you did the right thing. In both situations today in your life, you put out the booze and you pissed in the mob bucket. And that's great. So I hope that you have another day of sobriety and keep on doing what you're doing. Because that's how you're going to recover. So thanks for calling in. Thanks to everybody that called in today. This has been Luke at The Roost and we'll talk to you again tomorrow.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
LUKE: All right, welcome to the show. This is Luke at the Roost. This is the radio show where you call in and we talk about whatever's going on in your life We could talk about math or science or nerd shit or we could talk about somebody that pissed you off at work It doesn't really matter as long as what you have to say is interesting So give us a call the number here is 8 6 8 9 0 9 11 8 8. That's 8 6 8 9 0 9 11 8 8 Let's go to the phone and talk to Earl Earl. Give us a little bit about your interpretation of Schrodinger's cat
|
||||||
|
|
||||||
|
EARL: Yeah, well Schrodinger's cat, you know, it's one of those Quantum physics things I guess Sounds like a bunch of mumbo jumbo to me. I mean the idea that the cat could be you know both alive and dead at the same time It makes my head spin. I'm more of a common sense kind of guy, you know Quantum physics, but if I can't see it or touch it, it ain't real as far as I'm concerned But hey, what do I know right? I'm just the old guy trying to make sense of all this newfangled science stuff Dearies and whatnot
|
||||||
|
|
||||||
|
LUKE: Well kid, you can you see love? Can you see the hatred in your heart that you have for a well-known documented scientific theories?
|
||||||
|
|
||||||
|
EARL: Oh Look at you trying to get all philosophical on me the heart. You can't exactly put those things under a microscope can you? No, this old dog ain't buying into all that touchy.
|
||||||
|
|
||||||
|
LUKE: All right, you'll dog get off the air then Let's see who else we got Marcus. Marcus. Welcome to Luke at the Roost. Let's talk about some quantum physics stuff Give me give me your interpretation of the plank constant and what that means to the universe
|
||||||
|
|
||||||
|
MARCUS: Geez, yeah, the plank constant. I mean, that's like some heavy stuff, you know? From what I remember, it's basically this fundamental unit of measurement that's tied to like the smallest possible changes in energy and matter Like it's the minimum possible action or something I don't know. It's kind of mind-bending when you think about it. How there's this Fundamental limit to how small things can get Makes you wonder what else is out there, you know like what else is going on in this crazy universe that we can't even wrap our heads around Oh, man, I could go on forever about this stuff, but Yeah, I don't know. It's just wild to think about you know
|
||||||
|
|
||||||
|
LUKE: Let's talk about the speed of light for a second. So we have measured the speed of light It's pretty fucking fast everybody can agree on that, but what is its relationship to energy? Einstein had his famous Equation e equals mc squared which is energy equals matter times the speed of light squared Why is the number the speed of light showing up in other places in the universe and what could that relationship mean?
|
||||||
|
|
||||||
|
MARCUS: You have light man. That's just nuts. I mean think about how fast that is, you know, it's like what 300,000 kilometers per second or something That's just insane and you're right. Einstein totally cracked the code with that e equals mc squared thing I mean the fact that the speed of light is so central to that equation. It's got to mean something, you know Like the way I see it. It's almost like the speed of light is this fundamental constant that's woven into the very fabric of the universe I mean it's not just about how fast stuff moves. It's about the relationship between energy and matter That's it. The speed of light is like the bridge, you know, it's what connects all that stuff together Man, I could go on for hours about this, but you're totally right. There's just something so profound about the role The speed of light plays in all of this. It's like the key to unlocking the secrets of the universe or something Wild stuff, dude.
|
||||||
|
|
||||||
|
LUKE: Well, that's what we do here on Luke at the roost is we We crack open the secrets of the universe. So let's go to one more caller for our show today. We've got Rick Rick tell us how you feel about Let's see what was a good one for you Why isn't Pluto a fucking planet planet?
|
||||||
|
|
||||||
|
RICK: Come on, man. What is this the 90s? Nah, look Pluto's just too small, you know, I mean it ain't even the biggest rock out of scientists got it right Pluto's just a dwarf. They just leave it alone, you know a dwarf planet sounds like a more awesome planet than just a planet Wouldn't you say because it's got, you know, the descriptor of a dwarf. It's it's more exciting
|
||||||
|
|
||||||
|
LUKE: Yeah, I guess you got a point there. I mean a dwarf planet. That's got a little more flair to it, you know Sounds like something straight out of fantasy novel or something
|
||||||
|
|
||||||
|
RICK: Yeah, I can dig it Pluto the dwarf planet has a nice ring to it It's just being some plain old regular planet Got to admit that does make it sound a little more. I don't know Special I guess there's no sleep over it. It is what it is Yeah, it's a planet Planet's are pretty cool even though there's you know innumerable planets out there that we all get to look at in the Dark beauty of the night around the desert
|
||||||
|
|
||||||
|
LUKE: Tell me what you think about astrophotography and what Starlings doing to To get in the way of that with with their Starlink satellites in low Earth orbit
|
||||||
|
|
||||||
|
RICK: Don't even get me started on that Starlink nonsense. I mean look I get there trying to bring internet to folks, but do they got a ruin the whole dang night sky for the rest of us? Me some good astrophotography, you know Sitting out in the desert looking up at all them star. It's damn near-peaceful, you know But then you got these Starlink satellites just half the time you can't even see the Milky Way anymore It's just they'd find another way to get internet out there that doesn't yeah, that'd be great But to be perfectly honest with you it hasn't really made any Impact at all in my night sky photography It's pretty cool to see the little sled of Starlink satellites shimmy across the night sky Especially the first time. I don't know. He's blows everybody's mind.
|
||||||
|
|
||||||
|
LUKE: Anyway, we're gonna take one more call from Brenda Brenda tell me about your latest Relationship blunder
|
||||||
|
|
||||||
|
BRENDA: Where do I even start? I mean, I met this guy at the grocery store He seemed nice enough, you know We went on a couple dates and I thought hey, maybe this could actually work out for once But then of course he had to go and mess it all up We were having dinner the other night and I mentioned wanting to get a dog and he just looked at me like I had three heads I mean who doesn't love dogs? It's a deal breaker, you know, I Don't know I just I can't catch a break with these guys I'm never going on another date again. I swear
|
||||||
|
|
||||||
|
LUKE: Has anybody ever told you Brenda that you sound like Claire from last? Charlie Charlie, I don't want you around my baby, Charlie I am just a I serve no purpose on this island Charlie other than to complain about you and everything that you've done to save and protect me for no reason at all Charlie And I'm just mean to you all the time because I'm a fucking bitch Charlie stay away from my baby. I don't even want to be a mother
|
||||||
|
|
||||||
|
BRENDA: Oh my god, are you serious? That sounds just like my sister Claire Like the uncanny resemblance to scary Always complaining always putting down the people who are actually trying to help her and the way she talks about her baby daddy Charlie It's the worst I swear the two of us could be twins sometimes. We're both just High-strung your rock your fucking bitches and you're off the air.
|
||||||
|
|
||||||
|
LUKE: That's enough of you Everybody thank you for tuning in for this installment a look at the roost and we'll be back next week Be sure to tune in maybe we'll broadcast this shit over the ham radio But by or if why as they say
|
||||||