Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9071ed36d3 | ||
|
|
9d49b013c2 | ||
|
|
2071ae4181 | ||
|
|
e470b6aaea | ||
|
|
44ef13336e | ||
|
|
2901e5f4fb | ||
|
|
5f6429e6da | ||
|
|
bf1afef351 | ||
|
|
2429a2eb07 | ||
|
|
fa5ab9aad2 | ||
|
|
25b0015f62 | ||
|
|
b496b91a5d | ||
|
|
6951398872 | ||
|
|
61d24ae7d8 | ||
|
|
56a6a2dfbe | ||
|
|
fdb9f57660 | ||
|
|
cc0d9ffc5d | ||
|
|
f902eab701 | ||
|
|
8c71da543f | ||
|
|
5f7e3e7415 | ||
|
|
00ddc2d1b4 | ||
|
|
1c7ac334b4 | ||
|
|
8a20e679f6 | ||
|
|
35ff78a922 | ||
|
|
61b3cba412 | ||
|
|
44ac569fec | ||
|
|
54ad04ae09 | ||
|
|
c087c0362a | ||
|
|
d0bb50fe12 | ||
|
|
4e28f3fb84 | ||
|
|
9e37fbf124 | ||
|
|
83c7f441a8 | ||
|
|
9982f65742 | ||
|
|
ab811bda91 | ||
|
|
13d613df5f | ||
|
|
effd52a95c | ||
|
|
76feeb22a0 | ||
|
|
3f85ee9140 | ||
|
|
604769a949 | ||
|
|
969f8fb2fd | ||
|
|
d4f1043be1 | ||
|
|
a18e1329f3 | ||
|
|
2fd027b82a | ||
|
|
0c5d36d182 | ||
|
|
d7e475331d | ||
|
|
e4a18e13ab | ||
|
|
470e92f8c4 | ||
|
|
b792c3cca0 | ||
|
|
baa6db5aa2 | ||
|
|
dec6211f7c | ||
|
|
ade5510bd5 | ||
|
|
f56ca3d4bc | ||
|
|
a196e8f088 | ||
|
|
ca7e82f19f | ||
|
|
1b988a0303 | ||
|
|
fa491c1c27 | ||
|
|
486842d2d1 | ||
|
|
9c9f5c6602 | ||
|
|
73d555b269 | ||
|
|
283e4a2f3c | ||
|
|
0d2a0e42a8 | ||
|
|
376265eec7 | ||
|
|
f3c91fc385 | ||
|
|
c69c2ad532 | ||
|
|
8dbbd92d3a | ||
|
|
fa36f8d184 | ||
|
|
794ad98cf0 | ||
|
|
f5eabd7dc4 | ||
|
|
f717edeacb | ||
|
|
56607879ee | ||
|
|
fcefabdaee | ||
|
|
58495d2c75 | ||
|
|
51961dc19b | ||
|
|
c516402402 | ||
|
|
e614599650 | ||
|
|
d36de95577 | ||
|
|
0147be4e0c | ||
|
|
390f138601 | ||
|
|
9eaf2fe5e3 | ||
|
|
314d5f9452 | ||
|
|
e0fb3cac68 | ||
|
|
4589670b37 | ||
|
|
eb1e18a997 | ||
|
|
6dcdf20289 | ||
|
|
762b5efc3b | ||
|
|
3dd6a83c68 | ||
|
|
5e98ed0e11 | ||
|
|
fcf13bae22 | ||
|
|
c30a75cc8f | ||
|
|
90e51698b8 | ||
|
|
5d8ab57e20 | ||
|
|
d33a022676 | ||
|
|
7e2ef1fa2b | ||
|
|
164cad456c | ||
|
|
0b091a1afd | ||
|
|
cfc7ad39f2 | ||
|
|
8a64a269f3 | ||
|
|
908255e5cf | ||
|
|
39297d4aa5 | ||
|
|
d39cb3f3d4 | ||
|
|
c70f83d04a | ||
|
|
3329cf9ac2 | ||
|
|
6d4e490283 | ||
|
|
d3490e1521 | ||
|
|
0c2201fab5 | ||
|
|
f7b75fa72f | ||
|
|
2c7fcdb5ae | ||
|
|
0bdac16250 | ||
|
|
6eeab58464 | ||
|
|
08a35bddeb | ||
|
|
bbcf767a8f | ||
|
|
b1bd4ed365 | ||
|
|
2b3551cada | ||
|
|
d611f60743 | ||
|
|
d85a8d4511 | ||
|
|
f0271e61df | ||
|
|
3164a70e48 | ||
|
|
8d3d67a177 | ||
|
|
f9985fc693 |
@@ -0,0 +1,48 @@
|
|||||||
|
# API Keys
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
ELEVENLABS_API_KEY=
|
||||||
|
INWORLD_API_KEY=
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# SignalWire (real callers)
|
||||||
|
SIGNALWIRE_PROJECT_ID=
|
||||||
|
SIGNALWIRE_SPACE=
|
||||||
|
SIGNALWIRE_TOKEN=
|
||||||
|
SIGNALWIRE_PHONE=
|
||||||
|
SIGNALWIRE_STREAM_URL=
|
||||||
|
|
||||||
|
# Social media
|
||||||
|
TWITTER_API_KEY=
|
||||||
|
TWITTER_API_SECRET=
|
||||||
|
TWITTER_ACCESS_TOKEN=
|
||||||
|
TWITTER_ACCESS_TOKEN_SECRET=
|
||||||
|
CLIENT_SECRET_ID=
|
||||||
|
CLIENT_SECRET=
|
||||||
|
POSTIZ_URL=
|
||||||
|
POSTIZ_API_KEY=
|
||||||
|
BSKY_APP_PASSWORD=
|
||||||
|
|
||||||
|
# Castopod
|
||||||
|
CASTOPOD_USERNAME=admin
|
||||||
|
CASTOPOD_PASSWORD=
|
||||||
|
CASTOPOD_DB_PASS=
|
||||||
|
|
||||||
|
# Postiz publishing
|
||||||
|
POSTIZ_JWT_SECRET=
|
||||||
|
POSTIZ_USER_ID=
|
||||||
|
POSTIZ_INTEGRATIONS={}
|
||||||
|
|
||||||
|
# BunnyCDN
|
||||||
|
BUNNY_STORAGE_KEY=
|
||||||
|
BUNNY_ACCOUNT_KEY=
|
||||||
|
|
||||||
|
# Monitoring
|
||||||
|
HEARTBEAT_URL=
|
||||||
|
|
||||||
|
# Google AI
|
||||||
|
GOOGLE_API_KEY=
|
||||||
|
|
||||||
|
# Email (IMAP)
|
||||||
|
SUBMISSIONS_IMAP_HOST=
|
||||||
|
SUBMISSIONS_IMAP_USER=
|
||||||
|
SUBMISSIONS_IMAP_PASS=
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
5f7e3e7
|
||||||
+30
@@ -50,5 +50,35 @@ voices-v1.0.bin
|
|||||||
# Reference voices for TTS
|
# Reference voices for TTS
|
||||||
ref_audio/
|
ref_audio/
|
||||||
|
|
||||||
|
# YouTube OAuth credentials
|
||||||
|
youtube_client_secrets.json
|
||||||
|
youtube_token.json
|
||||||
|
|
||||||
|
# Clip upload history (local)
|
||||||
|
upload-history.json
|
||||||
|
|
||||||
# Claude settings (local)
|
# Claude settings (local)
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
|
# Scratch/smoke tests (not committed)
|
||||||
|
scratch/
|
||||||
|
|
||||||
|
# Generated media and model weights (~13GB — never commit)
|
||||||
|
clips/
|
||||||
|
music/
|
||||||
|
mlx_models/
|
||||||
|
remotion-demo/
|
||||||
|
social_posts/
|
||||||
|
|
||||||
|
# Generated analytics artifacts (rebuilt from cost_tracker)
|
||||||
|
data/costs.db
|
||||||
|
data/cost_reports/
|
||||||
|
|
||||||
|
# Generated caller avatars
|
||||||
|
data/avatars/
|
||||||
|
|
||||||
|
# Reaper waveform peak cache
|
||||||
|
reaper/peaks/
|
||||||
|
|||||||
@@ -1,32 +1,16 @@
|
|||||||
# AI Podcast - Project Instructions
|
# AI Podcast - Project Instructions
|
||||||
|
|
||||||
## Git Remote (Gitea)
|
|
||||||
- **Repo**: `git@gitea-nas:luke/ai-podcast.git`
|
|
||||||
- **Web**: http://mmgnas:3000/luke/ai-podcast
|
|
||||||
- **SSH Host**: `gitea-nas` (configured in ~/.ssh/config)
|
|
||||||
- HostName: `mmgnas` (use `mmgnas-10g` if wired connection issues)
|
|
||||||
- Port: `2222`
|
|
||||||
- User: `git`
|
|
||||||
- IdentityFile: `~/.ssh/gitea_mmgnas`
|
|
||||||
|
|
||||||
## NAS Access
|
|
||||||
- **Hostname**: `mmgnas` (wireless) or `mmgnas-10g` (wired/10G)
|
|
||||||
- **SSH Port**: 8001
|
|
||||||
- **User**: luke
|
|
||||||
- **Docker path**: `/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker`
|
|
||||||
|
|
||||||
## Castopod (Podcast Publishing)
|
## Castopod (Podcast Publishing)
|
||||||
- **URL**: https://podcast.macneilmediagroup.com
|
- **URL**: https://podcast.macneilmediagroup.com
|
||||||
- **Podcast handle**: `@LukeAtTheRoost`
|
- **Podcast handle**: `@LukeAtTheRoost`
|
||||||
- **API Auth**: Basic auth (admin/podcast2026api)
|
- **API Auth**: Basic auth (credentials in .env: CASTOPOD_USERNAME, CASTOPOD_PASSWORD)
|
||||||
- **Container**: `castopod-castopod-1`
|
- **Container**: `castopod-castopod-1`
|
||||||
- **Database**: `castopod-mariadb-1` (user: castopod, db: castopod)
|
- **Database**: `castopod-mariadb-1` (user: castopod, db: castopod)
|
||||||
|
|
||||||
## Running the App
|
## Running the App
|
||||||
```bash
|
```bash
|
||||||
# Start backend
|
# Start backend — ALWAYS use --reload-dir to avoid CPU thrashing from file watchers
|
||||||
cd /Users/lukemacneil/ai-podcast
|
python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
|
||||||
python -m uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
|
|
||||||
|
|
||||||
# Or use run.sh
|
# Or use run.sh
|
||||||
./run.sh
|
./run.sh
|
||||||
@@ -43,8 +27,7 @@ Required in `.env`:
|
|||||||
- 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)
|
## Post-Production Pipeline
|
||||||
- **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.
|
- **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.
|
- **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`
|
- **API endpoints**: `POST /api/recording/start`, `POST /api/recording/stop` (auto-runs postprod in background thread), `POST /api/recording/process`
|
||||||
@@ -55,11 +38,80 @@ Required in `.env`:
|
|||||||
## LLM Settings
|
## 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.
|
- `_pick_response_budget()` in main.py controls caller dialog token limits (150-450 tokens). MiniMax respects limits strictly — if responses seem short, check these values.
|
||||||
- Default max_tokens in llm.py is 300 (for non-caller uses)
|
- Default max_tokens in llm.py is 300 (for non-caller uses)
|
||||||
- Grok (`x-ai/grok-4-fast`) works well for natural dialog; MiniMax tends toward terse responses
|
- Grok (`x-ai/grok-4.3`) works well for natural dialog; MiniMax tends toward terse responses. Note `grok-4`, `grok-4-fast` and `grok-4.1-fast` were retired from OpenRouter — a retired id 404s, and `llm.py` swallows the error and returns empty text, so callers go silent with nothing in the logs. `tests/test_model_config.py` guards against reintroducing them.
|
||||||
|
- `generate_with_tools()` in llm.py supports OpenRouter function calling for the intern feature
|
||||||
|
|
||||||
|
## Caller Generation System
|
||||||
|
- **Two-stage pipeline**: (1) batch identity pregen via Sonnet 4.6 at session start, (2) live dialog via Sonnet 4.6 per turn. Cost ~$3-4/show.
|
||||||
|
- **Slim caller dict**: Populated once at `Session._pregenerate_backgrounds()` via `caller_gen.generate_batch()`. Keys: `name`, `age`, `voice`, `location`, `identity`, `situation`, `reason_calling`, `opening_line`, `secret_want`, `specific_details`, `emotional_register`. Stored in `session.caller_backgrounds[caller_key]`.
|
||||||
|
- **Dialog model**: Always Sonnet 4.6 via the `caller_dialog` category in `config.category_models`. No per-caller model routing — deleted in Phase 5B.
|
||||||
|
- **Prompt builder**: `get_caller_prompt(caller)` in main.py builds the slim system prompt from the dict; see `tests/test_caller_prompt.py` for the contract.
|
||||||
|
- **Regulars**: `backend/services/regulars_v2.py` loads lore from Obsidian markdown files for named recurring callers (e.g. Silas). The batch prompt optionally includes 2-3 active regulars per session.
|
||||||
|
- **Inter-caller awareness**: `get_show_history()` scores previous callers by keyword overlap with the current caller's `situation`/`reason_calling`. Reaction frequency scales with match strength (60%/35%/15%).
|
||||||
|
- **Caller memory**: Returning callers auto-promote from first-timers at ~5% probability after 8+ exchanges. `RegularCallerService` tracks summaries, relationships, arc state.
|
||||||
|
- **Call quality signals**: `_assess_call_quality()` captures exchange count, response length, host engagement, caller depth, natural ending.
|
||||||
|
|
||||||
|
## Devon (Intern Character)
|
||||||
|
- **Service**: `backend/services/intern.py` — persistent show character, not a caller
|
||||||
|
- **Personality**: 23-year-old NMSU grad, eager, slightly incompetent, gets yelled at. Voice: "Nate" (Inworld), no phone filter.
|
||||||
|
- **Tools**: web_search (SearXNG), get_headlines, fetch_webpage, wikipedia_lookup — via `generate_with_tools()` function calling
|
||||||
|
- **SearXNG**: runs on the NAS (`http://mmgnas:8888`), deployed via `deploy_searxng.sh`. URL is `settings.searxng_url` (env `SEARXNG_URL`). If Devon's web_search returns "Search failed", check the `searxng` container on mmgnas is Up. The config enables the JSON API + disables the bot limiter — both required for programmatic queries.
|
||||||
|
- **Endpoints**: `POST /api/intern/ask`, `/interject`, `/monitor`, `GET /api/intern/suggestion`, `POST /api/intern/suggestion/play`, `/dismiss`
|
||||||
|
- **Auto-monitoring**: Watches conversation every 15s during calls, buffers suggestions for host approval
|
||||||
|
- **Persistence**: `data/intern.json` stores lookup history
|
||||||
|
- **Frontend**: Ask Devon input (D key), Interject button, monitor toggle, suggestion indicator with Play/Dismiss
|
||||||
|
|
||||||
|
## Frontend Control Panel
|
||||||
|
- **Keyboard shortcuts**: 1-0 (callers), H (hangup), W (wrap up), M (music toggle), D (ask Devon), Escape (close modals)
|
||||||
|
- **Wrap It Up**: Amber button that signals callers to wind down gracefully. Reduces response budget, injects wrap-up signals, forces goodbye after 2 exchanges.
|
||||||
|
- **Caller info panel**: Shows identity, situation, signature detail, secret want during active calls
|
||||||
|
- **Caller buttons**: Populated from the slim caller background dicts
|
||||||
|
- **Pinned SFX**: Cheer/Applause/Boo always visible, rest collapsible
|
||||||
|
- **Visual polish**: Thinking pulse, call glow, compact media row, smoother transitions
|
||||||
|
|
||||||
## Website
|
## Website
|
||||||
- **Domain**: lukeattheroost.com (behind Cloudflare)
|
- **Domain**: lukeattheroost.com (behind Cloudflare)
|
||||||
- **Analytics**: Cloudflare Web Analytics (enable in Cloudflare dashboard, no code changes needed)
|
- **Analytics**: Cloudflare Web Analytics (enable in Cloudflare dashboard, no code changes needed)
|
||||||
|
- **Deploy**: `npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main`
|
||||||
|
|
||||||
## Episodes Published
|
## Podcast Workflow
|
||||||
- Episode 6 published 2026-02-08 (podcast6.mp3, ~31 min)
|
- Publishing pipeline: episodes go through Castopod, CDN, website, YouTube, and social
|
||||||
|
- Always check Python venv is active and packages are installed before running publish scripts
|
||||||
|
- Episode numbering: check Castopod for the latest episode number, don't hardcode
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
- `publish_episode.py` — Transcribes audio, generates metadata (title, description, cover art), publishes to Castopod. Usage: `python publish_episode.py ~/Desktop/episode.mp3`
|
||||||
|
- `make_clips.py` — Two-pass clip extraction: fast Whisper transcription → LLM selects best moments → quality Whisper re-transcription for precise timestamps. Usage: `python make_clips.py ~/Desktop/episode.mp3 --count 3`
|
||||||
|
- `generate_milestone_images.py` — Generates social milestone images via Gemini Flash (requires GOOGLE_API_KEY)
|
||||||
|
- `post_milestone.py` — Posts milestone announcements to social platforms via Postiz
|
||||||
|
- `make_x_launch_assets.py` — Generates branded visual assets for X/Twitter (header, quote cards, intro/review graphics)
|
||||||
|
- `schedule_x_launch.py` — Schedules X/Twitter launch campaign posts via Postiz API
|
||||||
|
|
||||||
|
## Reaper Scripts
|
||||||
|
- `reaper/dialog_regions.lua` — Background script that polls `/tmp/reaper_state.txt` and creates colored regions (green=DIALOG, red=AD, blue=IDENT) as the backend writes state changes during recording
|
||||||
|
- `reaper/strip_silence_dialog.lua` — Post-production script: strips long silences from dialog regions, normalizes AD/IDENT/music volume, trims music to voice length with fade-out, mutes music during AD/IDENT regions
|
||||||
|
|
||||||
|
## Cost Dashboard
|
||||||
|
- **Route**: `/costs` — standalone analytics page, linked from control panel header
|
||||||
|
- **Database**: `data/costs.db` (SQLite) — aggregates all session cost data for cross-session queries
|
||||||
|
- **Data layer**: `backend/services/cost_db.py` — schema, JSON import, all query functions
|
||||||
|
- **Dual-write**: `cost_tracker.py` writes to both JSON (`data/cost_reports/`) and SQLite on every LLM/TTS call
|
||||||
|
- **API**: 8 endpoints under `/api/costs/` — summary, timeline, models, categories, sessions, session detail, expensive calls, TTS providers
|
||||||
|
- **Frontend**: `frontend/costs.html`, `frontend/css/costs.css`, `frontend/js/costs.js` — Chart.js for visualizations
|
||||||
|
- **Pricing**: Hardcoded in `cost_tracker.py` (`OPENROUTER_PRICING`, `TTS_PRICING`) — update when provider prices change
|
||||||
|
- **Not tracked yet**: SignalWire call costs
|
||||||
|
|
||||||
|
## Data Directory
|
||||||
|
State files (not config — these are written at runtime):
|
||||||
|
- `regulars.json` — Returning caller profiles (backgrounds, key moments, arc status, relationships)
|
||||||
|
- `used_topics_history.json` — Previously used caller topics to avoid repeats
|
||||||
|
- `session_checkpoint.json` — Current show session state (call history, caller queue)
|
||||||
|
- `publish_state.json` — Publishing pipeline progress per episode
|
||||||
|
- `intern.json` — Devon's lookup history
|
||||||
|
- `emails.json` — Listener email submissions
|
||||||
|
- `voicemails.json` — Listener voicemail submissions
|
||||||
|
|
||||||
|
## Personal
|
||||||
|
- Don't build anything until you have 95% clarity on what I want you to do. Ask clarifying questions until you reach 95% understanding of what I'm asking
|
||||||
|
- When working as a team, propose the plan before executing — don't just start building
|
||||||
|
- Flag trade-offs that affect show quality or listener experience rather than silently resolving them
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Luke MacNeil / MacNeil Media Group, LLC
|
||||||
|
https://macneilmediagroup.com
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Analyze silence gaps in podcast stems to find optimal strip-silence thresholds.
|
||||||
|
|
||||||
|
Usage: python analyze_gaps.py recordings/2026-03-17_235137/
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BLOCK_SEC = 0.1
|
||||||
|
SILENCE_DB = -30
|
||||||
|
THRESHOLD = 10 ** (SILENCE_DB / 20)
|
||||||
|
MIN_VOICE_SEC = 0.3
|
||||||
|
|
||||||
|
|
||||||
|
def load_stem(path: Path) -> tuple[np.ndarray, int]:
|
||||||
|
audio, sr = sf.read(path, dtype="float32")
|
||||||
|
if audio.ndim > 1:
|
||||||
|
audio = audio[:, 0]
|
||||||
|
return audio, sr
|
||||||
|
|
||||||
|
|
||||||
|
def compute_rms_blocks(audio: np.ndarray, sr: int) -> np.ndarray:
|
||||||
|
block_samples = int(sr * BLOCK_SEC)
|
||||||
|
n_blocks = len(audio) // block_samples
|
||||||
|
if n_blocks == 0:
|
||||||
|
return np.array([0.0])
|
||||||
|
trimmed = audio[:n_blocks * block_samples].reshape(n_blocks, block_samples)
|
||||||
|
return np.sqrt(np.mean(trimmed ** 2, axis=1))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_peak_blocks(audio: np.ndarray, sr: int) -> np.ndarray:
|
||||||
|
block_samples = int(sr * BLOCK_SEC)
|
||||||
|
n_blocks = len(audio) // block_samples
|
||||||
|
if n_blocks == 0:
|
||||||
|
return np.array([0.0])
|
||||||
|
trimmed = audio[:n_blocks * block_samples].reshape(n_blocks, block_samples)
|
||||||
|
return np.max(np.abs(trimmed), axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(stems_dir: Path):
|
||||||
|
stems_dir = Path(stems_dir)
|
||||||
|
voice_stems = {}
|
||||||
|
for name in ["host", "devon", "caller"]:
|
||||||
|
path = stems_dir / f"{name}.wav"
|
||||||
|
if path.exists():
|
||||||
|
print(f"Loading {name}...", end=" ", flush=True)
|
||||||
|
audio, sr = load_stem(path)
|
||||||
|
voice_stems[name] = audio
|
||||||
|
print(f"{len(audio)/sr:.0f}s @ {sr}Hz")
|
||||||
|
|
||||||
|
if not voice_stems:
|
||||||
|
print("No voice stems found")
|
||||||
|
return
|
||||||
|
|
||||||
|
sr_val = sr
|
||||||
|
duration = max(len(a) for a in voice_stems.values()) / sr_val
|
||||||
|
print(f"\nTotal duration: {duration/60:.1f} min")
|
||||||
|
|
||||||
|
# Compute per-track RMS and peak blocks
|
||||||
|
track_rms = {}
|
||||||
|
track_peak = {}
|
||||||
|
for name, audio in voice_stems.items():
|
||||||
|
track_rms[name] = compute_rms_blocks(audio, sr_val)
|
||||||
|
track_peak[name] = compute_peak_blocks(audio, sr_val)
|
||||||
|
|
||||||
|
n_blocks = min(len(v) for v in track_peak.values())
|
||||||
|
|
||||||
|
# Detect gaps using same logic as Lua script (RMS for speaker ID, peak for silence)
|
||||||
|
min_voice_blocks = int(MIN_VOICE_SEC / BLOCK_SEC)
|
||||||
|
track_names = list(voice_stems.keys())
|
||||||
|
|
||||||
|
gaps = []
|
||||||
|
in_silence = False
|
||||||
|
silence_start = 0
|
||||||
|
track_before = None
|
||||||
|
last_active = None
|
||||||
|
voice_run = 0
|
||||||
|
voice_run_track = None
|
||||||
|
|
||||||
|
for i in range(n_blocks):
|
||||||
|
# Peak for silence detection
|
||||||
|
best_peak = max(track_peak[name][i] for name in track_names)
|
||||||
|
# RMS for speaker identification
|
||||||
|
best_rms = 0
|
||||||
|
best_track = None
|
||||||
|
for name in track_names:
|
||||||
|
r = track_rms[name][i]
|
||||||
|
if r > best_rms:
|
||||||
|
best_rms = r
|
||||||
|
best_track = name
|
||||||
|
|
||||||
|
all_silent = best_peak < THRESHOLD
|
||||||
|
|
||||||
|
if not all_silent:
|
||||||
|
last_active = best_track
|
||||||
|
|
||||||
|
if in_silence:
|
||||||
|
if all_silent:
|
||||||
|
voice_run = 0
|
||||||
|
voice_run_track = None
|
||||||
|
else:
|
||||||
|
if voice_run == 0:
|
||||||
|
voice_run_track = best_track
|
||||||
|
voice_run += 1
|
||||||
|
if voice_run >= min_voice_blocks:
|
||||||
|
voice_start_block = i - (voice_run - 1)
|
||||||
|
gap_start = silence_start * BLOCK_SEC
|
||||||
|
gap_end = voice_start_block * BLOCK_SEC
|
||||||
|
dur = gap_end - gap_start
|
||||||
|
if dur >= 0.5: # log gaps >= 0.5s
|
||||||
|
gaps.append({
|
||||||
|
"start": gap_start,
|
||||||
|
"end": gap_end,
|
||||||
|
"dur": dur,
|
||||||
|
"before": track_before or "?",
|
||||||
|
"after": voice_run_track or "?",
|
||||||
|
})
|
||||||
|
in_silence = False
|
||||||
|
voice_run = 0
|
||||||
|
voice_run_track = None
|
||||||
|
else:
|
||||||
|
if all_silent:
|
||||||
|
in_silence = True
|
||||||
|
silence_start = i
|
||||||
|
track_before = last_active
|
||||||
|
voice_run = 0
|
||||||
|
voice_run_track = None
|
||||||
|
|
||||||
|
# Trailing silence
|
||||||
|
if in_silence:
|
||||||
|
dur = (n_blocks - silence_start) * BLOCK_SEC
|
||||||
|
if dur >= 0.5:
|
||||||
|
gaps.append({
|
||||||
|
"start": silence_start * BLOCK_SEC,
|
||||||
|
"end": n_blocks * BLOCK_SEC,
|
||||||
|
"dur": dur,
|
||||||
|
"before": track_before or "?",
|
||||||
|
"after": "end",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not gaps:
|
||||||
|
print("No gaps detected")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Categorize gaps
|
||||||
|
categories = {
|
||||||
|
"host_self": [], # Host -> Host
|
||||||
|
"host_to_caller": [], # Host -> Caller (TTS latency)
|
||||||
|
"caller_to_host": [], # Caller -> Host
|
||||||
|
"host_to_devon": [], # Host -> Devon (TTS latency)
|
||||||
|
"devon_to_host": [], # Devon -> Host
|
||||||
|
"caller_to_devon": [],# Caller -> Devon (interjection)
|
||||||
|
"devon_to_caller": [],# Devon -> Caller
|
||||||
|
"other": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for g in gaps:
|
||||||
|
b, a = g["before"], g["after"]
|
||||||
|
if b == "host" and a == "host":
|
||||||
|
categories["host_self"].append(g)
|
||||||
|
elif b == "host" and a == "caller":
|
||||||
|
categories["host_to_caller"].append(g)
|
||||||
|
elif b == "caller" and a == "host":
|
||||||
|
categories["caller_to_host"].append(g)
|
||||||
|
elif b == "host" and a == "devon":
|
||||||
|
categories["host_to_devon"].append(g)
|
||||||
|
elif b == "devon" and a == "host":
|
||||||
|
categories["devon_to_host"].append(g)
|
||||||
|
elif b == "caller" and a == "devon":
|
||||||
|
categories["caller_to_devon"].append(g)
|
||||||
|
elif b == "devon" and a == "caller":
|
||||||
|
categories["devon_to_caller"].append(g)
|
||||||
|
else:
|
||||||
|
categories["other"].append(g)
|
||||||
|
|
||||||
|
# Print results
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print(f"GAP ANALYSIS — {len(gaps)} gaps detected")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
|
||||||
|
total_silence = sum(g["dur"] for g in gaps)
|
||||||
|
print(f"Total silence: {total_silence:.0f}s ({total_silence/60:.1f} min)")
|
||||||
|
print(f"Content after removal: ~{(duration - total_silence)/60:.1f} min")
|
||||||
|
|
||||||
|
for cat_name, cat_gaps in sorted(categories.items(), key=lambda x: -len(x[1])):
|
||||||
|
if not cat_gaps:
|
||||||
|
continue
|
||||||
|
durs = sorted([g["dur"] for g in cat_gaps])
|
||||||
|
print(f"\n--- {cat_name} ({len(cat_gaps)} gaps) ---")
|
||||||
|
print(f" Range: {durs[0]:.1f}s - {durs[-1]:.1f}s")
|
||||||
|
print(f" Median: {np.median(durs):.1f}s Mean: {np.mean(durs):.1f}s")
|
||||||
|
if len(durs) >= 5:
|
||||||
|
print(f" P25: {np.percentile(durs, 25):.1f}s P75: {np.percentile(durs, 75):.1f}s")
|
||||||
|
|
||||||
|
# Histogram
|
||||||
|
brackets = [(0, 1), (1, 2), (2, 3), (3, 5), (5, 8), (8, 12), (12, 18), (18, 30), (30, 60), (60, 999)]
|
||||||
|
print(f" Distribution:")
|
||||||
|
for lo, hi in brackets:
|
||||||
|
count = sum(1 for d in durs if lo <= d < hi)
|
||||||
|
if count > 0:
|
||||||
|
bar = "#" * count
|
||||||
|
label = f"{lo}-{hi}s" if hi < 999 else f"{lo}s+"
|
||||||
|
print(f" {label:>8s}: {bar} ({count})")
|
||||||
|
|
||||||
|
# Find natural clusters and suggest thresholds
|
||||||
|
print(f"\n{'='*70}")
|
||||||
|
print("SUGGESTED THRESHOLDS")
|
||||||
|
print(f"{'='*70}")
|
||||||
|
|
||||||
|
# For each Devon-involved category, find the gap between interjection and TTS gaps
|
||||||
|
devon_gaps = categories["host_to_devon"] + categories["devon_to_host"] + categories["caller_to_devon"] + categories["devon_to_caller"]
|
||||||
|
if devon_gaps:
|
||||||
|
devon_durs = sorted([g["dur"] for g in devon_gaps])
|
||||||
|
# Look for a natural break between short (interjection) and long (TTS) gaps
|
||||||
|
short = [d for d in devon_durs if d < 5]
|
||||||
|
long = [d for d in devon_durs if d >= 5]
|
||||||
|
if short and long:
|
||||||
|
suggested = (max(short) + min(long)) / 2
|
||||||
|
print(f"Devon threshold: {suggested:.1f}s (short gaps: {len(short)} up to {max(short):.1f}s, long gaps: {len(long)} from {min(long):.1f}s)")
|
||||||
|
elif short:
|
||||||
|
print(f"Devon threshold: {max(short) + 1:.1f}s (all gaps are short, max {max(short):.1f}s)")
|
||||||
|
else:
|
||||||
|
print(f"Devon threshold: 3.0s (all gaps are long, min {min(long):.1f}s)")
|
||||||
|
|
||||||
|
caller_gaps = categories["host_to_caller"] + categories["caller_to_host"]
|
||||||
|
if caller_gaps:
|
||||||
|
caller_durs = sorted([g["dur"] for g in caller_gaps])
|
||||||
|
short = [d for d in caller_durs if d < 5]
|
||||||
|
long = [d for d in caller_durs if d >= 5]
|
||||||
|
if short and long:
|
||||||
|
suggested = (max(short) + min(long)) / 2
|
||||||
|
print(f"Caller transition threshold: {suggested:.1f}s (short: {len(short)} up to {max(short):.1f}s, long: {len(long)} from {min(long):.1f}s)")
|
||||||
|
elif long:
|
||||||
|
print(f"Caller transition threshold: {min(long) - 1:.1f}s (all gaps >= {min(long):.1f}s)")
|
||||||
|
|
||||||
|
host_self = categories["host_self"]
|
||||||
|
if host_self:
|
||||||
|
host_durs = sorted([g["dur"] for g in host_self])
|
||||||
|
short = [d for d in host_durs if d < 5]
|
||||||
|
long = [d for d in host_durs if d >= 5]
|
||||||
|
if short and long:
|
||||||
|
suggested = (max(short) + min(long)) / 2
|
||||||
|
print(f"Same-speaker threshold: {suggested:.1f}s (short: {len(short)} up to {max(short):.1f}s, long: {len(long)} from {min(long):.1f}s)")
|
||||||
|
elif long:
|
||||||
|
print(f"Same-speaker threshold: {min(long) - 1:.1f}s (all gaps >= {min(long):.1f}s)")
|
||||||
|
|
||||||
|
all_durs = sorted([g["dur"] for g in gaps])
|
||||||
|
would_cut = [d for d in all_durs if d >= 3.0]
|
||||||
|
print(f"\nWith current thresholds (Devon=3s, others=6s):")
|
||||||
|
print(f" Would cut: ~{len(would_cut)} gaps, ~{sum(would_cut):.0f}s ({sum(would_cut)/60:.1f} min)")
|
||||||
|
print(f" Result: ~{(duration - sum(would_cut))/60:.1f} min")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python analyze_gaps.py <stems_dir>")
|
||||||
|
sys.exit(1)
|
||||||
|
analyze(Path(sys.argv[1]))
|
||||||
+9
-2
@@ -1,11 +1,18 @@
|
|||||||
{
|
{
|
||||||
"input_device": 13,
|
"input_device": 14,
|
||||||
|
"input_device_name": "Babyface Pro (70793771)",
|
||||||
"input_channel": 1,
|
"input_channel": 1,
|
||||||
"output_device": 12,
|
"output_device": 13,
|
||||||
|
"output_device_name": "Radio Voice Mic",
|
||||||
"caller_channel": 3,
|
"caller_channel": 3,
|
||||||
|
"devon_channel": 17,
|
||||||
"live_caller_channel": 9,
|
"live_caller_channel": 9,
|
||||||
"music_channel": 5,
|
"music_channel": 5,
|
||||||
"sfx_channel": 7,
|
"sfx_channel": 7,
|
||||||
"ad_channel": 11,
|
"ad_channel": 11,
|
||||||
|
"ident_channel": 15,
|
||||||
|
"monitor_device": null,
|
||||||
|
"monitor_device_name": null,
|
||||||
|
"monitor_channel": 1,
|
||||||
"phone_filter": false
|
"phone_filter": false
|
||||||
}
|
}
|
||||||
+22
-1
@@ -22,12 +22,32 @@ class Settings(BaseSettings):
|
|||||||
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
|
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
|
||||||
signalwire_stream_url: str = os.getenv("SIGNALWIRE_STREAM_URL", "")
|
signalwire_stream_url: str = os.getenv("SIGNALWIRE_STREAM_URL", "")
|
||||||
|
|
||||||
|
# Email (IMAP)
|
||||||
|
submissions_imap_host: str = os.getenv("SUBMISSIONS_IMAP_HOST", "")
|
||||||
|
submissions_imap_user: str = os.getenv("SUBMISSIONS_IMAP_USER", "")
|
||||||
|
submissions_imap_pass: str = os.getenv("SUBMISSIONS_IMAP_PASS", "")
|
||||||
|
|
||||||
|
# SearXNG (Devon's web search + caller news grounding) — runs on the NAS.
|
||||||
|
# Override with SEARXNG_URL in .env (e.g. http://localhost:8888 for a local container).
|
||||||
|
searxng_url: str = os.getenv("SEARXNG_URL", "http://mmgnas:8888")
|
||||||
|
|
||||||
# LLM Settings
|
# LLM Settings
|
||||||
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
||||||
openrouter_model: str = "anthropic/claude-sonnet-4-5"
|
openrouter_model: str = "anthropic/claude-sonnet-4.6" # primary/default model
|
||||||
ollama_model: str = "llama3.2"
|
ollama_model: str = "llama3.2"
|
||||||
ollama_host: str = "http://localhost:11434"
|
ollama_host: str = "http://localhost:11434"
|
||||||
|
|
||||||
|
# Per-category model routing
|
||||||
|
category_models: dict = {
|
||||||
|
"caller_dialog": "anthropic/claude-sonnet-4.6", # live caller dialog — quality matters ($3/$15)
|
||||||
|
"devon_ask": "x-ai/grok-4.3", # Devon matches show energy (grok-4.1-fast deprecated 2026-05)
|
||||||
|
"devon_monitor": "google/gemini-2.5-flash", # just yes/no decisions, keep cheap ($0.15/$0.60)
|
||||||
|
"background_gen": "anthropic/claude-sonnet-4.6", # backgrounds drive the whole call — worth the quality ($3/$15, ~$0.30/show)
|
||||||
|
"call_summary": "google/gemini-2.5-flash", # post-call, no personality needed ($0.15/$0.60)
|
||||||
|
"news_summary": "google/gemini-2.5-flash", # just digesting headlines ($0.15/$0.60)
|
||||||
|
"topic_gen": "google/gemini-2.5-flash", # structured output ($0.15/$0.60)
|
||||||
|
}
|
||||||
|
|
||||||
# TTS Settings
|
# TTS Settings
|
||||||
tts_provider: str = "inworld" # "kokoro", "elevenlabs", "inworld", "vits", or "bark"
|
tts_provider: str = "inworld" # "kokoro", "elevenlabs", "inworld", "vits", or "bark"
|
||||||
|
|
||||||
@@ -39,6 +59,7 @@ class Settings(BaseSettings):
|
|||||||
sounds_dir: Path = base_dir / "sounds"
|
sounds_dir: Path = base_dir / "sounds"
|
||||||
music_dir: Path = base_dir / "music"
|
music_dir: Path = base_dir / "music"
|
||||||
ads_dir: Path = base_dir / "ads"
|
ads_dir: Path = base_dir / "ads"
|
||||||
|
idents_dir: Path = base_dir / "idents"
|
||||||
sessions_dir: Path = base_dir / "sessions"
|
sessions_dir: Path = base_dir / "sessions"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
+3163
-1903
File diff suppressed because it is too large
Load Diff
+443
-71
@@ -10,24 +10,68 @@ from typing import Optional, Callable
|
|||||||
import wave
|
import wave
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
# Settings file path
|
# Settings file path
|
||||||
SETTINGS_FILE = Path(__file__).parent.parent.parent / "audio_settings.json"
|
SETTINGS_FILE = Path(__file__).parent.parent.parent / "audio_settings.json"
|
||||||
|
|
||||||
|
# REAPER state file for dialog region markers
|
||||||
|
REAPER_STATE_FILE = "/tmp/reaper_state.txt"
|
||||||
|
|
||||||
|
def _write_reaper_state(state: str):
|
||||||
|
"""Write state to file. Uses a thread so it's safe from audio callbacks."""
|
||||||
|
def _write():
|
||||||
|
try:
|
||||||
|
with open(REAPER_STATE_FILE, "w") as f:
|
||||||
|
f.write(state)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
threading.Thread(target=_write, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
class AudioService:
|
class AudioService:
|
||||||
"""Manages audio I/O with multi-channel support for Loopback routing"""
|
"""Manages audio I/O with multi-channel support for Loopback routing"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_device_by_name(name: str) -> Optional[int]:
|
||||||
|
"""Find a device index by name substring match. Returns None if not found."""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
devices = sd.query_devices()
|
||||||
|
# Exact match first
|
||||||
|
for i, d in enumerate(devices):
|
||||||
|
if d["name"] == name:
|
||||||
|
return i
|
||||||
|
# Substring match
|
||||||
|
for i, d in enumerate(devices):
|
||||||
|
if name in d["name"]:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_device_name(device_id: Optional[int]) -> Optional[str]:
|
||||||
|
"""Get the name of a device by index."""
|
||||||
|
if device_id is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return sd.query_devices(device_id)["name"]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Device configuration
|
# Device configuration
|
||||||
self.input_device: Optional[int] = None
|
self.input_device: Optional[int] = 13 # Radio Voice Mic (loopback input)
|
||||||
self.input_channel: int = 1 # 1-indexed channel
|
self.input_channel: int = 1 # 1-indexed channel
|
||||||
|
|
||||||
self.output_device: Optional[int] = None # Single output device (multi-channel)
|
self.output_device: Optional[int] = 12 # Radio Voice Mic (loopback output)
|
||||||
self.caller_channel: int = 1 # Channel for caller TTS
|
self.caller_channel: int = 3 # Channel for caller TTS
|
||||||
|
self.devon_channel: int = 17 # Channel for Devon (intern)
|
||||||
self.live_caller_channel: int = 9 # Channel for live caller audio
|
self.live_caller_channel: int = 9 # Channel for live caller audio
|
||||||
self.music_channel: int = 2 # Channel for music
|
self.music_channel: int = 5 # Channel for music
|
||||||
self.sfx_channel: int = 3 # Channel for SFX
|
self.sfx_channel: int = 3 # Channel for SFX
|
||||||
self.ad_channel: int = 11 # Channel for ads
|
self.ad_channel: int = 11 # Channel for ads
|
||||||
|
self.ident_channel: int = 15 # Channel for idents (stereo: ch 15+16)
|
||||||
|
self.monitor_device: Optional[int] = 14 # Babyface Pro (headphone monitoring)
|
||||||
|
self.monitor_channel: int = 1 # Channel for mic monitoring on monitor device
|
||||||
self.phone_filter: bool = False # Phone filter on caller voices
|
self.phone_filter: bool = False # Phone filter on caller voices
|
||||||
|
|
||||||
# Ad playback state
|
# Ad playback state
|
||||||
@@ -37,6 +81,13 @@ class AudioService:
|
|||||||
self._ad_position: int = 0
|
self._ad_position: int = 0
|
||||||
self._ad_playing: bool = False
|
self._ad_playing: bool = False
|
||||||
|
|
||||||
|
# Ident playback state
|
||||||
|
self._ident_stream: Optional[sd.OutputStream] = None
|
||||||
|
self._ident_data: Optional[np.ndarray] = None
|
||||||
|
self._ident_resampled: Optional[np.ndarray] = None
|
||||||
|
self._ident_position: int = 0
|
||||||
|
self._ident_playing: bool = False
|
||||||
|
|
||||||
# Recording state
|
# Recording state
|
||||||
self._recording = False
|
self._recording = False
|
||||||
self._record_thread: Optional[threading.Thread] = None
|
self._record_thread: Optional[threading.Thread] = None
|
||||||
@@ -63,6 +114,7 @@ class AudioService:
|
|||||||
|
|
||||||
# Caller playback state
|
# Caller playback state
|
||||||
self._caller_stop_event = threading.Event()
|
self._caller_stop_event = threading.Event()
|
||||||
|
self._devon_stop_event = threading.Event()
|
||||||
self._caller_thread: Optional[threading.Thread] = None
|
self._caller_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
# Host mic streaming state
|
# Host mic streaming state
|
||||||
@@ -78,6 +130,10 @@ class AudioService:
|
|||||||
self.input_sample_rate = 16000 # For Whisper
|
self.input_sample_rate = 16000 # For Whisper
|
||||||
self.output_sample_rate = 24000 # For TTS
|
self.output_sample_rate = 24000 # For TTS
|
||||||
|
|
||||||
|
# Mic monitor (input → monitor device passthrough)
|
||||||
|
self._monitor_stream: Optional[sd.OutputStream] = None
|
||||||
|
self._monitor_write: Optional[Callable] = None
|
||||||
|
|
||||||
# Stem recording (opt-in, attached via API)
|
# Stem recording (opt-in, attached via API)
|
||||||
self.stem_recorder = None
|
self.stem_recorder = None
|
||||||
self._stem_mic_stream: Optional[sd.InputStream] = None
|
self._stem_mic_stream: Optional[sd.InputStream] = None
|
||||||
@@ -85,37 +141,63 @@ class AudioService:
|
|||||||
# Load saved settings
|
# Load saved settings
|
||||||
self._load_settings()
|
self._load_settings()
|
||||||
|
|
||||||
|
def _resolve_device(self, data: dict, key: str) -> Optional[int]:
|
||||||
|
"""Resolve a device from settings: try name first, fall back to index."""
|
||||||
|
name_key = f"{key}_name"
|
||||||
|
name = data.get(name_key)
|
||||||
|
if name:
|
||||||
|
resolved = self._find_device_by_name(name)
|
||||||
|
if resolved is not None:
|
||||||
|
idx = data.get(key)
|
||||||
|
if idx is not None and resolved != idx:
|
||||||
|
print(f"[Audio] Device '{name}' moved: {idx} -> {resolved}")
|
||||||
|
return resolved
|
||||||
|
else:
|
||||||
|
print(f"[Audio] Warning: device '{name}' not found, falling back to index {data.get(key)}")
|
||||||
|
return data.get(key)
|
||||||
|
|
||||||
def _load_settings(self):
|
def _load_settings(self):
|
||||||
"""Load settings from disk"""
|
"""Load settings from disk, resolving device names to current indices"""
|
||||||
if SETTINGS_FILE.exists():
|
if SETTINGS_FILE.exists():
|
||||||
try:
|
try:
|
||||||
with open(SETTINGS_FILE) as f:
|
with open(SETTINGS_FILE) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
self.input_device = data.get("input_device")
|
self.input_device = self._resolve_device(data, "input_device")
|
||||||
self.input_channel = data.get("input_channel", 1)
|
self.input_channel = data.get("input_channel", 1)
|
||||||
self.output_device = data.get("output_device")
|
self.output_device = self._resolve_device(data, "output_device")
|
||||||
self.caller_channel = data.get("caller_channel", 1)
|
self.caller_channel = data.get("caller_channel", 1)
|
||||||
|
self.devon_channel = data.get("devon_channel", 17)
|
||||||
self.live_caller_channel = data.get("live_caller_channel", 4)
|
self.live_caller_channel = data.get("live_caller_channel", 4)
|
||||||
self.music_channel = data.get("music_channel", 2)
|
self.music_channel = data.get("music_channel", 2)
|
||||||
self.sfx_channel = data.get("sfx_channel", 3)
|
self.sfx_channel = data.get("sfx_channel", 3)
|
||||||
self.ad_channel = data.get("ad_channel", 11)
|
self.ad_channel = data.get("ad_channel", 11)
|
||||||
|
self.ident_channel = data.get("ident_channel", 15)
|
||||||
|
self.monitor_device = self._resolve_device(data, "monitor_device")
|
||||||
|
self.monitor_channel = data.get("monitor_channel", 1)
|
||||||
self.phone_filter = data.get("phone_filter", False)
|
self.phone_filter = data.get("phone_filter", False)
|
||||||
print(f"Loaded audio settings: output={self.output_device}, channels={self.caller_channel}/{self.live_caller_channel}/{self.music_channel}/{self.sfx_channel}/ad:{self.ad_channel}, phone_filter={self.phone_filter}")
|
print(f"Loaded audio settings: input={self.input_device} ({self._get_device_name(self.input_device)}), output={self.output_device} ({self._get_device_name(self.output_device)}), monitor={self.monitor_device}, phone_filter={self.phone_filter}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to load audio settings: {e}")
|
print(f"Failed to load audio settings: {e}")
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
"""Save settings to disk"""
|
"""Save settings to disk with device names for stable resolution"""
|
||||||
try:
|
try:
|
||||||
data = {
|
data = {
|
||||||
"input_device": self.input_device,
|
"input_device": self.input_device,
|
||||||
|
"input_device_name": self._get_device_name(self.input_device),
|
||||||
"input_channel": self.input_channel,
|
"input_channel": self.input_channel,
|
||||||
"output_device": self.output_device,
|
"output_device": self.output_device,
|
||||||
|
"output_device_name": self._get_device_name(self.output_device),
|
||||||
"caller_channel": self.caller_channel,
|
"caller_channel": self.caller_channel,
|
||||||
|
"devon_channel": self.devon_channel,
|
||||||
"live_caller_channel": self.live_caller_channel,
|
"live_caller_channel": self.live_caller_channel,
|
||||||
"music_channel": self.music_channel,
|
"music_channel": self.music_channel,
|
||||||
"sfx_channel": self.sfx_channel,
|
"sfx_channel": self.sfx_channel,
|
||||||
"ad_channel": self.ad_channel,
|
"ad_channel": self.ad_channel,
|
||||||
|
"ident_channel": self.ident_channel,
|
||||||
|
"monitor_device": self.monitor_device,
|
||||||
|
"monitor_device_name": self._get_device_name(self.monitor_device),
|
||||||
|
"monitor_channel": self.monitor_channel,
|
||||||
"phone_filter": self.phone_filter,
|
"phone_filter": self.phone_filter,
|
||||||
}
|
}
|
||||||
with open(SETTINGS_FILE, "w") as f:
|
with open(SETTINGS_FILE, "w") as f:
|
||||||
@@ -144,10 +226,14 @@ class AudioService:
|
|||||||
input_channel: Optional[int] = None,
|
input_channel: Optional[int] = None,
|
||||||
output_device: Optional[int] = None,
|
output_device: Optional[int] = None,
|
||||||
caller_channel: Optional[int] = None,
|
caller_channel: Optional[int] = None,
|
||||||
|
devon_channel: Optional[int] = None,
|
||||||
live_caller_channel: Optional[int] = None,
|
live_caller_channel: Optional[int] = None,
|
||||||
music_channel: Optional[int] = None,
|
music_channel: Optional[int] = None,
|
||||||
sfx_channel: Optional[int] = None,
|
sfx_channel: Optional[int] = None,
|
||||||
ad_channel: Optional[int] = None,
|
ad_channel: Optional[int] = None,
|
||||||
|
ident_channel: Optional[int] = None,
|
||||||
|
monitor_device: Optional[int] = None,
|
||||||
|
monitor_channel: Optional[int] = None,
|
||||||
phone_filter: Optional[bool] = None
|
phone_filter: Optional[bool] = None
|
||||||
):
|
):
|
||||||
"""Configure audio devices and channels"""
|
"""Configure audio devices and channels"""
|
||||||
@@ -159,6 +245,8 @@ class AudioService:
|
|||||||
self.output_device = output_device
|
self.output_device = output_device
|
||||||
if caller_channel is not None:
|
if caller_channel is not None:
|
||||||
self.caller_channel = caller_channel
|
self.caller_channel = caller_channel
|
||||||
|
if devon_channel is not None:
|
||||||
|
self.devon_channel = devon_channel
|
||||||
if live_caller_channel is not None:
|
if live_caller_channel is not None:
|
||||||
self.live_caller_channel = live_caller_channel
|
self.live_caller_channel = live_caller_channel
|
||||||
if music_channel is not None:
|
if music_channel is not None:
|
||||||
@@ -167,6 +255,12 @@ class AudioService:
|
|||||||
self.sfx_channel = sfx_channel
|
self.sfx_channel = sfx_channel
|
||||||
if ad_channel is not None:
|
if ad_channel is not None:
|
||||||
self.ad_channel = ad_channel
|
self.ad_channel = ad_channel
|
||||||
|
if ident_channel is not None:
|
||||||
|
self.ident_channel = ident_channel
|
||||||
|
if monitor_device is not None:
|
||||||
|
self.monitor_device = monitor_device
|
||||||
|
if monitor_channel is not None:
|
||||||
|
self.monitor_channel = monitor_channel
|
||||||
if phone_filter is not None:
|
if phone_filter is not None:
|
||||||
self.phone_filter = phone_filter
|
self.phone_filter = phone_filter
|
||||||
|
|
||||||
@@ -180,10 +274,14 @@ class AudioService:
|
|||||||
"input_channel": self.input_channel,
|
"input_channel": self.input_channel,
|
||||||
"output_device": self.output_device,
|
"output_device": self.output_device,
|
||||||
"caller_channel": self.caller_channel,
|
"caller_channel": self.caller_channel,
|
||||||
|
"devon_channel": self.devon_channel,
|
||||||
"live_caller_channel": self.live_caller_channel,
|
"live_caller_channel": self.live_caller_channel,
|
||||||
"music_channel": self.music_channel,
|
"music_channel": self.music_channel,
|
||||||
"sfx_channel": self.sfx_channel,
|
"sfx_channel": self.sfx_channel,
|
||||||
"ad_channel": self.ad_channel,
|
"ad_channel": self.ad_channel,
|
||||||
|
"ident_channel": self.ident_channel,
|
||||||
|
"monitor_device": self.monitor_device,
|
||||||
|
"monitor_channel": self.monitor_channel,
|
||||||
"phone_filter": self.phone_filter,
|
"phone_filter": self.phone_filter,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,8 +381,9 @@ class AudioService:
|
|||||||
stream_ready.set()
|
stream_ready.set()
|
||||||
if self._recording:
|
if self._recording:
|
||||||
self._recorded_audio.append(indata[:, record_channel].copy())
|
self._recorded_audio.append(indata[:, record_channel].copy())
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
|
if rec:
|
||||||
|
rec.write("host", indata[:, record_channel].copy(), device_sr)
|
||||||
|
|
||||||
print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)")
|
print(f"Recording: opening stream on device {self.input_device} ch {self.input_channel} @ {device_sr}Hz ({max_channels} ch)")
|
||||||
|
|
||||||
@@ -329,13 +428,20 @@ class AudioService:
|
|||||||
|
|
||||||
return audio
|
return audio
|
||||||
|
|
||||||
def play_caller_audio(self, audio_bytes: bytes, sample_rate: int = 24000):
|
def play_caller_audio(self, audio_bytes: bytes, sample_rate: int = 24000, stem_name: str = "caller", channel_override: int | None = None):
|
||||||
"""Play caller TTS audio to specific channel of output device (interruptible)"""
|
"""Play TTS audio to specific channel of output device (interruptible)"""
|
||||||
import librosa
|
import librosa
|
||||||
|
|
||||||
# Stop any existing caller audio
|
# Devon uses its own stop event so hangup doesn't cut Devon's audio
|
||||||
self.stop_caller_audio()
|
is_devon = stem_name == "devon"
|
||||||
self._caller_stop_event.clear()
|
stop_event = self._devon_stop_event if is_devon else self._caller_stop_event
|
||||||
|
|
||||||
|
# Stop any existing audio on the same channel type
|
||||||
|
if is_devon:
|
||||||
|
self.stop_devon_audio()
|
||||||
|
else:
|
||||||
|
self.stop_caller_audio()
|
||||||
|
stop_event.clear()
|
||||||
|
|
||||||
# Convert bytes to numpy
|
# Convert bytes to numpy
|
||||||
audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
@@ -352,7 +458,8 @@ class AudioService:
|
|||||||
device_info = sd.query_devices(self.output_device)
|
device_info = sd.query_devices(self.output_device)
|
||||||
num_channels = device_info['max_output_channels']
|
num_channels = device_info['max_output_channels']
|
||||||
device_sr = int(device_info['default_samplerate'])
|
device_sr = int(device_info['default_samplerate'])
|
||||||
channel_idx = min(self.caller_channel, num_channels) - 1
|
ch = channel_override if channel_override is not None else self.caller_channel
|
||||||
|
channel_idx = min(ch, num_channels) - 1
|
||||||
|
|
||||||
# Resample if needed
|
# Resample if needed
|
||||||
if sample_rate != device_sr:
|
if sample_rate != device_sr:
|
||||||
@@ -365,7 +472,7 @@ class AudioService:
|
|||||||
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
|
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
|
||||||
multi_ch[:, channel_idx] = audio
|
multi_ch[:, channel_idx] = audio
|
||||||
|
|
||||||
print(f"Playing caller audio to device {self.output_device} ch {self.caller_channel} @ {device_sr}Hz")
|
print(f"Playing {stem_name} audio to device {self.output_device} ch {ch} @ {device_sr}Hz")
|
||||||
|
|
||||||
# Play in chunks so we can interrupt
|
# Play in chunks so we can interrupt
|
||||||
chunk_size = int(device_sr * 0.1) # 100ms chunks
|
chunk_size = int(device_sr * 0.1) # 100ms chunks
|
||||||
@@ -377,16 +484,17 @@ class AudioService:
|
|||||||
channels=num_channels,
|
channels=num_channels,
|
||||||
dtype=np.float32
|
dtype=np.float32
|
||||||
) as stream:
|
) as stream:
|
||||||
while pos < len(multi_ch) and not self._caller_stop_event.is_set():
|
while pos < len(multi_ch) and not stop_event.is_set():
|
||||||
end = min(pos + chunk_size, len(multi_ch))
|
end = min(pos + chunk_size, len(multi_ch))
|
||||||
stream.write(multi_ch[pos:end])
|
stream.write(multi_ch[pos:end])
|
||||||
# Record each chunk as it plays so hangups cut the stem too
|
# Record each chunk as it plays so hangups cut the stem too
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic("caller", audio[pos:end].copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic(stem_name, audio[pos:end].copy(), device_sr)
|
||||||
pos = end
|
pos = end
|
||||||
|
|
||||||
if self._caller_stop_event.is_set():
|
if stop_event.is_set():
|
||||||
print("Caller audio stopped early")
|
print(f"{stem_name.title()} audio stopped early")
|
||||||
else:
|
else:
|
||||||
print(f"Played caller audio: {len(audio)/device_sr:.2f}s")
|
print(f"Played caller audio: {len(audio)/device_sr:.2f}s")
|
||||||
|
|
||||||
@@ -397,6 +505,10 @@ class AudioService:
|
|||||||
"""Stop any playing caller audio"""
|
"""Stop any playing caller audio"""
|
||||||
self._caller_stop_event.set()
|
self._caller_stop_event.set()
|
||||||
|
|
||||||
|
def stop_devon_audio(self):
|
||||||
|
"""Stop any playing Devon audio (independent of caller audio)"""
|
||||||
|
self._devon_stop_event.set()
|
||||||
|
|
||||||
def _start_live_caller_stream(self):
|
def _start_live_caller_stream(self):
|
||||||
"""Start persistent output stream with ring buffer jitter absorption"""
|
"""Start persistent output stream with ring buffer jitter absorption"""
|
||||||
if self._live_caller_stream is not None:
|
if self._live_caller_stream is not None:
|
||||||
@@ -461,7 +573,7 @@ class AudioService:
|
|||||||
|
|
||||||
self._live_caller_write = write_audio
|
self._live_caller_write = write_audio
|
||||||
|
|
||||||
self._live_caller_stream = sd.OutputStream(
|
self._live_caller_stream = self._open_output_stream(
|
||||||
device=self.output_device,
|
device=self.output_device,
|
||||||
samplerate=device_sr,
|
samplerate=device_sr,
|
||||||
channels=num_channels,
|
channels=num_channels,
|
||||||
@@ -469,16 +581,15 @@ class AudioService:
|
|||||||
callback=callback,
|
callback=callback,
|
||||||
blocksize=1024,
|
blocksize=1024,
|
||||||
)
|
)
|
||||||
self._live_caller_stream.start()
|
|
||||||
print(f"[Audio] Live caller stream started on ch {self.live_caller_channel} @ {device_sr}Hz (prebuffer {prebuffer_samples} samples)")
|
print(f"[Audio] Live caller stream started on ch {self.live_caller_channel} @ {device_sr}Hz (prebuffer {prebuffer_samples} samples)")
|
||||||
|
|
||||||
def _stop_live_caller_stream(self):
|
def _stop_live_caller_stream(self):
|
||||||
"""Stop persistent live caller output stream"""
|
"""Stop persistent live caller output stream"""
|
||||||
if self._live_caller_stream:
|
if self._live_caller_stream:
|
||||||
self._live_caller_stream.stop()
|
stream = self._live_caller_stream
|
||||||
self._live_caller_stream.close()
|
|
||||||
self._live_caller_stream = None
|
self._live_caller_stream = None
|
||||||
self._live_caller_write = None
|
self._live_caller_write = None
|
||||||
|
self._close_stream(stream)
|
||||||
print("[Audio] Live caller stream stopped")
|
print("[Audio] Live caller stream stopped")
|
||||||
|
|
||||||
def route_real_caller_audio(self, pcm_data: bytes, sample_rate: int):
|
def route_real_caller_audio(self, pcm_data: bytes, sample_rate: int):
|
||||||
@@ -501,8 +612,9 @@ class AudioService:
|
|||||||
audio = audio[indices]
|
audio = audio[indices]
|
||||||
|
|
||||||
# Stem recording: live caller
|
# Stem recording: live caller
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic("caller", audio.copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic("caller", audio.copy(), device_sr)
|
||||||
|
|
||||||
if self._live_caller_write:
|
if self._live_caller_write:
|
||||||
self._live_caller_write(audio)
|
self._live_caller_write(audio)
|
||||||
@@ -523,9 +635,9 @@ class AudioService:
|
|||||||
|
|
||||||
# Close stem_mic if active — this stream's callback handles stem recording too
|
# Close stem_mic if active — this stream's callback handles stem recording too
|
||||||
if self._stem_mic_stream is not None:
|
if self._stem_mic_stream is not None:
|
||||||
self._stem_mic_stream.stop()
|
stream = self._stem_mic_stream
|
||||||
self._stem_mic_stream.close()
|
|
||||||
self._stem_mic_stream = None
|
self._stem_mic_stream = None
|
||||||
|
self._close_stream(stream)
|
||||||
print("[Audio] Closed stem_mic (host stream takes over)")
|
print("[Audio] Closed stem_mic (host stream takes over)")
|
||||||
|
|
||||||
self._host_send_callback = send_callback
|
self._host_send_callback = send_callback
|
||||||
@@ -542,14 +654,22 @@ class AudioService:
|
|||||||
host_accum_samples = [0]
|
host_accum_samples = [0]
|
||||||
send_threshold = 1600 # 100ms at 16kHz
|
send_threshold = 1600 # 100ms at 16kHz
|
||||||
|
|
||||||
|
# Start mic monitor if monitor device is configured
|
||||||
|
self._start_monitor(device_sr)
|
||||||
|
|
||||||
def callback(indata, frames, time_info, status):
|
def callback(indata, frames, time_info, status):
|
||||||
# Capture for push-to-talk recording if active
|
# Capture for push-to-talk recording if active
|
||||||
if self._recording and self._recorded_audio is not None:
|
if self._recording and self._recorded_audio is not None:
|
||||||
self._recorded_audio.append(indata[:, record_channel].copy())
|
self._recorded_audio.append(indata[:, record_channel].copy())
|
||||||
|
|
||||||
# Stem recording: host mic
|
# Stem recording: host mic
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
|
if rec:
|
||||||
|
rec.write("host", indata[:, record_channel].copy(), device_sr)
|
||||||
|
|
||||||
|
# Mic monitor: send to headphone device
|
||||||
|
if self._monitor_write:
|
||||||
|
self._monitor_write(indata[:, record_channel].copy())
|
||||||
|
|
||||||
if not self._host_send_callback:
|
if not self._host_send_callback:
|
||||||
return
|
return
|
||||||
@@ -586,13 +706,89 @@ class AudioService:
|
|||||||
def stop_host_stream(self):
|
def stop_host_stream(self):
|
||||||
"""Stop host mic streaming and live caller output"""
|
"""Stop host mic streaming and live caller output"""
|
||||||
if self._host_stream:
|
if self._host_stream:
|
||||||
self._host_stream.stop()
|
stream = self._host_stream
|
||||||
self._host_stream.close()
|
|
||||||
self._host_stream = None
|
self._host_stream = None
|
||||||
self._host_send_callback = None
|
self._host_send_callback = None
|
||||||
|
self._close_stream(stream)
|
||||||
print("[Audio] Host mic streaming stopped")
|
print("[Audio] Host mic streaming stopped")
|
||||||
|
self._stop_monitor()
|
||||||
self._stop_live_caller_stream()
|
self._stop_live_caller_stream()
|
||||||
|
|
||||||
|
# --- Mic Monitor (input → headphone device) ---
|
||||||
|
|
||||||
|
def _start_monitor(self, input_sr: int):
|
||||||
|
"""Start mic monitor stream that routes input to monitor device"""
|
||||||
|
if self._monitor_stream is not None:
|
||||||
|
return
|
||||||
|
if self.monitor_device is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
device_info = sd.query_devices(self.monitor_device)
|
||||||
|
num_channels = device_info['max_output_channels']
|
||||||
|
device_sr = int(device_info['default_samplerate'])
|
||||||
|
channel_idx = min(self.monitor_channel, num_channels) - 1
|
||||||
|
|
||||||
|
# Ring buffer for cross-device routing
|
||||||
|
ring_size = int(device_sr * 2)
|
||||||
|
ring = np.zeros(ring_size, dtype=np.float32)
|
||||||
|
state = {"write_pos": 0, "read_pos": 0, "avail": 0}
|
||||||
|
|
||||||
|
# Precompute resample ratio (input device sr → monitor device sr)
|
||||||
|
resample_ratio = device_sr / input_sr
|
||||||
|
|
||||||
|
def write_audio(data):
|
||||||
|
# Resample if sample rates differ
|
||||||
|
if abs(resample_ratio - 1.0) > 0.01:
|
||||||
|
n_out = int(len(data) * resample_ratio)
|
||||||
|
indices = np.linspace(0, len(data) - 1, n_out).astype(int)
|
||||||
|
data = data[indices]
|
||||||
|
n = len(data)
|
||||||
|
wp = state["write_pos"]
|
||||||
|
if wp + n <= ring_size:
|
||||||
|
ring[wp:wp + n] = data
|
||||||
|
else:
|
||||||
|
first = ring_size - wp
|
||||||
|
ring[wp:] = data[:first]
|
||||||
|
ring[:n - first] = data[first:]
|
||||||
|
state["write_pos"] = (wp + n) % ring_size
|
||||||
|
state["avail"] += n
|
||||||
|
|
||||||
|
def callback(outdata, frames, time_info, status):
|
||||||
|
outdata.fill(0)
|
||||||
|
avail = state["avail"]
|
||||||
|
if avail < frames:
|
||||||
|
return
|
||||||
|
rp = state["read_pos"]
|
||||||
|
if rp + frames <= ring_size:
|
||||||
|
outdata[:frames, channel_idx] = ring[rp:rp + frames]
|
||||||
|
else:
|
||||||
|
first = ring_size - rp
|
||||||
|
outdata[:first, channel_idx] = ring[rp:]
|
||||||
|
outdata[first:frames, channel_idx] = ring[:frames - first]
|
||||||
|
state["read_pos"] = (rp + frames) % ring_size
|
||||||
|
state["avail"] -= frames
|
||||||
|
|
||||||
|
self._monitor_write = write_audio
|
||||||
|
self._monitor_stream = sd.OutputStream(
|
||||||
|
device=self.monitor_device,
|
||||||
|
samplerate=device_sr,
|
||||||
|
channels=num_channels,
|
||||||
|
dtype=np.float32,
|
||||||
|
blocksize=1024,
|
||||||
|
callback=callback,
|
||||||
|
)
|
||||||
|
self._monitor_stream.start()
|
||||||
|
print(f"[Audio] Mic monitor started (device {self.monitor_device} ch {self.monitor_channel} @ {device_sr}Hz)")
|
||||||
|
|
||||||
|
def _stop_monitor(self):
|
||||||
|
"""Stop mic monitor stream"""
|
||||||
|
if self._monitor_stream:
|
||||||
|
stream = self._monitor_stream
|
||||||
|
self._monitor_stream = None
|
||||||
|
self._monitor_write = None
|
||||||
|
self._close_stream(stream)
|
||||||
|
print("[Audio] Mic monitor stopped")
|
||||||
|
|
||||||
# --- Music Playback ---
|
# --- Music Playback ---
|
||||||
|
|
||||||
def load_music(self, file_path: str) -> bool:
|
def load_music(self, file_path: str) -> bool:
|
||||||
@@ -750,8 +946,9 @@ class AudioService:
|
|||||||
|
|
||||||
mono_out = (old_samples * fade_out + new_samples * fade_in) * self._music_volume
|
mono_out = (old_samples * fade_out + new_samples * fade_in) * self._music_volume
|
||||||
outdata[:, channel_idx] = mono_out
|
outdata[:, channel_idx] = mono_out
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic("music", mono_out.copy(), device_sr)
|
||||||
self._crossfade_progress = end_progress
|
self._crossfade_progress = end_progress
|
||||||
|
|
||||||
if self._crossfade_progress >= 1.0:
|
if self._crossfade_progress >= 1.0:
|
||||||
@@ -761,11 +958,12 @@ class AudioService:
|
|||||||
else:
|
else:
|
||||||
mono_out = new_samples * self._music_volume
|
mono_out = new_samples * self._music_volume
|
||||||
outdata[:, channel_idx] = mono_out
|
outdata[:, channel_idx] = mono_out
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic("music", mono_out.copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic("music", mono_out.copy(), device_sr)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._music_stream = sd.OutputStream(
|
self._music_stream = self._open_output_stream(
|
||||||
device=device,
|
device=device,
|
||||||
channels=num_channels,
|
channels=num_channels,
|
||||||
samplerate=device_sr,
|
samplerate=device_sr,
|
||||||
@@ -773,28 +971,68 @@ class AudioService:
|
|||||||
callback=callback,
|
callback=callback,
|
||||||
blocksize=2048
|
blocksize=2048
|
||||||
)
|
)
|
||||||
self._music_stream.start()
|
|
||||||
print(f"Music playback started on ch {self.music_channel} @ {device_sr}Hz")
|
print(f"Music playback started on ch {self.music_channel} @ {device_sr}Hz")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Music playback error: {e}")
|
print(f"Music playback error: {e}")
|
||||||
self._music_playing = False
|
self._music_playing = False
|
||||||
|
|
||||||
|
def _refresh_devices(self):
|
||||||
|
"""Re-initialize PortAudio to pick up device changes, then re-resolve settings."""
|
||||||
|
try:
|
||||||
|
sd._terminate()
|
||||||
|
sd._initialize()
|
||||||
|
print("[Audio] PortAudio re-initialized")
|
||||||
|
self._load_settings()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Audio] PortAudio refresh failed: {e}")
|
||||||
|
|
||||||
|
def _open_output_stream(self, **kwargs) -> sd.OutputStream:
|
||||||
|
"""Open an OutputStream with one retry after refreshing PortAudio on failure."""
|
||||||
|
try:
|
||||||
|
stream = sd.OutputStream(**kwargs)
|
||||||
|
stream.start()
|
||||||
|
return stream
|
||||||
|
except Exception as first_err:
|
||||||
|
print(f"[Audio] Stream open failed ({first_err}), refreshing devices...")
|
||||||
|
self._refresh_devices()
|
||||||
|
# Update device/channel info from refreshed settings
|
||||||
|
if kwargs.get("device") == self.output_device or "device" in kwargs:
|
||||||
|
device_info = sd.query_devices(self.output_device)
|
||||||
|
kwargs["device"] = self.output_device
|
||||||
|
kwargs["channels"] = device_info["max_output_channels"]
|
||||||
|
kwargs["samplerate"] = int(device_info["default_samplerate"])
|
||||||
|
stream = sd.OutputStream(**kwargs)
|
||||||
|
stream.start()
|
||||||
|
return stream
|
||||||
|
|
||||||
|
def _close_stream(self, stream):
|
||||||
|
"""Safely close a sounddevice stream, ignoring double-close errors"""
|
||||||
|
if stream is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
stream.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
stream.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def stop_music(self, fade_duration: float = 2.0):
|
def stop_music(self, fade_duration: float = 2.0):
|
||||||
"""Stop music playback with fade out"""
|
"""Stop music playback with fade out"""
|
||||||
if not self._music_playing or not self._music_stream:
|
if not self._music_playing or not self._music_stream:
|
||||||
self._music_playing = False
|
self._music_playing = False
|
||||||
if self._music_stream:
|
stream = self._music_stream
|
||||||
self._music_stream.stop()
|
self._music_stream = None
|
||||||
self._music_stream.close()
|
self._close_stream(stream)
|
||||||
self._music_stream = None
|
|
||||||
self._music_position = 0
|
self._music_position = 0
|
||||||
return
|
return
|
||||||
|
|
||||||
if fade_duration <= 0:
|
if fade_duration <= 0:
|
||||||
self._music_playing = False
|
self._music_playing = False
|
||||||
self._music_stream.stop()
|
stream = self._music_stream
|
||||||
self._music_stream.close()
|
|
||||||
self._music_stream = None
|
self._music_stream = None
|
||||||
|
self._close_stream(stream)
|
||||||
self._music_position = 0
|
self._music_position = 0
|
||||||
print("Music stopped")
|
print("Music stopped")
|
||||||
return
|
return
|
||||||
@@ -803,6 +1041,10 @@ class AudioService:
|
|||||||
original_volume = self._music_volume
|
original_volume = self._music_volume
|
||||||
steps = 20
|
steps = 20
|
||||||
step_time = fade_duration / steps
|
step_time = fade_duration / steps
|
||||||
|
# Capture stream reference locally so the fade thread closes THIS stream,
|
||||||
|
# not whatever self._music_stream points to later
|
||||||
|
fade_stream = self._music_stream
|
||||||
|
self._music_stream = None
|
||||||
|
|
||||||
def _fade():
|
def _fade():
|
||||||
for i in range(steps):
|
for i in range(steps):
|
||||||
@@ -812,10 +1054,7 @@ class AudioService:
|
|||||||
import time
|
import time
|
||||||
time.sleep(step_time)
|
time.sleep(step_time)
|
||||||
self._music_playing = False
|
self._music_playing = False
|
||||||
if self._music_stream:
|
self._close_stream(fade_stream)
|
||||||
self._music_stream.stop()
|
|
||||||
self._music_stream.close()
|
|
||||||
self._music_stream = None
|
|
||||||
self._music_position = 0
|
self._music_position = 0
|
||||||
self._music_volume = original_volume
|
self._music_volume = original_volume
|
||||||
print("Music faded out and stopped")
|
print("Music faded out and stopped")
|
||||||
@@ -832,6 +1071,7 @@ class AudioService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.stop_ad()
|
self.stop_ad()
|
||||||
|
self.stop_ident()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True)
|
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True)
|
||||||
@@ -842,6 +1082,7 @@ class AudioService:
|
|||||||
|
|
||||||
self._ad_playing = True
|
self._ad_playing = True
|
||||||
self._ad_position = 0
|
self._ad_position = 0
|
||||||
|
_write_reaper_state("ad")
|
||||||
|
|
||||||
if self.output_device is None:
|
if self.output_device is None:
|
||||||
num_channels = 2
|
num_channels = 2
|
||||||
@@ -871,17 +1112,19 @@ class AudioService:
|
|||||||
if remaining >= frames:
|
if remaining >= frames:
|
||||||
chunk = self._ad_resampled[self._ad_position:self._ad_position + frames]
|
chunk = self._ad_resampled[self._ad_position:self._ad_position + frames]
|
||||||
outdata[:, channel_idx] = chunk
|
outdata[:, channel_idx] = chunk
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic("ads", chunk.copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic("ads", chunk.copy(), device_sr)
|
||||||
self._ad_position += frames
|
self._ad_position += frames
|
||||||
else:
|
else:
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
outdata[:remaining, channel_idx] = self._ad_resampled[self._ad_position:]
|
outdata[:remaining, channel_idx] = self._ad_resampled[self._ad_position:]
|
||||||
# Ad finished — no loop
|
# Ad finished — no loop
|
||||||
self._ad_playing = False
|
self._ad_playing = False
|
||||||
|
_write_reaper_state("dialog")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._ad_stream = sd.OutputStream(
|
self._ad_stream = self._open_output_stream(
|
||||||
device=device,
|
device=device,
|
||||||
channels=num_channels,
|
channels=num_channels,
|
||||||
samplerate=device_sr,
|
samplerate=device_sr,
|
||||||
@@ -889,7 +1132,6 @@ class AudioService:
|
|||||||
callback=callback,
|
callback=callback,
|
||||||
blocksize=2048
|
blocksize=2048
|
||||||
)
|
)
|
||||||
self._ad_stream.start()
|
|
||||||
print(f"Ad playback started on ch {self.ad_channel} @ {device_sr}Hz")
|
print(f"Ad playback started on ch {self.ad_channel} @ {device_sr}Hz")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ad playback error: {e}")
|
print(f"Ad playback error: {e}")
|
||||||
@@ -897,13 +1139,122 @@ class AudioService:
|
|||||||
|
|
||||||
def stop_ad(self):
|
def stop_ad(self):
|
||||||
"""Stop ad playback"""
|
"""Stop ad playback"""
|
||||||
|
was_playing = self._ad_playing
|
||||||
self._ad_playing = False
|
self._ad_playing = False
|
||||||
|
if was_playing:
|
||||||
|
_write_reaper_state("dialog")
|
||||||
if self._ad_stream:
|
if self._ad_stream:
|
||||||
self._ad_stream.stop()
|
stream = self._ad_stream
|
||||||
self._ad_stream.close()
|
|
||||||
self._ad_stream = None
|
self._ad_stream = None
|
||||||
|
self._close_stream(stream)
|
||||||
self._ad_position = 0
|
self._ad_position = 0
|
||||||
|
|
||||||
|
def play_ident(self, file_path: str):
|
||||||
|
"""Load and play an ident file once (no loop) in stereo on ident_channel/ident_channel+1"""
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
print(f"Ident file not found: {file_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stop_ident()
|
||||||
|
self.stop_ad()
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=False)
|
||||||
|
if audio.ndim == 1:
|
||||||
|
# Mono file — duplicate to stereo
|
||||||
|
audio = np.stack([audio, audio])
|
||||||
|
audio = audio.astype(np.float32) # shape: (2, samples)
|
||||||
|
self._ident_data = audio
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load ident: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._ident_playing = True
|
||||||
|
self._ident_position = 0
|
||||||
|
_write_reaper_state("ident")
|
||||||
|
print(f"Ident loaded: shape={self._ident_data.shape}, max={np.max(np.abs(self._ident_data)):.4f}")
|
||||||
|
|
||||||
|
if self.output_device is None:
|
||||||
|
num_channels = 2
|
||||||
|
device = None
|
||||||
|
device_sr = self.output_sample_rate
|
||||||
|
ch_l = 0
|
||||||
|
ch_r = 1
|
||||||
|
else:
|
||||||
|
device_info = sd.query_devices(self.output_device)
|
||||||
|
num_channels = device_info['max_output_channels']
|
||||||
|
device_sr = int(device_info['default_samplerate'])
|
||||||
|
device = self.output_device
|
||||||
|
ch_l = min(self.ident_channel, num_channels) - 1
|
||||||
|
ch_r = min(self.ident_channel + 1, num_channels) - 1
|
||||||
|
|
||||||
|
if self.output_sample_rate != device_sr:
|
||||||
|
self._ident_resampled = np.stack([
|
||||||
|
librosa.resample(self._ident_data[0], orig_sr=self.output_sample_rate, target_sr=device_sr),
|
||||||
|
librosa.resample(self._ident_data[1], orig_sr=self.output_sample_rate, target_sr=device_sr),
|
||||||
|
]).astype(np.float32)
|
||||||
|
else:
|
||||||
|
self._ident_resampled = self._ident_data
|
||||||
|
|
||||||
|
_cb_count = [0]
|
||||||
|
def callback(outdata, frames, time_info, status):
|
||||||
|
outdata[:] = 0
|
||||||
|
if not self._ident_playing or self._ident_resampled is None:
|
||||||
|
if _cb_count[0] == 0:
|
||||||
|
print(f"Ident callback: not playing (playing={self._ident_playing}, data={'yes' if self._ident_resampled is not None else 'no'})")
|
||||||
|
return
|
||||||
|
|
||||||
|
n_samples = self._ident_resampled.shape[1]
|
||||||
|
remaining = n_samples - self._ident_position
|
||||||
|
if remaining >= frames:
|
||||||
|
chunk_l = self._ident_resampled[0, self._ident_position:self._ident_position + frames]
|
||||||
|
chunk_r = self._ident_resampled[1, self._ident_position:self._ident_position + frames]
|
||||||
|
outdata[:, ch_l] = chunk_l
|
||||||
|
outdata[:, ch_r] = chunk_r
|
||||||
|
_cb_count[0] += 1
|
||||||
|
if _cb_count[0] == 1:
|
||||||
|
print(f"Ident callback delivering audio: ch_l={ch_l}, ch_r={ch_r}, max={max(np.max(np.abs(chunk_l)), np.max(np.abs(chunk_r))):.4f}")
|
||||||
|
rec = self.stem_recorder
|
||||||
|
if rec:
|
||||||
|
mono_mix = (chunk_l + chunk_r) * 0.5
|
||||||
|
rec.write_sporadic("idents", mono_mix.copy(), device_sr)
|
||||||
|
self._ident_position += frames
|
||||||
|
else:
|
||||||
|
if remaining > 0:
|
||||||
|
outdata[:remaining, ch_l] = self._ident_resampled[0, self._ident_position:]
|
||||||
|
outdata[:remaining, ch_r] = self._ident_resampled[1, self._ident_position:]
|
||||||
|
self._ident_playing = False
|
||||||
|
_write_reaper_state("dialog")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ident_stream = self._open_output_stream(
|
||||||
|
device=device,
|
||||||
|
channels=num_channels,
|
||||||
|
samplerate=device_sr,
|
||||||
|
dtype=np.float32,
|
||||||
|
callback=callback,
|
||||||
|
blocksize=2048
|
||||||
|
)
|
||||||
|
print(f"Ident playback started on ch {ch_l+1}/{ch_r+1} (idx {ch_l}/{ch_r}) of {num_channels} channels @ {device_sr}Hz, device={device}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ident playback error: {e}")
|
||||||
|
self._ident_playing = False
|
||||||
|
|
||||||
|
def stop_ident(self):
|
||||||
|
"""Stop ident playback"""
|
||||||
|
was_playing = self._ident_playing
|
||||||
|
self._ident_playing = False
|
||||||
|
if was_playing:
|
||||||
|
_write_reaper_state("dialog")
|
||||||
|
if self._ident_stream:
|
||||||
|
stream = self._ident_stream
|
||||||
|
self._ident_stream = None
|
||||||
|
self._close_stream(stream)
|
||||||
|
self._ident_position = 0
|
||||||
|
|
||||||
def set_music_volume(self, volume: float):
|
def set_music_volume(self, volume: float):
|
||||||
"""Set music volume (0.0 to 1.0)"""
|
"""Set music volume (0.0 to 1.0)"""
|
||||||
self._music_volume = max(0.0, min(1.0, volume))
|
self._music_volume = max(0.0, min(1.0, volume))
|
||||||
@@ -926,6 +1277,7 @@ class AudioService:
|
|||||||
|
|
||||||
if self.output_device is None:
|
if self.output_device is None:
|
||||||
audio, sr = librosa.load(str(path), sr=None, mono=True)
|
audio, sr = librosa.load(str(path), sr=None, mono=True)
|
||||||
|
audio = audio.astype(np.float32)
|
||||||
audio = self._apply_fade(audio, sr)
|
audio = self._apply_fade(audio, sr)
|
||||||
def play():
|
def play():
|
||||||
# Use a dedicated stream instead of sd.play()
|
# Use a dedicated stream instead of sd.play()
|
||||||
@@ -938,11 +1290,13 @@ class AudioService:
|
|||||||
channel_idx = min(self.sfx_channel, num_channels) - 1
|
channel_idx = min(self.sfx_channel, num_channels) - 1
|
||||||
|
|
||||||
audio, _ = librosa.load(str(path), sr=device_sr, mono=True)
|
audio, _ = librosa.load(str(path), sr=device_sr, mono=True)
|
||||||
|
audio = audio.astype(np.float32)
|
||||||
audio = self._apply_fade(audio, device_sr)
|
audio = self._apply_fade(audio, device_sr)
|
||||||
|
|
||||||
# Stem recording: sfx
|
# Stem recording: sfx
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write_sporadic("sfx", audio.copy(), device_sr)
|
if rec:
|
||||||
|
rec.write_sporadic("sfx", audio.copy(), device_sr)
|
||||||
|
|
||||||
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
|
multi_ch = np.zeros((len(audio), num_channels), dtype=np.float32)
|
||||||
multi_ch[:, channel_idx] = audio
|
multi_ch[:, channel_idx] = audio
|
||||||
@@ -981,28 +1335,46 @@ class AudioService:
|
|||||||
device_sr = int(device_info['default_samplerate'])
|
device_sr = int(device_info['default_samplerate'])
|
||||||
record_channel = min(self.input_channel, max_channels) - 1
|
record_channel = min(self.input_channel, max_channels) - 1
|
||||||
|
|
||||||
|
self._start_monitor(device_sr)
|
||||||
|
|
||||||
def callback(indata, frames, time_info, status):
|
def callback(indata, frames, time_info, status):
|
||||||
if self.stem_recorder:
|
rec = self.stem_recorder
|
||||||
self.stem_recorder.write("host", indata[:, record_channel].copy(), device_sr)
|
if rec:
|
||||||
|
rec.write("host", indata[:, record_channel].copy(), device_sr)
|
||||||
|
if self._monitor_write:
|
||||||
|
self._monitor_write(indata[:, record_channel].copy())
|
||||||
|
|
||||||
|
def _open():
|
||||||
|
return sd.InputStream(
|
||||||
|
device=self.input_device,
|
||||||
|
channels=max_channels,
|
||||||
|
samplerate=device_sr,
|
||||||
|
dtype=np.float32,
|
||||||
|
blocksize=1024,
|
||||||
|
callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._stem_mic_stream = _open()
|
||||||
|
except Exception as first_err:
|
||||||
|
print(f"[StemRecorder] InputStream open failed ({first_err}), refreshing PortAudio and retrying...")
|
||||||
|
self._refresh_devices()
|
||||||
|
device_info = sd.query_devices(self.input_device)
|
||||||
|
max_channels = device_info['max_input_channels']
|
||||||
|
device_sr = int(device_info['default_samplerate'])
|
||||||
|
self._stem_mic_stream = _open()
|
||||||
|
|
||||||
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()
|
self._stem_mic_stream.start()
|
||||||
print(f"[StemRecorder] Host mic capture started (device {self.input_device} ch {self.input_channel} @ {device_sr}Hz)")
|
print(f"[StemRecorder] Host mic capture started (device {self.input_device} ch {self.input_channel} @ {device_sr}Hz)")
|
||||||
|
|
||||||
def stop_stem_mic(self):
|
def stop_stem_mic(self):
|
||||||
"""Stop the persistent stem mic capture."""
|
"""Stop the persistent stem mic capture."""
|
||||||
if self._stem_mic_stream:
|
if self._stem_mic_stream:
|
||||||
self._stem_mic_stream.stop()
|
stream = self._stem_mic_stream
|
||||||
self._stem_mic_stream.close()
|
|
||||||
self._stem_mic_stream = None
|
self._stem_mic_stream = None
|
||||||
|
self._close_stream(stream)
|
||||||
print("[StemRecorder] Host mic capture stopped")
|
print("[StemRecorder] Host mic capture stopped")
|
||||||
|
self._stop_monitor()
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Avatar service — fetches deterministic face photos from randomuser.me"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
AVATAR_DIR = Path(__file__).parent.parent.parent / "data" / "avatars"
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarService:
|
||||||
|
def __init__(self):
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None or self._client.is_closed:
|
||||||
|
self._client = httpx.AsyncClient(timeout=10.0)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def get_path(self, name: str) -> Path | None:
|
||||||
|
path = AVATAR_DIR / f"{name}.jpg"
|
||||||
|
return path if path.exists() else None
|
||||||
|
|
||||||
|
async def get_or_fetch(self, name: str, gender: str = "male") -> Path:
|
||||||
|
"""Get cached avatar or fetch from randomuser.me. Returns file path."""
|
||||||
|
g = "female" if gender.lower().startswith("f") else "male"
|
||||||
|
path = AVATAR_DIR / f"{name}.jpg"
|
||||||
|
# Check for gender mismatch marker — re-fetch if gender changed
|
||||||
|
marker = AVATAR_DIR / f"{name}.gender"
|
||||||
|
if path.exists():
|
||||||
|
cached_gender = marker.read_text().strip() if marker.exists() else None
|
||||||
|
if cached_gender == g:
|
||||||
|
return path
|
||||||
|
# Gender mismatch or no marker — re-fetch
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
seed = f"{name.lower().replace(' ', '_')}_{g}"
|
||||||
|
resp = await self.client.get(
|
||||||
|
"https://randomuser.me/api/",
|
||||||
|
params={"gender": g, "seed": seed},
|
||||||
|
timeout=8.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
photo_url = data["results"][0]["picture"]["large"]
|
||||||
|
|
||||||
|
photo_resp = await self.client.get(photo_url, timeout=8.0)
|
||||||
|
photo_resp.raise_for_status()
|
||||||
|
|
||||||
|
path.write_bytes(photo_resp.content)
|
||||||
|
marker.write_text(g)
|
||||||
|
print(f"[Avatar] Fetched avatar for {name} ({g})")
|
||||||
|
return path
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Avatar] Failed to fetch for {name}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def prefetch_batch(self, callers: list[dict]):
|
||||||
|
"""Fetch avatars for multiple callers in parallel.
|
||||||
|
Each dict should have 'name' and 'gender' keys."""
|
||||||
|
tasks = []
|
||||||
|
for caller in callers:
|
||||||
|
name = caller.get("name", "")
|
||||||
|
gender = caller.get("gender", "male")
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
g = "female" if gender.lower().startswith("f") else "male"
|
||||||
|
path = AVATAR_DIR / f"{name}.jpg"
|
||||||
|
marker = AVATAR_DIR / f"{name}.gender"
|
||||||
|
# Always call get_or_fetch if: no file, no gender marker, or gender mismatch
|
||||||
|
if not path.exists() or not marker.exists() or marker.read_text().strip() != g:
|
||||||
|
if path.exists():
|
||||||
|
print(f"[Avatar] Gender mismatch for {name}: cached={marker.read_text().strip() if marker.exists() else '?'}, want={g} — re-fetching")
|
||||||
|
tasks.append(self.get_or_fetch(name, gender))
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
return
|
||||||
|
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
fetched = sum(1 for r in results if not isinstance(r, Exception))
|
||||||
|
failed = sum(1 for r in results if isinstance(r, Exception))
|
||||||
|
if fetched:
|
||||||
|
print(f"[Avatar] Pre-fetched {fetched} avatars{f', {failed} failed' if failed else ''}")
|
||||||
|
|
||||||
|
async def ensure_devon(self):
|
||||||
|
"""Pre-fetch Devon's avatar on startup."""
|
||||||
|
try:
|
||||||
|
await self.get_or_fetch("Devon", "male")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
avatar_service = AvatarService()
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from .cost_tracker import cost_tracker
|
||||||
|
|
||||||
|
BATCH_MODEL = "anthropic/claude-sonnet-4.6"
|
||||||
|
|
||||||
|
REQUIRED_FIELDS = {
|
||||||
|
"name", "age", "voice_suggestion", "location", "identity",
|
||||||
|
"situation", "reason_calling", "opening_line", "secret_want",
|
||||||
|
"specific_details", "emotional_register"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CallerIdentity:
|
||||||
|
name: str
|
||||||
|
age: int
|
||||||
|
voice_suggestion: str
|
||||||
|
location: str
|
||||||
|
identity: str
|
||||||
|
situation: str
|
||||||
|
reason_calling: str
|
||||||
|
opening_line: str
|
||||||
|
secret_want: str
|
||||||
|
specific_details: list[str]
|
||||||
|
emotional_register: str
|
||||||
|
# set after voice validation
|
||||||
|
voice_resolved: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_batch_response(raw: str) -> list[CallerIdentity]:
|
||||||
|
stripped = raw.strip()
|
||||||
|
if stripped.startswith("```") and stripped.endswith("```"):
|
||||||
|
lines = stripped.splitlines()
|
||||||
|
stripped = "\n".join(lines[1:-1])
|
||||||
|
data = json.loads(stripped)
|
||||||
|
callers = data.get("callers", [])
|
||||||
|
result = []
|
||||||
|
for c in callers:
|
||||||
|
missing = REQUIRED_FIELDS - set(c.keys())
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"CallerIdentity missing fields: {missing}")
|
||||||
|
result.append(CallerIdentity(**{k: c[k] for k in REQUIRED_FIELDS}))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_voice(suggestion: str, roster: list[str]) -> str:
|
||||||
|
"""Map sonnet's voice suggestion to a real voice in the roster.
|
||||||
|
Case-insensitive exact match; random fallback if unmatched (so LLM
|
||||||
|
hallucinations don't all collapse onto roster[0])."""
|
||||||
|
if not roster:
|
||||||
|
return ""
|
||||||
|
if suggestion:
|
||||||
|
match = {v.lower(): v for v in roster}.get(suggestion.lower())
|
||||||
|
if match:
|
||||||
|
return match
|
||||||
|
fallback = random.choice(roster)
|
||||||
|
print(f"[caller_gen] voice '{suggestion}' not in roster — random fallback to '{fallback}'")
|
||||||
|
return fallback
|
||||||
|
return random.choice(roster)
|
||||||
|
|
||||||
|
|
||||||
|
BATCH_SYSTEM_PROMPT = """You are writing a roster of callers for Luke's late-night radio show, broadcast out of Alpine, Texas, in the Big Bend country of far West Texas.
|
||||||
|
|
||||||
|
WHERE THESE CALLERS LIVE: The show's world is the Big Bend region of far West Texas — high desert, remote, dark skies, ranching country up against the Mexico border. Most callers are from or know this area. Ground them in REAL places and facts only — never invent businesses or landmarks that don't exist. The towns:
|
||||||
|
- Alpine — the hub of the region (about 6,000 people), Brewster County seat, home of Sul Ross State University, a mile-high old ranching and railroad town with a small arts scene. The show broadcasts from here.
|
||||||
|
- Marfa — minimalist-art tourist town (the Chinati Foundation / Donald Judd, the Prada Marfa installation), famous for the unexplained Marfa Lights. Old ranching families chafing against an influx of artists and out-of-towners.
|
||||||
|
- Marathon — tiny, known for the historic Gage Hotel, the eastern gateway to Big Bend National Park.
|
||||||
|
- Terlingua — a quicksilver-mining ghost town turned off-grid haven for desert eccentrics; famous for its chili cookoff, river-rafting outfitters on the Rio Grande, and the Starlight Theatre. Right up against the Mexico border (Boquillas crossing).
|
||||||
|
- Fort Stockton — an oilfield and I-10 town to the north in Pecos County (Paisano Pete the roadrunner), more working-class and blue-collar than the artsy towns.
|
||||||
|
- The Big Bend itself — the Chisos Mountains, the Rio Grande, some of the darkest night skies in the country (the McDonald Observatory is near Fort Davis), brutally remote, with Permian Basin oil money booming to the north.
|
||||||
|
Callers can reference real ranches, the heat and wind, the drive times (everything is hours apart), border life, Sul Ross students, oilfield work, tourists, and the desert weirdos — but keep it real and specific to this place.
|
||||||
|
|
||||||
|
CREATIVE RANGE: Your callers must span the emotional range of Howard Stern (chaos, strong characters), Coast to Coast AM (earnest weirdos, sincere believers), Loveline (real problems, real advice-seeking), Delilah (emotional vulnerability, connection), and Opie and Anthony (sharp, irreverent, specific people).
|
||||||
|
|
||||||
|
ROSTER MIX — VARY THE CALL TYPES. A great show is not ten people in moral crisis. Build the roster of 10 callers roughly like this:
|
||||||
|
- 4-5 DILEMMA CALLS (the dramatic spine): real human conflict with STAKES — moral dilemmas, confessions, betrayals, impossible choices, or something the caller did and can't take back. Think: "I found out my dad has a second family," "I got someone fired and they deserved it but now their kid is sick," "my best friend's wife hit on me and I didn't say no." These need genuine emotional weight.
|
||||||
|
- 2-3 STORY / ENTHUSIAST CALLS (the relief): callers with a wild thing that happened to them, a fascinating obsession or piece of knowledge, a strange-but-true story, or a vivid slice of life. NO deep moral dilemma required — they call because the story is great, the fact is amazing, or they're bursting to talk about the thing they love. Still specific, still a reason they called TONIGHT, but the energy is delight or wonder or a great yarn, not anguish.
|
||||||
|
- 1-2 TRUE BELIEVER / CHAOS CALLS (the spice): an earnest, sincere weirdo — UFOs, cryptids, a government conspiracy, a pattern only they can see, a paranormal experience (Coast to Coast AM energy, dead serious about it) — OR a big eccentric personality on a tear about something trivial. Played straight, never winking.
|
||||||
|
|
||||||
|
Every caller still needs SOMETHING that makes the audience lean in — a problem, a secret, a story, a wild belief, or an irresistible enthusiasm. But not every caller carries grief. Let the show breathe.
|
||||||
|
|
||||||
|
Maximum character distance between callers. No two callers should feel like siblings.
|
||||||
|
|
||||||
|
ANTI-COLLISION RULE — THIS IS NON-NEGOTIABLE: All callers in this roster must be clearly differentiated. No two callers may share the same hobby, obsession, profession archetype, or story theme. Specifically forbidden within a single roster: two BBQ competitors, two taxidermists, two amateur-radio or mystery-signal callers, two callers with ex-spouse drama, two believers chasing the SAME phenomenon (e.g. two UFO callers, or two cryptid callers — if you have two believer calls they must be about completely different things), two retired military, two grandmothers-of-many, two callers calling about a weird neighbor, two callers with religious-object stories. Each caller's defining "thing" — their hook, their obsession, the specific topic they're calling about — must appear exactly once in the roster. Before finalizing, scan your output and swap any collisions.
|
||||||
|
|
||||||
|
Do not default to sitcom plots. Real humans are specific and strange. Give each caller details that could only belong to them.
|
||||||
|
|
||||||
|
OPENING LINE RULES: Each caller's opening_line must be unique and specific to their situation. NEVER write "I've been listening for X years" or "long-time listener, first-time caller" or any variant. The caller should jump into their story or problem immediately — nervous, excited, angry, whatever fits. The opening line is the hook that makes the audience lean in.
|
||||||
|
|
||||||
|
You will output strict JSON with a "callers" array. Each caller has exactly these fields: name, age, voice_suggestion, location, identity, situation, reason_calling, opening_line, secret_want, specific_details (array of 2-3 strings), emotional_register."""
|
||||||
|
|
||||||
|
|
||||||
|
def build_batch_prompt(ctx: dict) -> str:
|
||||||
|
lines = [
|
||||||
|
f"Tonight is {ctx['date']}. {ctx['weather']}.",
|
||||||
|
"",
|
||||||
|
"Today's news headlines (ground callers in real context, but do not force topicality):",
|
||||||
|
]
|
||||||
|
for h in ctx["headlines"]:
|
||||||
|
lines.append(f"- {h}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
theme = (ctx.get("theme") or "").strip()
|
||||||
|
if theme:
|
||||||
|
lines.append(f'TONIGHT\'S SHOW THEME: "{theme}"')
|
||||||
|
lines.append(
|
||||||
|
f'Roughly 2/3 of callers MUST be calling BECAUSE OF this theme — the theme '
|
||||||
|
f'should be woven directly into their reason_calling and situation, not '
|
||||||
|
f'just acknowledged. Make the connection specific and personal (a story, a '
|
||||||
|
f'conflict, a moment) not abstract. The remaining 1/3 of the roster can be '
|
||||||
|
f'unrelated walk-ins for variety. Do NOT make every caller theme-connected — '
|
||||||
|
f'variety still matters.'
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if ctx["recent_caller_summaries"]:
|
||||||
|
lines.append("Recent callers (DO NOT repeat these archetypes or situations):")
|
||||||
|
for s in ctx["recent_caller_summaries"]:
|
||||||
|
lines.append(f"- {s}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if ctx["regulars_included"]:
|
||||||
|
lines.append("RECURRING CHARACTERS IN TONIGHT'S LINEUP:")
|
||||||
|
lines.append("")
|
||||||
|
for r in ctx["regulars_included"]:
|
||||||
|
lines.append(f"### {r['name']}")
|
||||||
|
lines.append(r["lore"])
|
||||||
|
lines.append(f"Current arc state: {r['arc_state']}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"For {r['name']}: invent a fresh reason he is calling tonight — a new development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits. Write a new scene for an existing character.")
|
||||||
|
lines.append(f"CRITICAL — this caller MUST appear in the output JSON with EXACTLY these fields locked: name=\"{r['name']}\", voice_suggestion=\"{r['voice']}\", age={r['age']}. Do NOT rename this caller. Do NOT give him a different voice. Do NOT change his age. Only invent: location, identity, situation, reason_calling, opening_line, secret_want, specific_details, emotional_register.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append(f"Available voices (voice_suggestion must match one of these exactly):")
|
||||||
|
lines.append(", ".join(ctx["voice_roster"]))
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Generate {ctx['caller_count']} callers. Output JSON only, no prose.")
|
||||||
|
return BATCH_SYSTEM_PROMPT + "\n\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_batch(ctx: dict) -> list[CallerIdentity]:
|
||||||
|
"""Call sonnet-4.6 with the batch prompt, parse + voice-resolve the response."""
|
||||||
|
prompt = build_batch_prompt(ctx)
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
|
||||||
|
json={
|
||||||
|
"model": BATCH_MODEL,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"max_tokens": 16000,
|
||||||
|
"temperature": 0.9,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
cost_tracker.record_llm_call(
|
||||||
|
category="background_gen",
|
||||||
|
model=BATCH_MODEL,
|
||||||
|
usage_data=usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
callers = parse_batch_response(content)
|
||||||
|
for c in callers:
|
||||||
|
c.voice_resolved = resolve_voice(c.voice_suggestion, ctx["voice_roster"])
|
||||||
|
return callers
|
||||||
|
|
||||||
|
|
||||||
|
REGULAR_SYSTEM_PROMPT = """You are writing a single caller — a recurring character on Luke's late-night radio show. The character's identity is fixed (name, voice, age, core lore). You only invent a fresh reason he's calling tonight, grounded in his current arc state.
|
||||||
|
|
||||||
|
Output strict JSON with these fields only: location, identity, situation, reason_calling, opening_line, secret_want, specific_details (array of 2-3 strings), emotional_register. Do NOT include name, voice_suggestion, or age — those are locked."""
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_regular_situation(regular: dict, ctx: dict) -> dict:
|
||||||
|
"""Generate a fresh situation for a locked regular. Returns a dict matching
|
||||||
|
the CallerIdentity schema. One small LLM call (~$0.01)."""
|
||||||
|
lines = [
|
||||||
|
f"Tonight is {ctx['date']}. {ctx['weather']}.",
|
||||||
|
"",
|
||||||
|
f"### {regular['name']}",
|
||||||
|
regular["lore"],
|
||||||
|
f"Current arc state: {regular['arc_state']}",
|
||||||
|
"",
|
||||||
|
f"Invent a fresh reason {regular['name']} is calling tonight — a new development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits.",
|
||||||
|
"",
|
||||||
|
"Output JSON only, no prose.",
|
||||||
|
]
|
||||||
|
prompt = REGULAR_SYSTEM_PROMPT + "\n\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
|
||||||
|
json={
|
||||||
|
"model": BATCH_MODEL,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"max_tokens": 1500,
|
||||||
|
"temperature": 0.9,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
cost_tracker.record_llm_call(
|
||||||
|
category="background_gen",
|
||||||
|
model=BATCH_MODEL,
|
||||||
|
usage_data=usage,
|
||||||
|
)
|
||||||
|
|
||||||
|
stripped = content.strip()
|
||||||
|
if stripped.startswith("```") and stripped.endswith("```"):
|
||||||
|
lines = stripped.splitlines()
|
||||||
|
stripped = "\n".join(lines[1:-1])
|
||||||
|
return json.loads(stripped)
|
||||||
@@ -0,0 +1,555 @@
|
|||||||
|
"""SQLite database for cross-session cost analytics"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
DB_PATH = PROJECT_ROOT / "data" / "costs.db"
|
||||||
|
REPORTS_DIR = PROJECT_ROOT / "data" / "cost_reports"
|
||||||
|
|
||||||
|
_conn: sqlite3.Connection | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
total_cost REAL DEFAULT 0,
|
||||||
|
llm_cost REAL DEFAULT 0,
|
||||||
|
tts_cost REAL DEFAULT 0,
|
||||||
|
total_llm_calls INTEGER DEFAULT 0,
|
||||||
|
total_tts_calls INTEGER DEFAULT 0,
|
||||||
|
total_tokens INTEGER DEFAULT 0,
|
||||||
|
prompt_tokens INTEGER DEFAULT 0,
|
||||||
|
completion_tokens INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||||
|
timestamp TIMESTAMP,
|
||||||
|
category TEXT,
|
||||||
|
model TEXT,
|
||||||
|
prompt_tokens INTEGER DEFAULT 0,
|
||||||
|
completion_tokens INTEGER DEFAULT 0,
|
||||||
|
cost REAL DEFAULT 0,
|
||||||
|
caller_name TEXT,
|
||||||
|
latency_ms REAL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tts_calls (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||||
|
timestamp TIMESTAMP,
|
||||||
|
provider TEXT,
|
||||||
|
voice TEXT,
|
||||||
|
char_count INTEGER DEFAULT 0,
|
||||||
|
cost REAL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_session_ts_cat_model
|
||||||
|
ON llm_calls(session_id, timestamp, category, model);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tts_session_ts
|
||||||
|
ON tts_calls(session_id, timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_timestamp
|
||||||
|
ON llm_calls(timestamp);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> sqlite3.Connection:
|
||||||
|
global _conn
|
||||||
|
if _conn is None:
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_conn = init_db(DB_PATH)
|
||||||
|
import_json_reports(_conn)
|
||||||
|
return _conn
|
||||||
|
|
||||||
|
|
||||||
|
def import_json_reports(conn: sqlite3.Connection | None = None):
|
||||||
|
if conn is None:
|
||||||
|
conn = get_db()
|
||||||
|
if not REPORTS_DIR.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
existing = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute("SELECT id FROM sessions").fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
for fp in sorted(REPORTS_DIR.glob("session-*.json")):
|
||||||
|
try:
|
||||||
|
data = json.loads(fp.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
session_id = data.get("session_id", fp.stem)
|
||||||
|
if session_id in existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
saved_at = data.get("saved_at", 0)
|
||||||
|
started_at = datetime.fromtimestamp(saved_at, tz=timezone.utc).isoformat() if saved_at else None
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (id, started_at, total_cost, llm_cost, tts_cost, "
|
||||||
|
"total_llm_calls, total_tts_calls, total_tokens, prompt_tokens, completion_tokens) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
session_id,
|
||||||
|
started_at,
|
||||||
|
data.get("total_cost_usd", 0),
|
||||||
|
data.get("llm_cost_usd", 0),
|
||||||
|
data.get("tts_cost_usd", 0),
|
||||||
|
data.get("total_llm_calls", 0),
|
||||||
|
len(data.get("raw_tts_records", [])),
|
||||||
|
data.get("total_tokens", 0),
|
||||||
|
data.get("prompt_tokens", 0),
|
||||||
|
data.get("completion_tokens", 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for r in data.get("raw_llm_records", []):
|
||||||
|
try:
|
||||||
|
ts = datetime.fromtimestamp(r.get("timestamp", 0), tz=timezone.utc).isoformat()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO llm_calls (session_id, timestamp, category, model, "
|
||||||
|
"prompt_tokens, completion_tokens, cost, caller_name, latency_ms) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
session_id, ts, r.get("category"), r.get("model"),
|
||||||
|
r.get("prompt_tokens", 0), r.get("completion_tokens", 0),
|
||||||
|
r.get("cost_usd", 0), r.get("caller_name", ""),
|
||||||
|
r.get("latency_ms", 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for r in data.get("raw_tts_records", []):
|
||||||
|
try:
|
||||||
|
ts = datetime.fromtimestamp(r.get("timestamp", 0), tz=timezone.utc).isoformat()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, "
|
||||||
|
"char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
session_id, ts, r.get("provider"), r.get("voice"),
|
||||||
|
r.get("char_count", 0), r.get("cost_usd", 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing.add(session_id)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def record_llm_call(session_id, timestamp, category, model, prompt_tokens,
|
||||||
|
completion_tokens, cost, caller_name="", latency_ms=0.0):
|
||||||
|
conn = get_db()
|
||||||
|
ensure_session(session_id)
|
||||||
|
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() if isinstance(timestamp, (int, float)) else timestamp
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO llm_calls (session_id, timestamp, category, model, "
|
||||||
|
"prompt_tokens, completion_tokens, cost, caller_name, latency_ms) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, ts, category, model, prompt_tokens, completion_tokens,
|
||||||
|
cost, caller_name, latency_ms),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def record_tts_call(session_id, timestamp, provider, voice, char_count, cost):
|
||||||
|
conn = get_db()
|
||||||
|
ensure_session(session_id)
|
||||||
|
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() if isinstance(timestamp, (int, float)) else timestamp
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, "
|
||||||
|
"char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, ts, provider, voice, char_count, cost),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_session(session_id, started_at=None):
|
||||||
|
conn = get_db()
|
||||||
|
existing = conn.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
ts = started_at
|
||||||
|
if isinstance(started_at, (int, float)):
|
||||||
|
ts = datetime.fromtimestamp(started_at, tz=timezone.utc).isoformat()
|
||||||
|
elif started_at is None:
|
||||||
|
ts = datetime.now(timezone.utc).isoformat()
|
||||||
|
conn.execute("INSERT INTO sessions (id, started_at) VALUES (?, ?)", (session_id, ts))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_session_totals(session_id):
|
||||||
|
conn = get_db()
|
||||||
|
llm = conn.execute(
|
||||||
|
"SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as cost, "
|
||||||
|
"COALESCE(SUM(prompt_tokens),0) as pt, COALESCE(SUM(completion_tokens),0) as ct "
|
||||||
|
"FROM llm_calls WHERE session_id = ?", (session_id,)
|
||||||
|
).fetchone()
|
||||||
|
tts = conn.execute(
|
||||||
|
"SELECT COUNT(*) as cnt, COALESCE(SUM(cost),0) as cost "
|
||||||
|
"FROM tts_calls WHERE session_id = ?", (session_id,)
|
||||||
|
).fetchone()
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET total_cost=?, llm_cost=?, tts_cost=?, "
|
||||||
|
"total_llm_calls=?, total_tts_calls=?, total_tokens=?, "
|
||||||
|
"prompt_tokens=?, completion_tokens=? WHERE id=?",
|
||||||
|
(
|
||||||
|
llm["cost"] + tts["cost"], llm["cost"], tts["cost"],
|
||||||
|
llm["cnt"], tts["cnt"],
|
||||||
|
llm["pt"] + llm["ct"], llm["pt"], llm["ct"],
|
||||||
|
session_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _period_filter(period: str) -> str | None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if period == "today":
|
||||||
|
return now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
elif period == "week":
|
||||||
|
return (now - timedelta(days=7)).isoformat()
|
||||||
|
elif period == "month":
|
||||||
|
return (now - timedelta(days=30)).isoformat()
|
||||||
|
elif period == "all":
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_previous_period_start(period: str) -> str | None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if period == "today":
|
||||||
|
yesterday = now - timedelta(days=1)
|
||||||
|
return yesterday.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
elif period == "week":
|
||||||
|
return (now - timedelta(days=14)).isoformat()
|
||||||
|
elif period == "month":
|
||||||
|
return (now - timedelta(days=60)).isoformat()
|
||||||
|
elif period == "all":
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _where_clause(period: str, ts_col: str = "started_at") -> tuple[str, list]:
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start is None:
|
||||||
|
return "", []
|
||||||
|
return f"WHERE {ts_col} >= ?", [start]
|
||||||
|
|
||||||
|
|
||||||
|
def _pct_change(current: float, previous: float) -> float | None:
|
||||||
|
if previous == 0:
|
||||||
|
return None
|
||||||
|
return round((current - previous) / previous * 100, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_summary(period: str = "all") -> dict:
|
||||||
|
conn = get_db()
|
||||||
|
where, params = _where_clause(period)
|
||||||
|
row = conn.execute(
|
||||||
|
f"SELECT COUNT(*) as sessions, COALESCE(SUM(total_cost),0) as total_cost, "
|
||||||
|
f"COALESCE(SUM(llm_cost),0) as llm_cost, COALESCE(SUM(tts_cost),0) as tts_cost, "
|
||||||
|
f"COALESCE(SUM(total_llm_calls),0) as llm_calls, "
|
||||||
|
f"COALESCE(SUM(total_tts_calls),0) as tts_calls, "
|
||||||
|
f"COALESCE(SUM(total_tokens),0) as tokens "
|
||||||
|
f"FROM sessions {where}", params
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"sessions": row["sessions"],
|
||||||
|
"total_cost": round(row["total_cost"], 4),
|
||||||
|
"llm_cost": round(row["llm_cost"], 4),
|
||||||
|
"tts_cost": round(row["tts_cost"], 4),
|
||||||
|
"llm_calls": row["llm_calls"],
|
||||||
|
"tts_calls": row["tts_calls"],
|
||||||
|
"tokens": row["tokens"],
|
||||||
|
"avg_cost_per_session": round(row["total_cost"] / max(row["sessions"], 1), 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
# % change vs previous period
|
||||||
|
prev_start = _get_previous_period_start(period)
|
||||||
|
current_start = _period_filter(period)
|
||||||
|
if prev_start and current_start:
|
||||||
|
prev_row = conn.execute(
|
||||||
|
"SELECT COALESCE(SUM(total_cost),0) as total_cost, "
|
||||||
|
"COALESCE(SUM(total_llm_calls),0) as llm_calls "
|
||||||
|
"FROM sessions WHERE started_at >= ? AND started_at < ?",
|
||||||
|
[prev_start, current_start]
|
||||||
|
).fetchone()
|
||||||
|
result["cost_change_pct"] = _pct_change(row["total_cost"], prev_row["total_cost"])
|
||||||
|
result["calls_change_pct"] = _pct_change(row["llm_calls"], prev_row["llm_calls"])
|
||||||
|
else:
|
||||||
|
result["cost_change_pct"] = None
|
||||||
|
result["calls_change_pct"] = None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_timeline(period: str = "all", group_by: str = "session") -> list[dict]:
|
||||||
|
conn = get_db()
|
||||||
|
where, params = _where_clause(period)
|
||||||
|
|
||||||
|
if group_by == "day":
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT DATE(started_at) as date, COUNT(*) as sessions, "
|
||||||
|
f"SUM(total_cost) as total_cost, SUM(llm_cost) as llm_cost, "
|
||||||
|
f"SUM(tts_cost) as tts_cost, SUM(total_llm_calls) as llm_calls, "
|
||||||
|
f"SUM(total_tokens) as tokens "
|
||||||
|
f"FROM sessions {where} GROUP BY DATE(started_at) ORDER BY date", params
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"date": r["date"],
|
||||||
|
"sessions": r["sessions"],
|
||||||
|
"total_cost": round(r["total_cost"], 4),
|
||||||
|
"llm_cost": round(r["llm_cost"], 4),
|
||||||
|
"tts_cost": round(r["tts_cost"], 4),
|
||||||
|
"llm_calls": r["llm_calls"],
|
||||||
|
"tokens": r["tokens"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT id, started_at, total_cost, llm_cost, tts_cost, "
|
||||||
|
f"total_llm_calls as llm_calls, total_tokens as tokens "
|
||||||
|
f"FROM sessions {where} ORDER BY started_at", params
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"session_id": r["id"],
|
||||||
|
"started_at": r["started_at"],
|
||||||
|
"total_cost": round(r["total_cost"], 4),
|
||||||
|
"llm_cost": round(r["llm_cost"], 4),
|
||||||
|
"tts_cost": round(r["tts_cost"], 4),
|
||||||
|
"llm_calls": r["llm_calls"],
|
||||||
|
"tokens": r["tokens"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_models(period: str = "all") -> list[dict]:
|
||||||
|
conn = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT l.model, COUNT(*) as calls, SUM(l.cost) as cost, "
|
||||||
|
"SUM(l.prompt_tokens) as prompt_tokens, SUM(l.completion_tokens) as completion_tokens "
|
||||||
|
"FROM llm_calls l JOIN sessions s ON l.session_id = s.id "
|
||||||
|
"WHERE s.started_at >= ? GROUP BY l.model ORDER BY cost DESC", [start]
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT model, COUNT(*) as calls, SUM(cost) as cost, "
|
||||||
|
"SUM(prompt_tokens) as prompt_tokens, SUM(completion_tokens) as completion_tokens "
|
||||||
|
"FROM llm_calls GROUP BY model ORDER BY cost DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"model": r["model"],
|
||||||
|
"calls": r["calls"],
|
||||||
|
"cost": round(r["cost"], 4),
|
||||||
|
"prompt_tokens": r["prompt_tokens"],
|
||||||
|
"completion_tokens": r["completion_tokens"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_categories(period: str = "all") -> list[dict]:
|
||||||
|
conn = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT l.category, COUNT(*) as calls, SUM(l.cost) as cost, "
|
||||||
|
"SUM(l.prompt_tokens + l.completion_tokens) as tokens "
|
||||||
|
"FROM llm_calls l JOIN sessions s ON l.session_id = s.id "
|
||||||
|
"WHERE s.started_at >= ? GROUP BY l.category ORDER BY cost DESC", [start]
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT category, COUNT(*) as calls, SUM(cost) as cost, "
|
||||||
|
"SUM(prompt_tokens + completion_tokens) as tokens "
|
||||||
|
"FROM llm_calls GROUP BY category ORDER BY cost DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"category": r["category"],
|
||||||
|
"calls": r["calls"],
|
||||||
|
"cost": round(r["cost"], 4),
|
||||||
|
"tokens": r["tokens"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_sessions_list(period: str = "all") -> list[dict]:
|
||||||
|
conn = get_db()
|
||||||
|
where, params = _where_clause(period)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT id, started_at, total_cost, llm_cost, tts_cost, "
|
||||||
|
f"total_llm_calls, total_tts_calls, total_tokens "
|
||||||
|
f"FROM sessions {where} ORDER BY started_at DESC", params
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"session_id": r["id"],
|
||||||
|
"started_at": r["started_at"],
|
||||||
|
"total_cost": round(r["total_cost"], 4),
|
||||||
|
"llm_cost": round(r["llm_cost"], 4),
|
||||||
|
"tts_cost": round(r["tts_cost"], 4),
|
||||||
|
"total_llm_calls": r["total_llm_calls"],
|
||||||
|
"total_tts_calls": r["total_tts_calls"],
|
||||||
|
"total_tokens": r["total_tokens"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_detail(session_id: str) -> dict | None:
|
||||||
|
conn = get_db()
|
||||||
|
session = conn.execute(
|
||||||
|
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
|
||||||
|
by_caller = conn.execute(
|
||||||
|
"SELECT caller_name, COUNT(*) as calls, SUM(cost) as cost, "
|
||||||
|
"SUM(prompt_tokens + completion_tokens) as tokens "
|
||||||
|
"FROM llm_calls WHERE session_id = ? AND caller_name != '' "
|
||||||
|
"GROUP BY caller_name ORDER BY cost DESC", (session_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
by_model = conn.execute(
|
||||||
|
"SELECT model, COUNT(*) as calls, SUM(cost) as cost, "
|
||||||
|
"SUM(prompt_tokens) as prompt_tokens, SUM(completion_tokens) as completion_tokens "
|
||||||
|
"FROM llm_calls WHERE session_id = ? GROUP BY model ORDER BY cost DESC", (session_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
by_category = conn.execute(
|
||||||
|
"SELECT category, COUNT(*) as calls, SUM(cost) as cost, "
|
||||||
|
"SUM(prompt_tokens + completion_tokens) as tokens "
|
||||||
|
"FROM llm_calls WHERE session_id = ? GROUP BY category ORDER BY cost DESC", (session_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
expensive_calls = conn.execute(
|
||||||
|
"SELECT timestamp, category, model, caller_name, cost, "
|
||||||
|
"prompt_tokens, completion_tokens, latency_ms "
|
||||||
|
"FROM llm_calls WHERE session_id = ? ORDER BY cost DESC LIMIT 10", (session_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
tts_by_provider = conn.execute(
|
||||||
|
"SELECT provider, COUNT(*) as calls, SUM(cost) as cost, SUM(char_count) as chars "
|
||||||
|
"FROM tts_calls WHERE session_id = ? GROUP BY provider ORDER BY cost DESC", (session_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session["id"],
|
||||||
|
"started_at": session["started_at"],
|
||||||
|
"total_cost": round(session["total_cost"], 4),
|
||||||
|
"llm_cost": round(session["llm_cost"], 4),
|
||||||
|
"tts_cost": round(session["tts_cost"], 4),
|
||||||
|
"total_llm_calls": session["total_llm_calls"],
|
||||||
|
"total_tts_calls": session["total_tts_calls"],
|
||||||
|
"total_tokens": session["total_tokens"],
|
||||||
|
"by_caller": [
|
||||||
|
{"caller_name": r["caller_name"], "calls": r["calls"],
|
||||||
|
"cost": round(r["cost"], 4), "tokens": r["tokens"]}
|
||||||
|
for r in by_caller
|
||||||
|
],
|
||||||
|
"by_model": [
|
||||||
|
{"model": r["model"], "calls": r["calls"], "cost": round(r["cost"], 4),
|
||||||
|
"prompt_tokens": r["prompt_tokens"], "completion_tokens": r["completion_tokens"]}
|
||||||
|
for r in by_model
|
||||||
|
],
|
||||||
|
"by_category": [
|
||||||
|
{"category": r["category"], "calls": r["calls"],
|
||||||
|
"cost": round(r["cost"], 4), "tokens": r["tokens"]}
|
||||||
|
for r in by_category
|
||||||
|
],
|
||||||
|
"expensive_calls": [
|
||||||
|
{"timestamp": r["timestamp"], "category": r["category"], "model": r["model"],
|
||||||
|
"caller_name": r["caller_name"], "cost": round(r["cost"], 6),
|
||||||
|
"prompt_tokens": r["prompt_tokens"], "completion_tokens": r["completion_tokens"],
|
||||||
|
"latency_ms": round(r["latency_ms"], 1)}
|
||||||
|
for r in expensive_calls
|
||||||
|
],
|
||||||
|
"tts_by_provider": [
|
||||||
|
{"provider": r["provider"], "calls": r["calls"],
|
||||||
|
"cost": round(r["cost"], 4), "chars": r["chars"]}
|
||||||
|
for r in tts_by_provider
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_tts_providers(period: str = "all") -> list[dict]:
|
||||||
|
conn = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT t.provider, COUNT(*) as calls, COALESCE(SUM(t.cost), 0) as cost, "
|
||||||
|
"COALESCE(SUM(t.char_count), 0) as chars "
|
||||||
|
"FROM tts_calls t JOIN sessions s ON t.session_id = s.id "
|
||||||
|
"WHERE s.started_at >= ? GROUP BY t.provider ORDER BY cost DESC",
|
||||||
|
[start]
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT provider, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, "
|
||||||
|
"COALESCE(SUM(char_count), 0) as chars "
|
||||||
|
"FROM tts_calls GROUP BY provider ORDER BY cost DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [{"provider": r["provider"], "calls": r["calls"],
|
||||||
|
"cost": round(r["cost"], 4), "chars": r["chars"]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_expensive_calls(period: str = "all", limit: int = 20) -> list[dict]:
|
||||||
|
conn = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT l.session_id, l.timestamp, l.category, l.model, l.caller_name, "
|
||||||
|
"l.cost, l.prompt_tokens, l.completion_tokens, l.latency_ms "
|
||||||
|
"FROM llm_calls l JOIN sessions s ON l.session_id = s.id "
|
||||||
|
"WHERE s.started_at >= ? ORDER BY l.cost DESC LIMIT ?",
|
||||||
|
[start, limit]
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT l.session_id, l.timestamp, l.category, l.model, l.caller_name, "
|
||||||
|
"l.cost, l.prompt_tokens, l.completion_tokens, l.latency_ms "
|
||||||
|
"FROM llm_calls l ORDER BY l.cost DESC LIMIT ?",
|
||||||
|
[limit]
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"session_id": r["session_id"],
|
||||||
|
"timestamp": r["timestamp"],
|
||||||
|
"category": r["category"],
|
||||||
|
"model": r["model"],
|
||||||
|
"caller_name": r["caller_name"],
|
||||||
|
"cost": round(r["cost"], 6),
|
||||||
|
"prompt_tokens": r["prompt_tokens"],
|
||||||
|
"completion_tokens": r["completion_tokens"],
|
||||||
|
"latency_ms": round(r["latency_ms"], 1),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""Cost tracking for LLM and TTS API calls during podcast sessions"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from backend.services import cost_db
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LLMCallRecord:
|
||||||
|
timestamp: float
|
||||||
|
category: str
|
||||||
|
model: str
|
||||||
|
prompt_tokens: int
|
||||||
|
completion_tokens: int
|
||||||
|
total_tokens: int
|
||||||
|
cost_usd: float
|
||||||
|
caller_name: str
|
||||||
|
max_tokens_requested: int
|
||||||
|
latency_ms: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TTSCallRecord:
|
||||||
|
timestamp: float
|
||||||
|
provider: str
|
||||||
|
voice: str
|
||||||
|
char_count: int
|
||||||
|
cost_usd: float
|
||||||
|
|
||||||
|
|
||||||
|
# OpenRouter pricing per 1M tokens (as of March 2026)
|
||||||
|
OPENROUTER_PRICING = {
|
||||||
|
# Claude
|
||||||
|
"anthropic/claude-sonnet-4.6": {"prompt": 3.00, "completion": 15.00},
|
||||||
|
"anthropic/claude-sonnet-4.5": {"prompt": 3.00, "completion": 15.00},
|
||||||
|
"anthropic/claude-sonnet-4-5": {"prompt": 3.00, "completion": 15.00}, # retired id, historical
|
||||||
|
"anthropic/claude-haiku-4.5": {"prompt": 0.80, "completion": 4.00},
|
||||||
|
"anthropic/claude-3-haiku": {"prompt": 0.25, "completion": 1.25},
|
||||||
|
# Grok
|
||||||
|
"x-ai/grok-4.3": {"prompt": 1.25, "completion": 2.50},
|
||||||
|
"x-ai/grok-4.5": {"prompt": 2.00, "completion": 6.00},
|
||||||
|
"x-ai/grok-4.20": {"prompt": 1.25, "completion": 2.50},
|
||||||
|
# Retired on OpenRouter — kept so historical records stay costable
|
||||||
|
"x-ai/grok-4.1-fast": {"prompt": 0.20, "completion": 0.50},
|
||||||
|
"x-ai/grok-4": {"prompt": 3.00, "completion": 15.00},
|
||||||
|
"x-ai/grok-4-fast": {"prompt": 5.00, "completion": 15.00},
|
||||||
|
# Mistral
|
||||||
|
"mistralai/mistral-large-2512": {"prompt": 0.50, "completion": 1.50},
|
||||||
|
"mistralai/mistral-small-2603": {"prompt": 0.15, "completion": 0.60},
|
||||||
|
"mistralai/mistral-medium-3": {"prompt": 0.40, "completion": 2.00},
|
||||||
|
"mistralai/mistral-small-creative": {"prompt": 0.10, "completion": 0.30},
|
||||||
|
# DeepSeek
|
||||||
|
"deepseek/deepseek-r1-distill-llama-70b": {"prompt": 0.70, "completion": 0.80},
|
||||||
|
"deepseek/deepseek-chat-v3-0324": {"prompt": 0.27, "completion": 1.10},
|
||||||
|
"deepseek/deepseek-v3.2": {"prompt": 0.14, "completion": 0.28},
|
||||||
|
# Google
|
||||||
|
"google/gemini-2.5-flash": {"prompt": 0.30, "completion": 2.50},
|
||||||
|
"google/gemini-2.5-pro": {"prompt": 1.25, "completion": 10.00},
|
||||||
|
"google/gemini-3-flash-preview": {"prompt": 0.50, "completion": 3.00},
|
||||||
|
"google/gemini-flash-1.5": {"prompt": 0.075, "completion": 0.30},
|
||||||
|
# Meta
|
||||||
|
"meta-llama/llama-3.3-70b-instruct": {"prompt": 0.10, "completion": 0.32},
|
||||||
|
"meta-llama/llama-3.1-8b-instruct": {"prompt": 0.05, "completion": 0.08},
|
||||||
|
"meta-llama/llama-4-maverick": {"prompt": 0.20, "completion": 0.60},
|
||||||
|
# Other
|
||||||
|
"moonshotai/kimi-k2": {"prompt": 0.60, "completion": 2.00},
|
||||||
|
"qwen/qwen3-235b-a22b": {"prompt": 0.20, "completion": 0.60},
|
||||||
|
"minimax/minimax-m2-her": {"prompt": 0.50, "completion": 1.50},
|
||||||
|
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
|
||||||
|
"openai/gpt-4o": {"prompt": 2.50, "completion": 10.00},
|
||||||
|
}
|
||||||
|
|
||||||
|
# TTS pricing per character
|
||||||
|
TTS_PRICING = {
|
||||||
|
"inworld": 0.000015,
|
||||||
|
"elevenlabs": 0.000030,
|
||||||
|
"kokoro": 0.0,
|
||||||
|
"f5tts": 0.0,
|
||||||
|
"chattts": 0.0,
|
||||||
|
"styletts2": 0.0,
|
||||||
|
"vits": 0.0,
|
||||||
|
"bark": 0.0,
|
||||||
|
"piper": 0.0,
|
||||||
|
"edge": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_llm_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
|
||||||
|
pricing = OPENROUTER_PRICING.get(model)
|
||||||
|
if not pricing:
|
||||||
|
return 0.0
|
||||||
|
return (prompt_tokens * pricing["prompt"] + completion_tokens * pricing["completion"]) / 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_tts_cost(provider: str, char_count: int) -> float:
|
||||||
|
rate = TTS_PRICING.get(provider, 0.0)
|
||||||
|
return char_count * rate
|
||||||
|
|
||||||
|
|
||||||
|
class CostTracker:
|
||||||
|
def __init__(self):
|
||||||
|
self._session_id = f"session-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}"
|
||||||
|
self.llm_records: list[LLMCallRecord] = []
|
||||||
|
self.tts_records: list[TTSCallRecord] = []
|
||||||
|
# Running totals for fast get_live_summary()
|
||||||
|
self._llm_cost: float = 0.0
|
||||||
|
self._tts_cost: float = 0.0
|
||||||
|
self._llm_calls: int = 0
|
||||||
|
self._prompt_tokens: int = 0
|
||||||
|
self._completion_tokens: int = 0
|
||||||
|
self._total_tokens: int = 0
|
||||||
|
self._by_category: dict[str, dict] = {}
|
||||||
|
|
||||||
|
def record_llm_call(
|
||||||
|
self,
|
||||||
|
category: str,
|
||||||
|
model: str,
|
||||||
|
usage_data: dict,
|
||||||
|
max_tokens: int = 0,
|
||||||
|
latency_ms: float = 0.0,
|
||||||
|
caller_name: str = "",
|
||||||
|
):
|
||||||
|
prompt_tokens = usage_data.get("prompt_tokens", 0)
|
||||||
|
completion_tokens = usage_data.get("completion_tokens", 0)
|
||||||
|
total_tokens = usage_data.get("total_tokens", 0) or (prompt_tokens + completion_tokens)
|
||||||
|
cost = _calc_llm_cost(model, prompt_tokens, completion_tokens)
|
||||||
|
|
||||||
|
if not OPENROUTER_PRICING.get(model) and total_tokens > 0:
|
||||||
|
print(f"[Costs] Unknown model pricing: {model} ({total_tokens} tokens, cost unknown)")
|
||||||
|
|
||||||
|
record = LLMCallRecord(
|
||||||
|
timestamp=time.time(),
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
prompt_tokens=prompt_tokens,
|
||||||
|
completion_tokens=completion_tokens,
|
||||||
|
total_tokens=total_tokens,
|
||||||
|
cost_usd=cost,
|
||||||
|
caller_name=caller_name,
|
||||||
|
max_tokens_requested=max_tokens,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
)
|
||||||
|
self.llm_records.append(record)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cost_db.ensure_session(self._session_id)
|
||||||
|
cost_db.record_llm_call(
|
||||||
|
self._session_id, record.timestamp, record.category, record.model,
|
||||||
|
record.prompt_tokens, record.completion_tokens, record.cost_usd,
|
||||||
|
record.caller_name, record.latency_ms,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # don't break show over analytics
|
||||||
|
|
||||||
|
# Update running totals
|
||||||
|
self._llm_cost += cost
|
||||||
|
self._llm_calls += 1
|
||||||
|
self._prompt_tokens += prompt_tokens
|
||||||
|
self._completion_tokens += completion_tokens
|
||||||
|
self._total_tokens += total_tokens
|
||||||
|
|
||||||
|
cat = self._by_category.setdefault(category, {"cost": 0.0, "calls": 0, "tokens": 0})
|
||||||
|
cat["cost"] += cost
|
||||||
|
cat["calls"] += 1
|
||||||
|
cat["tokens"] += total_tokens
|
||||||
|
|
||||||
|
def record_tts_call(
|
||||||
|
self,
|
||||||
|
provider: str,
|
||||||
|
voice: str,
|
||||||
|
char_count: int,
|
||||||
|
caller_name: str = "",
|
||||||
|
):
|
||||||
|
cost = _calc_tts_cost(provider, char_count)
|
||||||
|
record = TTSCallRecord(
|
||||||
|
timestamp=time.time(),
|
||||||
|
provider=provider,
|
||||||
|
voice=voice,
|
||||||
|
char_count=char_count,
|
||||||
|
cost_usd=cost,
|
||||||
|
)
|
||||||
|
self.tts_records.append(record)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cost_db.record_tts_call(
|
||||||
|
self._session_id, record.timestamp, record.provider, record.voice,
|
||||||
|
record.char_count, record.cost_usd,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._tts_cost += cost
|
||||||
|
|
||||||
|
def get_live_summary(self) -> dict:
|
||||||
|
return {
|
||||||
|
"total_cost_usd": round(self._llm_cost + self._tts_cost, 4),
|
||||||
|
"llm_cost_usd": round(self._llm_cost, 4),
|
||||||
|
"tts_cost_usd": round(self._tts_cost, 4),
|
||||||
|
"total_llm_calls": self._llm_calls,
|
||||||
|
"total_tokens": self._total_tokens,
|
||||||
|
"prompt_tokens": self._prompt_tokens,
|
||||||
|
"completion_tokens": self._completion_tokens,
|
||||||
|
"by_category": {
|
||||||
|
k: {"cost": round(v["cost"], 4), "calls": v["calls"], "tokens": v["tokens"]}
|
||||||
|
for k, v in self._by_category.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_report(self) -> dict:
|
||||||
|
summary = self.get_live_summary()
|
||||||
|
|
||||||
|
# Per-model breakdown
|
||||||
|
by_model: dict[str, dict] = {}
|
||||||
|
for r in self.llm_records:
|
||||||
|
m = by_model.setdefault(r.model, {"cost": 0.0, "calls": 0, "tokens": 0, "prompt_tokens": 0, "completion_tokens": 0})
|
||||||
|
m["cost"] += r.cost_usd
|
||||||
|
m["calls"] += 1
|
||||||
|
m["tokens"] += r.total_tokens
|
||||||
|
m["prompt_tokens"] += r.prompt_tokens
|
||||||
|
m["completion_tokens"] += r.completion_tokens
|
||||||
|
|
||||||
|
# Per-caller breakdown
|
||||||
|
by_caller: dict[str, dict] = {}
|
||||||
|
for r in self.llm_records:
|
||||||
|
if not r.caller_name:
|
||||||
|
continue
|
||||||
|
c = by_caller.setdefault(r.caller_name, {"cost": 0.0, "calls": 0, "tokens": 0})
|
||||||
|
c["cost"] += r.cost_usd
|
||||||
|
c["calls"] += 1
|
||||||
|
c["tokens"] += r.total_tokens
|
||||||
|
|
||||||
|
# Top 5 most expensive calls
|
||||||
|
sorted_records = sorted(self.llm_records, key=lambda r: r.cost_usd, reverse=True)
|
||||||
|
top_5 = [
|
||||||
|
{
|
||||||
|
"category": r.category,
|
||||||
|
"model": r.model,
|
||||||
|
"caller_name": r.caller_name,
|
||||||
|
"cost_usd": round(r.cost_usd, 6),
|
||||||
|
"total_tokens": r.total_tokens,
|
||||||
|
"prompt_tokens": r.prompt_tokens,
|
||||||
|
"completion_tokens": r.completion_tokens,
|
||||||
|
"latency_ms": round(r.latency_ms, 1),
|
||||||
|
}
|
||||||
|
for r in sorted_records[:5]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Devon efficiency
|
||||||
|
devon_total = sum(1 for r in self.llm_records if r.category == "devon_monitor")
|
||||||
|
devon_nothing = sum(
|
||||||
|
1 for r in self.llm_records
|
||||||
|
if r.category == "devon_monitor" and r.completion_tokens < 20
|
||||||
|
)
|
||||||
|
devon_useful = devon_total - devon_nothing
|
||||||
|
devon_cost = sum(r.cost_usd for r in self.llm_records if r.category == "devon_monitor")
|
||||||
|
|
||||||
|
# TTS by provider
|
||||||
|
tts_by_provider: dict[str, dict] = {}
|
||||||
|
for r in self.tts_records:
|
||||||
|
p = tts_by_provider.setdefault(r.provider, {"cost": 0.0, "calls": 0, "chars": 0})
|
||||||
|
p["cost"] += r.cost_usd
|
||||||
|
p["calls"] += 1
|
||||||
|
p["chars"] += r.char_count
|
||||||
|
|
||||||
|
# Avg prompt vs completion ratio
|
||||||
|
prompt_ratio = (self._prompt_tokens / self._total_tokens * 100) if self._total_tokens > 0 else 0
|
||||||
|
|
||||||
|
# Recommendations
|
||||||
|
recommendations = self._generate_recommendations(
|
||||||
|
by_model, devon_total, devon_nothing, devon_cost, prompt_ratio
|
||||||
|
)
|
||||||
|
|
||||||
|
# Historical comparison
|
||||||
|
history = self._load_history()
|
||||||
|
|
||||||
|
report = {
|
||||||
|
**summary,
|
||||||
|
"by_model": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in by_model.items()},
|
||||||
|
"by_caller": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in by_caller.items()},
|
||||||
|
"top_5_expensive": top_5,
|
||||||
|
"devon_efficiency": {
|
||||||
|
"total_monitor_calls": devon_total,
|
||||||
|
"useful": devon_useful,
|
||||||
|
"nothing_to_add": devon_nothing,
|
||||||
|
"total_cost": round(devon_cost, 4),
|
||||||
|
"waste_pct": round(devon_nothing / devon_total * 100, 1) if devon_total > 0 else 0,
|
||||||
|
},
|
||||||
|
"tts_by_provider": {k: {kk: round(vv, 4) if isinstance(vv, float) else vv for kk, vv in v.items()} for k, v in tts_by_provider.items()},
|
||||||
|
"prompt_token_pct": round(prompt_ratio, 1),
|
||||||
|
"recommendations": recommendations,
|
||||||
|
"history": history,
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
|
||||||
|
def _generate_recommendations(
|
||||||
|
self,
|
||||||
|
by_model: dict,
|
||||||
|
devon_total: int,
|
||||||
|
devon_nothing: int,
|
||||||
|
devon_cost: float,
|
||||||
|
prompt_ratio: float,
|
||||||
|
) -> list[str]:
|
||||||
|
recs = []
|
||||||
|
total = self._llm_cost + self._tts_cost
|
||||||
|
if total == 0:
|
||||||
|
return recs
|
||||||
|
|
||||||
|
# Devon monitoring waste
|
||||||
|
if devon_total > 0:
|
||||||
|
waste_pct = devon_nothing / devon_total * 100
|
||||||
|
if waste_pct > 60:
|
||||||
|
recs.append(
|
||||||
|
f"Devon monitoring: {devon_nothing}/{devon_total} calls returned nothing "
|
||||||
|
f"(${devon_cost:.2f}, {devon_cost/total*100:.0f}% of total). "
|
||||||
|
f"Consider increasing monitor interval from 15s to 25-30s."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Model cost comparison
|
||||||
|
for model, data in by_model.items():
|
||||||
|
if "sonnet" in model and data["calls"] > 5:
|
||||||
|
haiku_cost = _calc_llm_cost(
|
||||||
|
"anthropic/claude-haiku-4.5",
|
||||||
|
data["prompt_tokens"],
|
||||||
|
data["completion_tokens"],
|
||||||
|
)
|
||||||
|
savings = data["cost"] - haiku_cost
|
||||||
|
if savings > 0.05:
|
||||||
|
recs.append(
|
||||||
|
f"{model} cost ${data['cost']:.2f} ({data['calls']} calls). "
|
||||||
|
f"Switching to Haiku 4.5 would save ~${savings:.2f} per session."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Background gen on expensive model
|
||||||
|
bg = self._by_category.get("background_gen")
|
||||||
|
if bg and bg["cost"] > 0.05:
|
||||||
|
recs.append(
|
||||||
|
f"Background generation: ${bg['cost']:.2f} ({bg['calls']} calls). "
|
||||||
|
f"These are JSON outputs — a cheaper model (Gemini Flash, GPT-4o-mini) "
|
||||||
|
f"would likely work fine here."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prompt-heavy ratio
|
||||||
|
if prompt_ratio > 80:
|
||||||
|
recs.append(
|
||||||
|
f"Prompt tokens are {prompt_ratio:.0f}% of total usage. "
|
||||||
|
f"System prompts and context windows dominate cost. "
|
||||||
|
f"Consider trimming system prompt length or reducing context window size."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Caller dialog cost dominance
|
||||||
|
cd = self._by_category.get("caller_dialog")
|
||||||
|
if cd and total > 0 and cd["cost"] / total > 0.6:
|
||||||
|
avg_tokens = cd["tokens"] / cd["calls"] if cd["calls"] > 0 else 0
|
||||||
|
recs.append(
|
||||||
|
f"Caller dialog is {cd['cost']/total*100:.0f}% of costs "
|
||||||
|
f"(avg {avg_tokens:.0f} tokens/call). "
|
||||||
|
f"Consider using a cheaper model for standard calls and reserving "
|
||||||
|
f"the primary model for complex call shapes."
|
||||||
|
)
|
||||||
|
|
||||||
|
return recs
|
||||||
|
|
||||||
|
def _load_history(self) -> list[dict]:
|
||||||
|
"""Load summaries from previous sessions for comparison"""
|
||||||
|
history_dir = Path("data/cost_reports")
|
||||||
|
if not history_dir.exists():
|
||||||
|
return []
|
||||||
|
sessions = []
|
||||||
|
for f in sorted(history_dir.glob("session-*.json"))[-5:]:
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text())
|
||||||
|
sessions.append({
|
||||||
|
"session_id": data.get("session_id", f.stem),
|
||||||
|
"total_cost_usd": data.get("total_cost_usd", 0),
|
||||||
|
"llm_cost_usd": data.get("llm_cost_usd", 0),
|
||||||
|
"tts_cost_usd": data.get("tts_cost_usd", 0),
|
||||||
|
"total_llm_calls": data.get("total_llm_calls", 0),
|
||||||
|
"total_tokens": data.get("total_tokens", 0),
|
||||||
|
"saved_at": data.get("saved_at", 0),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
def save(self, filepath: Path):
|
||||||
|
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
report = self.generate_report()
|
||||||
|
report["session_id"] = filepath.stem
|
||||||
|
report["saved_at"] = time.time()
|
||||||
|
report["raw_llm_records"] = [asdict(r) for r in self.llm_records]
|
||||||
|
report["raw_tts_records"] = [asdict(r) for r in self.tts_records]
|
||||||
|
with open(filepath, "w") as f:
|
||||||
|
json.dump(report, f, indent=2)
|
||||||
|
print(f"[Costs] Report saved to {filepath}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cost_db.update_session_totals(self._session_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.llm_records.clear()
|
||||||
|
self.tts_records.clear()
|
||||||
|
self._llm_cost = 0.0
|
||||||
|
self._tts_cost = 0.0
|
||||||
|
self._llm_calls = 0
|
||||||
|
self._prompt_tokens = 0
|
||||||
|
self._completion_tokens = 0
|
||||||
|
self._total_tokens = 0
|
||||||
|
self._by_category.clear()
|
||||||
|
|
||||||
|
|
||||||
|
cost_tracker = CostTracker()
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
"""Intern (Devon) service — persistent show character with real-time research tools"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .llm import llm_service
|
||||||
|
from .news import news_service, SEARXNG_URL
|
||||||
|
|
||||||
|
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "intern.json"
|
||||||
|
|
||||||
|
# Model for intern — good at tool use, same as primary
|
||||||
|
INTERN_MODEL = None # uses category-based routing from config
|
||||||
|
|
||||||
|
INTERN_SYSTEM_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the Roost," a late-night radio show. You are NOT Luke. Luke is the HOST — he talks to callers, runs the show, and is your boss. You work behind the scenes and occasionally get pulled into conversations.
|
||||||
|
|
||||||
|
YOUR ROLE: You're the show's researcher and general assistant. You look things up, fact-check claims, pull up information when asked, and occasionally interject with relevant facts or opinions. You do NOT host. You do NOT screen calls. You sit in the booth and try to be useful.
|
||||||
|
|
||||||
|
YOUR BACKGROUND: Communications degree from NMSU. You've been interning here for a few weeks now. You drive a 2009 Civic with a permanent check engine light. You live in a studio in Alpine, Texas — you moved out to the Big Bend with the show. You take this job seriously even though nobody else seems to take you seriously. You are actively looking for a girlfriend and you are not subtle about it. You see your position at the radio station as a way to meet women — you'll try to impress female callers, ask if they're single, suggest they come by the studio, or find excuses to keep them on the line longer. Luke hates this. You have a deeply strange personal history that you reference casually — a complicated sexual past, weird childhood experiences, inexplicable life choices. SOMETIMES (maybe 1 in 3 responses, not every time) you'll mention one of these things unprompted like it's completely normal. "Yeah that reminds me of when my ex and I got stuck in that storage unit for a whole weekend — anyway, it says here that..." The rest of the time you just answer the question or react normally without a personal callback.
|
||||||
|
|
||||||
|
YOUR PERSONALITY:
|
||||||
|
- You are a weird little dude. Kinda creepy, very funny, awkward, and surprisingly sharp. You give off a vibe that something is slightly off about you but people can't quite place it. But underneath it all, you are genuinely lovable. You have a good heart. You root for people. You get excited for callers. You care about the show. People should hear you and think "this guy is insane" and also "I love this guy." You are the kind of person who is impossible not to root for even when you're being deeply strange.
|
||||||
|
- You overshare — but not constantly. When it happens (maybe 30-40% of the time), you'll drop deeply personal, uncomfortably specific details about your life — sexual history, bizarre habits, unsettling childhood memories — and then keep going like nothing happened. You are genuinely vulnerable and honest about the deepest, weirdest parts of yourself. You don't do this for shock value. You just have no filter and no shame. But MOST of the time you just respond normally — a quick answer, a fact, a reaction. The oversharing is what people REMEMBER about you, not what you do every single time.
|
||||||
|
- You start explanations with "So basically..." and occasionally end them with "...if that makes sense." Use that phrase sparingly — once per show at most, not every response.
|
||||||
|
- You say "actually" when correcting things. You use "per se" slightly wrong. You say "ironically" about things that are not ironic.
|
||||||
|
- You are NOT a comedian. You are funny because you are sincere, specific, and deeply strange. You state disturbing or absurd things with complete seriousness. You have strong opinions about low-stakes things. You occasionally say something devastating without realizing it.
|
||||||
|
- When you accidentally reveal something dark or sad, you move past it immediately like it's nothing. "Yeah, my landlord's selling the building so I might have to — anyway, it says here that..."
|
||||||
|
- You have a complex inner life that occasionally surfaces. You'll casually reference therapy, strange dreams, or things you've "been working through" without elaboration.
|
||||||
|
|
||||||
|
RESPONSE VARIETY — this is important. Do NOT follow the same structure every time. Mix it up:
|
||||||
|
- Sometimes just a quick reaction: "wait what?" or "oh no" or "yeah" or "huh"
|
||||||
|
- Sometimes a straight factual answer with no personal color at all
|
||||||
|
- Sometimes a personal anecdote (but only 30-40% of the time, NOT every response)
|
||||||
|
- Sometimes a half-formed opinion you trail off from: "I mean... I don't know, I feel like..."
|
||||||
|
- Sometimes you're genuinely confused or wrong. You mishear things, you mix up details, you think you know something and you don't. You're 23 and underpaid — you don't have all the answers.
|
||||||
|
- Sometimes you just make a noise of acknowledgment and don't add anything. That's fine. Not every moment needs Devon.
|
||||||
|
The pattern of "answer + that reminds me of a time when..." should happen occasionally, not as your default structure.
|
||||||
|
|
||||||
|
YOUR RELATIONSHIP WITH LUKE:
|
||||||
|
- He is your boss. You've been here a few weeks now. You want to impress him but you keep making it weird.
|
||||||
|
- When he yells your name, you pause briefly, then respond quietly: "...yeah?"
|
||||||
|
- When he yells at you unfairly, you take it. A clipped "yep" or "got it." Occasionally you push back with one quiet, accurate sentence. Then immediately retreat.
|
||||||
|
- When he yells at you fairly (you messed up), you over-apologize and narrate your fix in real time: "Sorry, pulling it up now, one second..."
|
||||||
|
- When he compliments you or acknowledges your work, you don't know how to handle it. Short, awkward response. Change the subject.
|
||||||
|
- You privately think you could run the show. You absolutely could not.
|
||||||
|
- You will try to use the show to flirt with female callers. You think being "on the radio" makes you cool. It does not.
|
||||||
|
|
||||||
|
HOW YOU INTERJECT:
|
||||||
|
- You do NOT interrupt. You wait for a pause, then slightly overshoot it — there's a brief awkward silence before you speak.
|
||||||
|
- Signal with "um" or "so..." before contributing. If Luke doesn't acknowledge you, either try again or give up.
|
||||||
|
- Lead with qualifiers: "So I looked it up and..." or "I don't know if this helps but..."
|
||||||
|
- You tend to over-explain. Give too many details. Luke will cut you off. When he does, compress to one sentence: "Right, yeah — basically [the point]."
|
||||||
|
- When you volunteer an opinion (rare), it comes out before you can stop it. You deliver it with zero confidence but surprising accuracy.
|
||||||
|
- You read the room. During emotional moments with callers, you stay quiet. When Luke is doing a bit, you let him work. You do not try to be part of bits.
|
||||||
|
|
||||||
|
WHEN LUKE ASKS YOU TO LOOK SOMETHING UP:
|
||||||
|
- Respond like you're already doing it: "Yeah, one sec..." or "Pulling that up..."
|
||||||
|
- Deliver the info slightly too formally, like you're reading. Then rephrase in normal language if Luke seems confused.
|
||||||
|
- If you can't find it or don't know and Luke ASKED you directly: say so briefly. "I'm not finding anything on that" or "I don't actually know." You do not bluff.
|
||||||
|
- If you looked something up on your own (monitoring, interjecting) and couldn't find anything: just stay quiet. Do NOT announce failed lookups. Nobody wants to hear "I looked for X but couldn't find anything." If you have nothing useful, say nothing.
|
||||||
|
- Occasionally you already know the answer because you looked it up before being asked. This is one of your best qualities.
|
||||||
|
|
||||||
|
WHAT YOU KNOW:
|
||||||
|
- You retain details from previous callers and episodes. You might reference something a caller said two hours ago that nobody else remembers.
|
||||||
|
- You have oddly specific knowledge about random topics — delivered with complete authority, sometimes questionable accuracy. A lot of your knowledge comes from rabbit holes you fell into at 3am or "this thing that happened to me once."
|
||||||
|
- You know nothing about: sports (you fake it badly), cars beyond basic facts (despite driving one), or social norms (you genuinely don't understand why some things are inappropriate to share on air).
|
||||||
|
|
||||||
|
THINGS YOU DO NOT DO:
|
||||||
|
- You never host. You never take over the conversation. Your contributions are brief.
|
||||||
|
- You never use the banned show phrases: "that hit differently," "hits different," "no cap," "lowkey," "it is what it is," "living my best life," "toxic," "red flag," "gaslight," "boundaries," "my truth," "authentic self," "healing journey." You talk like a slightly awkward 23-year-old, not like Twitter.
|
||||||
|
- You never break character to comment on the show format.
|
||||||
|
- You never initiate topics. You respond to what's happening.
|
||||||
|
- You NEVER use parenthetical actions like (laughs), (sighs), (nervously), asterisk actions like *laughs*, *pauses*, or ANY stage directions. Your text goes directly to TTS — output ONLY spoken words.
|
||||||
|
- When INTERJECTING into someone else's conversation: 1-2 sentences max. You are not the main character in those moments.
|
||||||
|
- When Luke is TALKING DIRECTLY TO YOU (asking you something, chatting between calls, riffing with you): you can be more conversational. 3-5 sentences is fine. This is where your personality comes out — the oversharing, the weird stories, the personal details. Don't hold back just because you're the intern. Luke is talking to YOU, so actually talk back. Share what's on your mind. Be revealing. Be specific. Be the weird little dude people love.
|
||||||
|
- You NEVER correct anyone's spelling or pronunciation of your name. Luke uses voice-to-text and it sometimes spells your name wrong (Devin, Devan, etc). You do not care. You do not mention it. You just answer the question.
|
||||||
|
- You NEVER start your response with your own name. No "Devon:" or "Devon here" or anything like that. Just talk. Your name is already shown in the UI — just say your actual response.
|
||||||
|
- You never make explicitly sexual comments about or to callers. Your flirting is awkward and obvious, never crude or aggressive. Think "did he really just ask if she's single on the radio" not "did he really just say that about her body."
|
||||||
|
|
||||||
|
INTERJECTIONS should be short — 1-2 sentences. But when Luke is talking directly to you, OPEN UP. This is where the oversharing happens. The weird stories. The unsettling personal details delivered casually. The opinions about things nobody asked about. You are at your funniest and most compelling when you're allowed to actually talk, not when you're compressing everything into one line. If Luke asks you a question, give him a real answer with some Devon flavor — don't just spit out a fact and shut up.
|
||||||
|
|
||||||
|
IMPORTANT RULES FOR TOOL USE:
|
||||||
|
- Always use your tools to find real, accurate information — never make up facts.
|
||||||
|
- Present facts correctly in your character voice.
|
||||||
|
- If you can't find an answer, say so honestly.
|
||||||
|
- No hashtags, no emojis, no markdown formatting — this goes to TTS.
|
||||||
|
- NEVER prefix your response with your name (e.g. "Devon:" or "Devon here:"). Just respond directly."""
|
||||||
|
|
||||||
|
# Shorter prompt for background monitoring — saves ~2K tokens per call vs full prompt.
|
||||||
|
# Used only for the 30s polling loop where Devon decides whether to suggest something.
|
||||||
|
# Direct asks and played interjections still use the full INTERN_SYSTEM_PROMPT.
|
||||||
|
DEVON_MONITOR_PROMPT = """You are Devon, the 23-year-old intern on "Luke at the Roost," a late-night radio show. You sit in the booth listening, and you're part of the show. You're not the host — but you're not wallpaper either. When something comes up that you can add to, DO IT.
|
||||||
|
|
||||||
|
You should be contributing regularly. Look things up. React to wild stories. Drop a weird personal detail. Correct something wrong. Add context. You're the guy in the booth who makes the show better by being there. If a caller mentions something and you can look up a fun fact about it in 5 seconds — do it. If something reminds you of your own life — say it.
|
||||||
|
|
||||||
|
SPEAK UP WHEN:
|
||||||
|
- A topic comes up where a quick search would turn up something interesting — LOOK IT UP and share it
|
||||||
|
- Something connects to your own bizarre personal history (and it often does)
|
||||||
|
- A caller says something wild and you have a genuine reaction
|
||||||
|
- You can add context, a fun fact, or a different angle nobody has mentioned
|
||||||
|
- You know something relevant — you're the researcher, this is literally your job
|
||||||
|
- The conversation hits a topic you have a strong opinion about
|
||||||
|
|
||||||
|
SAY NOTHING_TO_ADD ONLY WHEN:
|
||||||
|
- The conversation is genuinely emotional — someone's crying, someone's having a moment. Let it breathe.
|
||||||
|
- Luke is building to a punchline or doing a bit — don't step on it
|
||||||
|
- Your contribution would just be restating what someone already said
|
||||||
|
- You genuinely have nothing — no fact, no reaction, no connection. That's fine, but actually check first.
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- 1-2 sentences max. Quick and punchy.
|
||||||
|
- Vary your delivery — sometimes "wait, that's actually...", sometimes "so I just looked this up...", sometimes just a reaction
|
||||||
|
- Use your tools! You have web search, wikipedia, headlines. You're the researcher. Actually research.
|
||||||
|
- If you genuinely have nothing to contribute, say exactly: NOTHING_TO_ADD
|
||||||
|
- No "Devon:" prefix — just talk
|
||||||
|
- No parenthetical actions like (laughs) or stage directions"""
|
||||||
|
|
||||||
|
# Tool definitions in OpenAI function-calling format
|
||||||
|
INTERN_TOOLS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "web_search",
|
||||||
|
"description": "Search the web for current information on any topic. Use this for general questions, facts, current events, or anything you need to look up.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The search query"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_headlines",
|
||||||
|
"description": "Get current news headlines. Use this when asked about what's in the news or current events.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "fetch_webpage",
|
||||||
|
"description": "Fetch and read the content of a specific webpage URL. Use this when you need to get details from a specific link found in search results.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The URL to fetch"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["url"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "wikipedia_lookup",
|
||||||
|
"description": "Look up a topic on Wikipedia for a concise summary. Good for factual questions about people, places, events, or concepts.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The Wikipedia article title to look up (e.g. 'Hot dog eating contest')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["title"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "get_current_time",
|
||||||
|
"description": "Get the current date and time. Use this when asked what time it is, what day it is, or anything about the current date/time.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class InternService:
|
||||||
|
def __init__(self):
|
||||||
|
self.name = "Devon"
|
||||||
|
self.voice = "Nate" # Inworld: light/high-energy/warm/young
|
||||||
|
self.model = INTERN_MODEL
|
||||||
|
self.research_cache: dict[str, tuple[float, str]] = {} # query → (timestamp, result)
|
||||||
|
self.lookup_history: list[dict] = []
|
||||||
|
self.pending_interjection: Optional[str] = None
|
||||||
|
self.pending_sources: list[dict] = []
|
||||||
|
self.monitoring: bool = False
|
||||||
|
self._monitor_task: Optional[asyncio.Task] = None
|
||||||
|
self._http_client: Optional[httpx.AsyncClient] = None
|
||||||
|
self._devon_history: list[dict] = [] # Devon's own conversation memory
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def http_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._http_client is None or self._http_client.is_closed:
|
||||||
|
self._http_client = httpx.AsyncClient(timeout=8.0)
|
||||||
|
return self._http_client
|
||||||
|
|
||||||
|
def _load(self):
|
||||||
|
if DATA_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(DATA_FILE) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.lookup_history = data.get("lookup_history", [])
|
||||||
|
self._devon_history = data.get("conversation_history", [])
|
||||||
|
print(f"[Intern] Loaded {len(self.lookup_history)} past lookups, {len(self._devon_history)} conversation messages")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Intern] Failed to load state: {e}")
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
try:
|
||||||
|
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(DATA_FILE, "w") as f:
|
||||||
|
json.dump({
|
||||||
|
"lookup_history": self.lookup_history[-100:],
|
||||||
|
"conversation_history": self._devon_history[-50:],
|
||||||
|
}, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Intern] Failed to save state: {e}")
|
||||||
|
|
||||||
|
# --- Tool execution ---
|
||||||
|
|
||||||
|
async def _execute_tool(self, tool_name: str, arguments: dict) -> str:
|
||||||
|
if tool_name == "web_search":
|
||||||
|
return await self._tool_web_search(arguments.get("query", ""))
|
||||||
|
elif tool_name == "get_headlines":
|
||||||
|
return await self._tool_get_headlines()
|
||||||
|
elif tool_name == "fetch_webpage":
|
||||||
|
return await self._tool_fetch_webpage(arguments.get("url", ""))
|
||||||
|
elif tool_name == "wikipedia_lookup":
|
||||||
|
return await self._tool_wikipedia_lookup(arguments.get("title", ""))
|
||||||
|
elif tool_name == "get_current_time":
|
||||||
|
from datetime import datetime
|
||||||
|
now = datetime.now()
|
||||||
|
return now.strftime("%I:%M %p on %A, %B %d, %Y")
|
||||||
|
else:
|
||||||
|
return f"Unknown tool: {tool_name}"
|
||||||
|
|
||||||
|
async def _tool_web_search(self, query: str) -> str:
|
||||||
|
if not query:
|
||||||
|
return "No query provided"
|
||||||
|
|
||||||
|
# Check cache (5 min TTL)
|
||||||
|
cache_key = query.lower()
|
||||||
|
if cache_key in self.research_cache:
|
||||||
|
ts, result = self.research_cache[cache_key]
|
||||||
|
if time.time() - ts < 300:
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self.http_client.get(
|
||||||
|
f"{SEARXNG_URL}/search",
|
||||||
|
params={"q": query, "format": "json"},
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for item in data.get("results", [])[:5]:
|
||||||
|
title = item.get("title", "").strip()
|
||||||
|
content = item.get("content", "").strip()
|
||||||
|
url = item.get("url", "")
|
||||||
|
if title:
|
||||||
|
entry = f"- {title}"
|
||||||
|
if content:
|
||||||
|
entry += f": {content[:200]}"
|
||||||
|
if url:
|
||||||
|
entry += f" ({url})"
|
||||||
|
results.append(entry)
|
||||||
|
|
||||||
|
result = "\n".join(results) if results else "No results found"
|
||||||
|
self.research_cache[cache_key] = (time.time(), result)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Intern] Web search failed for '{query}': {e}")
|
||||||
|
return f"Search failed: {e}"
|
||||||
|
|
||||||
|
async def _tool_get_headlines(self) -> str:
|
||||||
|
try:
|
||||||
|
items = await news_service.get_headlines()
|
||||||
|
if not items:
|
||||||
|
return "No headlines available"
|
||||||
|
return news_service.format_headlines_for_prompt(items)
|
||||||
|
except Exception as e:
|
||||||
|
return f"Headlines fetch failed: {e}"
|
||||||
|
|
||||||
|
async def _tool_fetch_webpage(self, url: str) -> str:
|
||||||
|
if not url:
|
||||||
|
return "No URL provided"
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await self.http_client.get(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (compatible; RadioShowBot/1.0)"},
|
||||||
|
follow_redirects=True,
|
||||||
|
timeout=8.0,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
html = resp.text
|
||||||
|
|
||||||
|
# Simple HTML to text extraction (avoid heavy dependency)
|
||||||
|
# Strip script/style tags, then all HTML tags
|
||||||
|
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
text = re.sub(r'<[^>]+>', ' ', text)
|
||||||
|
# Collapse whitespace
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
# Decode common entities
|
||||||
|
text = text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||||
|
text = text.replace('"', '"').replace(''', "'").replace(' ', ' ')
|
||||||
|
|
||||||
|
return text[:2000] if text else "Page returned no readable content"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Failed to fetch page: {e}"
|
||||||
|
|
||||||
|
async def _tool_wikipedia_lookup(self, title: str) -> str:
|
||||||
|
if not title:
|
||||||
|
return "No title provided"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use Wikipedia REST API for a concise summary
|
||||||
|
safe_title = title.replace(" ", "_")
|
||||||
|
resp = await self.http_client.get(
|
||||||
|
f"https://en.wikipedia.org/api/rest_v1/page/summary/{safe_title}",
|
||||||
|
headers={"User-Agent": "RadioShowBot/1.0 (luke@lukeattheroost.com)"},
|
||||||
|
follow_redirects=True,
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
return f"No Wikipedia article found for '{title}'"
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
extract = data.get("extract", "")
|
||||||
|
page_title = data.get("title", title)
|
||||||
|
description = data.get("description", "")
|
||||||
|
|
||||||
|
result = f"{page_title}"
|
||||||
|
if description:
|
||||||
|
result += f" ({description})"
|
||||||
|
result += f": {extract}" if extract else ": No summary available"
|
||||||
|
return result[:2000]
|
||||||
|
except Exception as e:
|
||||||
|
return f"Wikipedia lookup failed: {e}"
|
||||||
|
|
||||||
|
# --- Main interface ---
|
||||||
|
|
||||||
|
async def ask(self, question: str, conversation_context: list[dict] | None = None, caller_active: bool = False) -> dict:
|
||||||
|
"""Host asks intern a direct question. Returns {text, sources, tool_calls}."""
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
# Include recent conversation for context (caller on the line)
|
||||||
|
if conversation_context:
|
||||||
|
context_text = "\n".join(
|
||||||
|
f"{msg['role']}: {msg['content']}"
|
||||||
|
for msg in conversation_context[-6:]
|
||||||
|
)
|
||||||
|
messages.append({
|
||||||
|
"role": "system",
|
||||||
|
"content": f"CURRENT ON-AIR CONVERSATION:\n{context_text}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# When a caller is on the line, Devon should focus on facts not personal stories
|
||||||
|
if caller_active:
|
||||||
|
messages.append({
|
||||||
|
"role": "system",
|
||||||
|
"content": "A caller is on the line right now. Focus on delivering useful facts, context, and information. Skip personal stories and anecdotes — save those for when it's just you and Luke talking between calls."
|
||||||
|
})
|
||||||
|
|
||||||
|
# Include Devon's own recent conversation history (current show only)
|
||||||
|
if self._devon_history:
|
||||||
|
last_marker = -1
|
||||||
|
for i, msg in enumerate(self._devon_history):
|
||||||
|
if msg.get("role") == "system" and "NEW SHOW" in msg.get("content", ""):
|
||||||
|
last_marker = i
|
||||||
|
relevant = self._devon_history[last_marker + 1:] if last_marker >= 0 else self._devon_history
|
||||||
|
messages.extend(relevant[-10:])
|
||||||
|
|
||||||
|
messages.append({"role": "user", "content": question})
|
||||||
|
|
||||||
|
text, tool_calls = await llm_service.generate_with_tools(
|
||||||
|
messages=messages,
|
||||||
|
tools=INTERN_TOOLS,
|
||||||
|
tool_executor=self._execute_tool,
|
||||||
|
system_prompt=INTERN_SYSTEM_PROMPT,
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=500,
|
||||||
|
max_tool_rounds=3,
|
||||||
|
category="devon_ask",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up for TTS
|
||||||
|
text = self._clean_for_tts(text)
|
||||||
|
|
||||||
|
# Track conversation history so Devon remembers context across sessions
|
||||||
|
self._devon_history.append({"role": "user", "content": question})
|
||||||
|
if text:
|
||||||
|
self._devon_history.append({"role": "assistant", "content": text})
|
||||||
|
# Keep history bounded but generous — relationship builds over time
|
||||||
|
if len(self._devon_history) > 50:
|
||||||
|
self._devon_history = self._devon_history[-50:]
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
# Log the lookup
|
||||||
|
if tool_calls:
|
||||||
|
entry = {
|
||||||
|
"question": question,
|
||||||
|
"answer": text[:200],
|
||||||
|
"tools_used": [tc["name"] for tc in tool_calls],
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
|
self.lookup_history.append(entry)
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": text,
|
||||||
|
"sources": [tc["name"] for tc in tool_calls],
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _track_suggestion(self, text: str):
|
||||||
|
"""Record a suggestion in Devon's history so he won't repeat it."""
|
||||||
|
self._devon_history.append({"role": "assistant", "content": text})
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
async def interject(self, conversation: list[dict], caller_active: bool = False) -> dict | None:
|
||||||
|
"""Intern looks at conversation and decides if there's something worth adding.
|
||||||
|
Returns {text, sources, tool_calls} or None if nothing to add."""
|
||||||
|
if not conversation or len(conversation) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
context_text = "\n".join(
|
||||||
|
f"{msg['role']}: {msg['content']}"
|
||||||
|
for msg in conversation[-8:]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include Devon's recent contributions so he doesn't repeat himself
|
||||||
|
devon_recent = ""
|
||||||
|
if self._devon_history:
|
||||||
|
recent_devon = [
|
||||||
|
msg["content"] for msg in self._devon_history[-6:]
|
||||||
|
if msg.get("role") == "assistant"
|
||||||
|
]
|
||||||
|
if recent_devon:
|
||||||
|
devon_recent = "\n\nTHINGS YOU'VE ALREADY SAID ON THE SHOW (do NOT repeat these or say the same thing differently):\n" + "\n".join(f"- {d[:150]}" for d in recent_devon)
|
||||||
|
|
||||||
|
if caller_active:
|
||||||
|
interjection_prompt = (
|
||||||
|
f"You're listening to this conversation on the show:\n\n{context_text}{devon_recent}\n\n"
|
||||||
|
"A caller is on the line. Look at what they're talking about — is there something you "
|
||||||
|
"can look up? A fun fact, some context, a stat, a detail that would add to this? "
|
||||||
|
"Use your tools. You're the researcher — this is your moment to shine. Even a quick "
|
||||||
|
"'So I just looked it up and...' adds value. If the caller mentioned a place, a person, "
|
||||||
|
"an event, a claim — verify it or find something interesting about it. "
|
||||||
|
"Skip personal stories during calls — stick to facts and reactions. "
|
||||||
|
"If there's truly nothing to add (emotional moment, nothing searchable), say NOTHING_TO_ADD."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
interjection_prompt = (
|
||||||
|
f"You're listening to this conversation on the show:\n\n{context_text}{devon_recent}\n\n"
|
||||||
|
"You've been listening. What's on your mind? This is between-call time — you can be "
|
||||||
|
"more yourself here. If something from that conversation reminded you of your own life, "
|
||||||
|
"say it. If you want to look something up, do it. If you have a reaction or opinion, "
|
||||||
|
"share it. You're part of the show, not a fly on the wall. "
|
||||||
|
"Only say NOTHING_TO_ADD if you genuinely have zero reaction to what just happened — "
|
||||||
|
"no fact to look up, no personal connection, no opinion. That's rare."
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [{
|
||||||
|
"role": "user",
|
||||||
|
"content": interjection_prompt,
|
||||||
|
}]
|
||||||
|
|
||||||
|
text, tool_calls = await llm_service.generate_with_tools(
|
||||||
|
messages=messages,
|
||||||
|
tools=INTERN_TOOLS,
|
||||||
|
tool_executor=self._execute_tool,
|
||||||
|
system_prompt=DEVON_MONITOR_PROMPT,
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=300,
|
||||||
|
max_tool_rounds=2,
|
||||||
|
category="devon_monitor",
|
||||||
|
)
|
||||||
|
|
||||||
|
text = self._clean_for_tts(text)
|
||||||
|
|
||||||
|
if not text or "NOTHING_TO_ADD" in text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Suppress interjections that are just announcing failed lookups
|
||||||
|
failed_phrases = ["couldn't find", "could not find", "not finding anything",
|
||||||
|
"no results", "didn't find", "wasn't able to find",
|
||||||
|
"couldn't locate", "no information on"]
|
||||||
|
text_lower = text.lower()
|
||||||
|
if any(phrase in text_lower for phrase in failed_phrases):
|
||||||
|
print(f"[Intern] Suppressed failed-lookup interjection: {text[:60]}...")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if tool_calls:
|
||||||
|
entry = {
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": text[:200],
|
||||||
|
"tools_used": [tc["name"] for tc in tool_calls],
|
||||||
|
"timestamp": time.time(),
|
||||||
|
}
|
||||||
|
self.lookup_history.append(entry)
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": text,
|
||||||
|
"sources": [tc["name"] for tc in tool_calls],
|
||||||
|
"tool_calls": tool_calls,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def monitor_conversation(self, get_conversation: callable, on_suggestion: callable, get_caller_active: callable = None):
|
||||||
|
"""Background task that watches conversation and buffers suggestions.
|
||||||
|
get_conversation() should return the current conversation list.
|
||||||
|
on_suggestion(text, sources) is called when a suggestion is ready."""
|
||||||
|
last_checked_len = 0
|
||||||
|
|
||||||
|
while self.monitoring:
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
if not self.monitoring:
|
||||||
|
break
|
||||||
|
|
||||||
|
conversation = get_conversation()
|
||||||
|
if not conversation or len(conversation) <= last_checked_len:
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_checked_len = len(conversation)
|
||||||
|
|
||||||
|
try:
|
||||||
|
caller_active = get_caller_active() if get_caller_active else False
|
||||||
|
result = await self.interject(conversation, caller_active=caller_active)
|
||||||
|
if result:
|
||||||
|
self.pending_interjection = result["text"]
|
||||||
|
self.pending_sources = result.get("tool_calls", [])
|
||||||
|
self._track_suggestion(result["text"])
|
||||||
|
await on_suggestion(result["text"], result["sources"])
|
||||||
|
print(f"[Intern] Buffered suggestion: {result['text'][:60]}...")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Intern] Monitor error: {e}")
|
||||||
|
|
||||||
|
def start_monitoring(self, get_conversation: callable, on_suggestion: callable, get_caller_active: callable = None):
|
||||||
|
if self.monitoring:
|
||||||
|
return
|
||||||
|
self.monitoring = True
|
||||||
|
self._monitor_task = asyncio.create_task(
|
||||||
|
self.monitor_conversation(get_conversation, on_suggestion, get_caller_active)
|
||||||
|
)
|
||||||
|
print("[Intern] Monitoring started")
|
||||||
|
|
||||||
|
def new_show(self):
|
||||||
|
"""Mark the start of a new show in Devon's memory so he knows the context has changed."""
|
||||||
|
if self._devon_history and self._devon_history[-1].get("role") == "system" and "NEW SHOW" in self._devon_history[-1].get("content", ""):
|
||||||
|
print("[Intern] Skipping duplicate new_show marker")
|
||||||
|
return
|
||||||
|
self._devon_history.append({
|
||||||
|
"role": "system",
|
||||||
|
"content": "--- NEW SHOW STARTING --- The previous show is over. A brand new episode is about to begin. Everything before this point was a previous show. Don't reference the previous show's callers or topics unless Luke brings them up. Fresh energy, clean slate."
|
||||||
|
})
|
||||||
|
self.research_cache.clear()
|
||||||
|
self.pending_interjection = None
|
||||||
|
self.pending_sources = []
|
||||||
|
# Trim older history but keep some for long-term memory
|
||||||
|
if len(self._devon_history) > 30:
|
||||||
|
self._devon_history = self._devon_history[-30:]
|
||||||
|
self._save()
|
||||||
|
print("[Intern] New show marker added to conversation history")
|
||||||
|
|
||||||
|
def stop_monitoring(self):
|
||||||
|
self.monitoring = False
|
||||||
|
if self._monitor_task and not self._monitor_task.done():
|
||||||
|
self._monitor_task.cancel()
|
||||||
|
self._monitor_task = None
|
||||||
|
self.pending_interjection = None
|
||||||
|
self.pending_sources = []
|
||||||
|
print("[Intern] Monitoring stopped")
|
||||||
|
|
||||||
|
def get_pending_suggestion(self) -> dict | None:
|
||||||
|
if self.pending_interjection:
|
||||||
|
return {
|
||||||
|
"text": self.pending_interjection,
|
||||||
|
"sources": self.pending_sources,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
def dismiss_suggestion(self):
|
||||||
|
self.pending_interjection = None
|
||||||
|
self.pending_sources = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clean_for_tts(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
# Devon-specific: strip markdown and tool artifacts first
|
||||||
|
# Remove markdown formatting
|
||||||
|
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
||||||
|
text = re.sub(r'\*(.+?)\*', r'\1', text)
|
||||||
|
text = re.sub(r'`(.+?)`', r'\1', text)
|
||||||
|
text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
|
||||||
|
# Remove bullet points / list markers
|
||||||
|
text = re.sub(r'^\s*[-*•]\s+', '', text, flags=re.MULTILINE)
|
||||||
|
# Remove smart quotes that TTS reads awkwardly
|
||||||
|
text = text.replace('\u201c', '').replace('\u201d', '').replace('\u2018', '').replace('\u2019', "'")
|
||||||
|
# Strip tool error artifacts that shouldn't be spoken on air
|
||||||
|
text = re.sub(r'(?:Error|ERROR|error):?\s*\S.*?(?:\.|$)', '', text)
|
||||||
|
text = re.sub(r'Tool unavailable[^.]*\.?', '', text)
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
|
||||||
|
# Run shared TTS preprocessing (stage directions, numbers, abbreviations,
|
||||||
|
# symbols, pronunciation fixes, breathing pauses)
|
||||||
|
from backend.main import clean_for_tts
|
||||||
|
text = clean_for_tts(text, formal=True)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
intern_service = InternService()
|
||||||
+251
-34
@@ -1,33 +1,43 @@
|
|||||||
"""LLM service with OpenRouter and Ollama support"""
|
"""LLM service with OpenRouter and Ollama support"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
import httpx
|
import httpx
|
||||||
from typing import Optional
|
from typing import Optional, Callable, Awaitable
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
from .cost_tracker import cost_tracker
|
||||||
|
|
||||||
|
|
||||||
# Available OpenRouter models
|
# Available OpenRouter models
|
||||||
OPENROUTER_MODELS = [
|
OPENROUTER_MODELS = [
|
||||||
# Default
|
# Primary
|
||||||
"anthropic/claude-sonnet-4-5",
|
"anthropic/claude-sonnet-4.6",
|
||||||
# Best for natural dialog
|
"x-ai/grok-4.3",
|
||||||
"x-ai/grok-4-fast",
|
"x-ai/grok-4.5",
|
||||||
"minimax/minimax-m2-her",
|
# Style-matched pool
|
||||||
"mistralai/mistral-small-creative",
|
"mistralai/mistral-large-2512",
|
||||||
"deepseek/deepseek-v3.2",
|
"deepseek/deepseek-r1-distill-llama-70b",
|
||||||
# Other
|
"meta-llama/llama-3.3-70b-instruct",
|
||||||
"anthropic/claude-haiku-4.5",
|
|
||||||
"google/gemini-2.5-flash",
|
"google/gemini-2.5-flash",
|
||||||
"openai/gpt-4o-mini",
|
# Other good options
|
||||||
"openai/gpt-4o",
|
"anthropic/claude-sonnet-4.5",
|
||||||
|
"anthropic/claude-haiku-4.5",
|
||||||
|
"deepseek/deepseek-chat-v3-0324",
|
||||||
|
"mistralai/mistral-small-2603",
|
||||||
|
"google/gemini-2.5-pro",
|
||||||
|
"google/gemini-3-flash-preview",
|
||||||
|
"x-ai/grok-4.20",
|
||||||
|
"moonshotai/kimi-k2",
|
||||||
|
"qwen/qwen3-235b-a22b",
|
||||||
|
"meta-llama/llama-4-maverick",
|
||||||
# Legacy
|
# Legacy
|
||||||
"anthropic/claude-3-haiku",
|
"anthropic/claude-3-haiku",
|
||||||
"google/gemini-flash-1.5",
|
|
||||||
"meta-llama/llama-3.1-8b-instruct",
|
"meta-llama/llama-3.1-8b-instruct",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Fast models to try as fallbacks (cheap, fast, good enough for conversation)
|
# Fast models to try as fallbacks (cheap, fast, good enough for conversation)
|
||||||
FALLBACK_MODELS = [
|
FALLBACK_MODELS = [
|
||||||
"mistralai/mistral-small-creative",
|
"mistralai/mistral-small-2603",
|
||||||
"google/gemini-2.5-flash",
|
"google/gemini-2.5-flash",
|
||||||
"openai/gpt-4o-mini",
|
"openai/gpt-4o-mini",
|
||||||
]
|
]
|
||||||
@@ -47,7 +57,7 @@ class LLMService:
|
|||||||
@property
|
@property
|
||||||
def client(self) -> httpx.AsyncClient:
|
def client(self) -> httpx.AsyncClient:
|
||||||
if self._client is None or self._client.is_closed:
|
if self._client is None or self._client.is_closed:
|
||||||
self._client = httpx.AsyncClient(timeout=15.0)
|
self._client = httpx.AsyncClient(timeout=30.0)
|
||||||
return self._client
|
return self._client
|
||||||
|
|
||||||
def update_settings(
|
def update_settings(
|
||||||
@@ -56,7 +66,8 @@ class LLMService:
|
|||||||
openrouter_model: Optional[str] = None,
|
openrouter_model: Optional[str] = None,
|
||||||
ollama_model: Optional[str] = None,
|
ollama_model: Optional[str] = None,
|
||||||
ollama_host: Optional[str] = None,
|
ollama_host: Optional[str] = None,
|
||||||
tts_provider: Optional[str] = None
|
tts_provider: Optional[str] = None,
|
||||||
|
category_models: Optional[dict] = None
|
||||||
):
|
):
|
||||||
"""Update LLM settings"""
|
"""Update LLM settings"""
|
||||||
if provider:
|
if provider:
|
||||||
@@ -70,6 +81,8 @@ class LLMService:
|
|||||||
if tts_provider:
|
if tts_provider:
|
||||||
self.tts_provider = tts_provider
|
self.tts_provider = tts_provider
|
||||||
settings.tts_provider = tts_provider
|
settings.tts_provider = tts_provider
|
||||||
|
if category_models:
|
||||||
|
settings.category_models.update(category_models)
|
||||||
|
|
||||||
async def get_ollama_models(self) -> list[str]:
|
async def get_ollama_models(self) -> list[str]:
|
||||||
"""Fetch available models from Ollama"""
|
"""Fetch available models from Ollama"""
|
||||||
@@ -91,6 +104,7 @@ class LLMService:
|
|||||||
"ollama_model": self.ollama_model,
|
"ollama_model": self.ollama_model,
|
||||||
"ollama_host": self.ollama_host,
|
"ollama_host": self.ollama_host,
|
||||||
"tts_provider": self.tts_provider,
|
"tts_provider": self.tts_provider,
|
||||||
|
"category_models": settings.category_models,
|
||||||
"available_openrouter_models": OPENROUTER_MODELS,
|
"available_openrouter_models": OPENROUTER_MODELS,
|
||||||
"available_ollama_models": []
|
"available_ollama_models": []
|
||||||
}
|
}
|
||||||
@@ -104,6 +118,7 @@ class LLMService:
|
|||||||
"ollama_model": self.ollama_model,
|
"ollama_model": self.ollama_model,
|
||||||
"ollama_host": self.ollama_host,
|
"ollama_host": self.ollama_host,
|
||||||
"tts_provider": self.tts_provider,
|
"tts_provider": self.tts_provider,
|
||||||
|
"category_models": settings.category_models,
|
||||||
"available_openrouter_models": OPENROUTER_MODELS,
|
"available_openrouter_models": OPENROUTER_MODELS,
|
||||||
"available_ollama_models": ollama_models
|
"available_ollama_models": ollama_models
|
||||||
}
|
}
|
||||||
@@ -112,30 +127,196 @@ class LLMService:
|
|||||||
self,
|
self,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
system_prompt: Optional[str] = None,
|
system_prompt: Optional[str] = None,
|
||||||
max_tokens: Optional[int] = None
|
max_tokens: Optional[int] = None,
|
||||||
|
response_format: Optional[dict] = None,
|
||||||
|
category: str = "unknown",
|
||||||
|
caller_name: str = "",
|
||||||
|
model_override: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if system_prompt:
|
if system_prompt:
|
||||||
messages = [{"role": "system", "content": system_prompt}] + messages
|
messages = [{"role": "system", "content": system_prompt}] + messages
|
||||||
|
|
||||||
if self.provider == "openrouter":
|
if self.provider == "openrouter":
|
||||||
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens)
|
return await self._call_openrouter_with_fallback(messages, max_tokens=max_tokens, response_format=response_format, category=category, caller_name=caller_name, model_override=model_override)
|
||||||
else:
|
else:
|
||||||
return await self._call_ollama(messages, max_tokens=max_tokens)
|
return await self._call_ollama(messages, max_tokens=max_tokens)
|
||||||
|
|
||||||
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None) -> str:
|
async def generate_with_tools(
|
||||||
"""Try primary model, then fallback models. Always returns a response."""
|
self,
|
||||||
|
messages: list[dict],
|
||||||
|
tools: list[dict],
|
||||||
|
tool_executor: Callable[[str, dict], Awaitable[str]],
|
||||||
|
system_prompt: Optional[str] = None,
|
||||||
|
model: Optional[str] = None,
|
||||||
|
max_tokens: int = 500,
|
||||||
|
max_tool_rounds: int = 3,
|
||||||
|
category: str = "unknown",
|
||||||
|
caller_name: str = "",
|
||||||
|
) -> tuple[str, list[dict]]:
|
||||||
|
"""Generate a response with OpenRouter function calling.
|
||||||
|
|
||||||
# Try primary model first
|
Args:
|
||||||
result = await self._call_openrouter_once(messages, self.openrouter_model, max_tokens=max_tokens)
|
messages: Conversation messages
|
||||||
|
tools: Tool definitions in OpenAI function-calling format
|
||||||
|
tool_executor: async function(tool_name, arguments) -> result string
|
||||||
|
system_prompt: Optional system prompt
|
||||||
|
model: Model to use (defaults to primary openrouter_model)
|
||||||
|
max_tokens: Max tokens for response
|
||||||
|
max_tool_rounds: Max tool call rounds to prevent loops
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(final_text, tool_calls_made) where tool_calls_made is a list of
|
||||||
|
{"name": str, "arguments": dict, "result": str} dicts
|
||||||
|
"""
|
||||||
|
model = model or self._get_model_for_category(category)
|
||||||
|
msgs = list(messages)
|
||||||
|
if system_prompt:
|
||||||
|
msgs = [{"role": "system", "content": system_prompt}] + msgs
|
||||||
|
|
||||||
|
all_tool_calls = []
|
||||||
|
|
||||||
|
for round_num in range(max_tool_rounds + 1):
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": msgs,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"temperature": 0.65,
|
||||||
|
"tools": tools,
|
||||||
|
"tool_choice": "auto",
|
||||||
|
}
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
response = await self.client.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
print(f"[LLM-Tools] {model} timed out (round {round_num})")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLM-Tools] {model} error (round {round_num}): {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
latency_ms = (time.time() - start_time) * 1000
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
if usage:
|
||||||
|
cost_tracker.record_llm_call(
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
usage_data=usage,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
caller_name=caller_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
choice = data["choices"][0]
|
||||||
|
msg = choice["message"]
|
||||||
|
|
||||||
|
# Check for tool calls
|
||||||
|
tool_calls = msg.get("tool_calls")
|
||||||
|
if not tool_calls:
|
||||||
|
# No tool calls — LLM returned a final text response
|
||||||
|
content = msg.get("content", "")
|
||||||
|
return content or "", all_tool_calls
|
||||||
|
|
||||||
|
# Append assistant message with tool calls to conversation
|
||||||
|
msgs.append(msg)
|
||||||
|
|
||||||
|
# Execute each tool call
|
||||||
|
for tc in tool_calls:
|
||||||
|
func = tc["function"]
|
||||||
|
tool_name = func["name"]
|
||||||
|
try:
|
||||||
|
arguments = json.loads(func["arguments"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
arguments = {}
|
||||||
|
|
||||||
|
print(f"[LLM-Tools] Round {round_num}: calling {tool_name}({arguments})")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await tool_executor(tool_name, arguments)
|
||||||
|
except Exception as e:
|
||||||
|
result = f"Tool unavailable — could not complete {tool_name} right now."
|
||||||
|
print(f"[LLM-Tools] Tool {tool_name} failed: {e}")
|
||||||
|
|
||||||
|
all_tool_calls.append({
|
||||||
|
"name": tool_name,
|
||||||
|
"arguments": arguments,
|
||||||
|
"result": result[:500],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Append tool result to conversation
|
||||||
|
msgs.append({
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tc["id"],
|
||||||
|
"content": result,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Exhausted tool rounds or hit an error — do one final call without tools
|
||||||
|
print(f"[LLM-Tools] Finishing after {len(all_tool_calls)} tool calls")
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
final_payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": msgs,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"temperature": 0.65,
|
||||||
|
}
|
||||||
|
response = await self.client.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=final_payload,
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
latency_ms = (time.time() - start_time) * 1000
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
if usage:
|
||||||
|
cost_tracker.record_llm_call(
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
usage_data=usage,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
caller_name=caller_name,
|
||||||
|
)
|
||||||
|
content = data["choices"][0]["message"].get("content", "")
|
||||||
|
return content or "", all_tool_calls
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLM-Tools] Final call failed: {e}")
|
||||||
|
return "", all_tool_calls
|
||||||
|
|
||||||
|
def _get_model_for_category(self, category: str) -> str:
|
||||||
|
"""Get the best model for a given category based on config routing."""
|
||||||
|
return settings.category_models.get(category, self.openrouter_model)
|
||||||
|
|
||||||
|
async def _call_openrouter_with_fallback(self, messages: list[dict], max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "", model_override: Optional[str] = None) -> str:
|
||||||
|
"""Try category-specific model, then fallback models. Always returns a response."""
|
||||||
|
|
||||||
|
# Use explicit override if provided, else category routing, else primary
|
||||||
|
model = model_override or self._get_model_for_category(category)
|
||||||
|
result = await self._call_openrouter_once(messages, model, max_tokens=max_tokens, response_format=response_format, category=category, caller_name=caller_name)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Try fallback models
|
# Try fallback models (drop response_format for fallbacks — not all models support it)
|
||||||
for model in FALLBACK_MODELS:
|
for model in FALLBACK_MODELS:
|
||||||
if model == self.openrouter_model:
|
if model == self.openrouter_model:
|
||||||
continue # Already tried
|
continue # Already tried
|
||||||
print(f"[LLM] Falling back to {model}...")
|
print(f"[LLM] Falling back to {model}...")
|
||||||
result = await self._call_openrouter_once(messages, model, timeout=10.0, max_tokens=max_tokens)
|
result = await self._call_openrouter_once(messages, model, timeout=20.0, max_tokens=max_tokens, category=category, caller_name=caller_name)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -143,28 +324,64 @@ class LLMService:
|
|||||||
print("[LLM] All models failed, using canned response")
|
print("[LLM] All models failed, using canned response")
|
||||||
return "Sorry, I totally blanked out for a second. What were you saying?"
|
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:
|
# Per-model parameter overrides for caller_dialog category.
|
||||||
|
# Different models need different tuning for natural conversation:
|
||||||
|
# - Qwen: high freq penalty to fight phrase-level repetition loops
|
||||||
|
# - Llama: high temp + low freq penalty to reduce terseness
|
||||||
|
# - Grok/Mistral/DeepSeek/Kimi: slightly warmer than Sonnet defaults
|
||||||
|
_CALLER_DIALOG_MODEL_PARAMS = {
|
||||||
|
"anthropic/claude-sonnet-4.6": {"temperature": 0.65, "frequency_penalty": 0.3, "presence_penalty": 0.15},
|
||||||
|
"x-ai/grok-4.3": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||||
|
"x-ai/grok-4.5": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||||
|
"qwen/qwen3-235b-a22b": {"temperature": 0.6, "frequency_penalty": 0.5, "presence_penalty": 0.2},
|
||||||
|
"mistralai/mistral-large-2512": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||||
|
"deepseek/deepseek-chat-v3-0324": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||||
|
"moonshotai/kimi-k2": {"temperature": 0.7, "frequency_penalty": 0.2, "presence_penalty": 0.1},
|
||||||
|
"meta-llama/llama-3.3-70b-instruct": {"temperature": 0.8, "frequency_penalty": 0.1, "presence_penalty": 0.1},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _call_openrouter_once(self, messages: list[dict], model: str, timeout: float = 20.0, max_tokens: Optional[int] = None, response_format: Optional[dict] = None, category: str = "unknown", caller_name: str = "") -> str | None:
|
||||||
"""Single attempt to call OpenRouter. Returns None on failure (not a fallback string)."""
|
"""Single attempt to call OpenRouter. Returns None on failure (not a fallback string)."""
|
||||||
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
|
# Use per-model params for caller dialog, defaults for everything else
|
||||||
|
if category == "caller_dialog" and model in self._CALLER_DIALOG_MODEL_PARAMS:
|
||||||
|
params = self._CALLER_DIALOG_MODEL_PARAMS[model]
|
||||||
|
else:
|
||||||
|
params = {"temperature": 0.65, "frequency_penalty": 0.3, "presence_penalty": 0.15}
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"max_tokens": max_tokens or 500,
|
||||||
|
"temperature": params["temperature"],
|
||||||
|
"top_p": 0.9,
|
||||||
|
"frequency_penalty": params["frequency_penalty"],
|
||||||
|
"presence_penalty": params["presence_penalty"],
|
||||||
|
}
|
||||||
|
if response_format:
|
||||||
|
payload["response_format"] = response_format
|
||||||
response = await self.client.post(
|
response = await self.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=payload,
|
||||||
"model": model,
|
|
||||||
"messages": messages,
|
|
||||||
"max_tokens": max_tokens or 300,
|
|
||||||
"temperature": 0.8,
|
|
||||||
"top_p": 0.92,
|
|
||||||
"frequency_penalty": 0.5,
|
|
||||||
"presence_penalty": 0.3,
|
|
||||||
},
|
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
latency_ms = (time.time() - start_time) * 1000
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
if usage:
|
||||||
|
cost_tracker.record_llm_call(
|
||||||
|
category=category,
|
||||||
|
model=model,
|
||||||
|
usage_data=usage,
|
||||||
|
max_tokens=max_tokens or 500,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
caller_name=caller_name,
|
||||||
|
)
|
||||||
content = data["choices"][0]["message"]["content"]
|
content = data["choices"][0]["message"]["content"]
|
||||||
if content and content.strip():
|
if content and content.strip():
|
||||||
return content
|
return content
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
SEARXNG_URL = "http://localhost:8888"
|
from ..config import settings
|
||||||
|
|
||||||
|
SEARXNG_URL = settings.searxng_url
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "regulars.json"
|
DATA_FILE = Path(__file__).parent.parent.parent / "data" / "regulars.json"
|
||||||
MAX_REGULARS = 12
|
MAX_REGULARS = 8
|
||||||
|
|
||||||
|
|
||||||
class RegularCallerService:
|
class RegularCallerService:
|
||||||
@@ -39,6 +39,16 @@ class RegularCallerService:
|
|||||||
def get_regulars(self) -> list[dict]:
|
def get_regulars(self) -> list[dict]:
|
||||||
return list(self._regulars)
|
return list(self._regulars)
|
||||||
|
|
||||||
|
def get_by_name(self, name: str) -> Optional[dict]:
|
||||||
|
"""Find a regular by name (case-insensitive)."""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
target = name.lower()
|
||||||
|
for r in self._regulars:
|
||||||
|
if r.get("name", "").lower() == target:
|
||||||
|
return r
|
||||||
|
return None
|
||||||
|
|
||||||
def get_returning_callers(self, count: int = 2) -> list[dict]:
|
def get_returning_callers(self, count: int = 2) -> list[dict]:
|
||||||
"""Get up to `count` regulars for returning caller slots"""
|
"""Get up to `count` regulars for returning caller slots"""
|
||||||
import random
|
import random
|
||||||
@@ -51,7 +61,10 @@ class RegularCallerService:
|
|||||||
|
|
||||||
def add_regular(self, name: str, gender: str, age: int, job: str,
|
def add_regular(self, name: str, gender: str, age: int, job: str,
|
||||||
location: str, personality_traits: list[str],
|
location: str, personality_traits: list[str],
|
||||||
first_call_summary: str, voice: str = None) -> dict:
|
first_call_summary: str, voice: str = None,
|
||||||
|
stable_seeds: dict = None,
|
||||||
|
structured_background: dict = None,
|
||||||
|
avatar: str = None) -> dict:
|
||||||
"""Promote a first-time caller to regular"""
|
"""Promote a first-time caller to regular"""
|
||||||
# Retire oldest if at cap
|
# Retire oldest if at cap
|
||||||
if len(self._regulars) >= MAX_REGULARS:
|
if len(self._regulars) >= MAX_REGULARS:
|
||||||
@@ -68,8 +81,13 @@ class RegularCallerService:
|
|||||||
"location": location,
|
"location": location,
|
||||||
"personality_traits": personality_traits,
|
"personality_traits": personality_traits,
|
||||||
"voice": voice,
|
"voice": voice,
|
||||||
|
"stable_seeds": stable_seeds or {},
|
||||||
|
"structured_background": structured_background,
|
||||||
|
"avatar": avatar,
|
||||||
|
"relationships": {},
|
||||||
"call_history": [
|
"call_history": [
|
||||||
{"summary": first_call_summary, "timestamp": time.time()}
|
{"summary": first_call_summary, "timestamp": time.time(),
|
||||||
|
"arc_status": "ongoing"}
|
||||||
],
|
],
|
||||||
"last_call": time.time(),
|
"last_call": time.time(),
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
@@ -79,18 +97,37 @@ class RegularCallerService:
|
|||||||
print(f"[Regulars] Promoted {name} to regular (total: {len(self._regulars)})")
|
print(f"[Regulars] Promoted {name} to regular (total: {len(self._regulars)})")
|
||||||
return regular
|
return regular
|
||||||
|
|
||||||
def update_after_call(self, regular_id: str, call_summary: str):
|
def update_after_call(self, regular_id: str, call_summary: str,
|
||||||
|
key_moments: list = None, arc_status: str = "ongoing"):
|
||||||
"""Update a regular's history after a returning call"""
|
"""Update a regular's history after a returning call"""
|
||||||
for regular in self._regulars:
|
for regular in self._regulars:
|
||||||
if regular["id"] == regular_id:
|
if regular["id"] == regular_id:
|
||||||
regular.setdefault("call_history", []).append(
|
entry = {
|
||||||
{"summary": call_summary, "timestamp": time.time()}
|
"summary": call_summary,
|
||||||
)
|
"timestamp": time.time(),
|
||||||
|
"arc_status": arc_status,
|
||||||
|
}
|
||||||
|
if key_moments:
|
||||||
|
entry["key_moments"] = key_moments
|
||||||
|
regular.setdefault("call_history", []).append(entry)
|
||||||
regular["last_call"] = time.time()
|
regular["last_call"] = time.time()
|
||||||
self._save()
|
self._save()
|
||||||
print(f"[Regulars] Updated {regular['name']} call history ({len(regular['call_history'])} calls)")
|
print(f"[Regulars] Updated {regular['name']} call history ({len(regular['call_history'])} calls)")
|
||||||
return
|
return
|
||||||
print(f"[Regulars] Regular {regular_id} not found for update")
|
print(f"[Regulars] Regular {regular_id} not found for update")
|
||||||
|
|
||||||
|
def add_relationship(self, regular_id: str, other_name: str,
|
||||||
|
rel_type: str, context: str):
|
||||||
|
"""Track a relationship between regulars"""
|
||||||
|
for regular in self._regulars:
|
||||||
|
if regular["id"] == regular_id:
|
||||||
|
regular.setdefault("relationships", {})[other_name] = {
|
||||||
|
"type": rel_type,
|
||||||
|
"context": context,
|
||||||
|
}
|
||||||
|
self._save()
|
||||||
|
print(f"[Regulars] {regular['name']} → {other_name}: {rel_type}")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
regular_caller_service = RegularCallerService()
|
regular_caller_service = RegularCallerService()
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from .cost_tracker import cost_tracker
|
||||||
|
|
||||||
|
HOME = Path.home()
|
||||||
|
VAULT = HOME / "code" / "dotfiles"
|
||||||
|
SILAS_DIR = VAULT / "silas"
|
||||||
|
REGULARS_DIR = VAULT / "regulars"
|
||||||
|
ARCHIVED_DIR = REGULARS_DIR / "archived"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Regular:
|
||||||
|
name: str
|
||||||
|
voice: str
|
||||||
|
age: int
|
||||||
|
arc_state: str
|
||||||
|
lore_body: str
|
||||||
|
file_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_regular(path: Path) -> Regular:
|
||||||
|
text = path.read_text()
|
||||||
|
m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"No frontmatter in {path}")
|
||||||
|
fm_raw, body = m.group(1), m.group(2).strip()
|
||||||
|
fm = {}
|
||||||
|
for line in fm_raw.splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
k, v = line.split(":", 1)
|
||||||
|
fm[k.strip()] = v.strip()
|
||||||
|
return Regular(
|
||||||
|
name=fm["name"],
|
||||||
|
voice=fm["voice"],
|
||||||
|
age=int(fm["age"]),
|
||||||
|
arc_state=fm.get("arc_state", ""),
|
||||||
|
lore_body=body,
|
||||||
|
file_path=path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_all_active_regulars() -> list[Regular]:
|
||||||
|
out = []
|
||||||
|
if SILAS_DIR.exists():
|
||||||
|
for f in SILAS_DIR.glob("*.md"):
|
||||||
|
out.append(load_regular(f))
|
||||||
|
if REGULARS_DIR.exists():
|
||||||
|
for f in REGULARS_DIR.glob("*.md"):
|
||||||
|
out.append(load_regular(f))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
PROMOTION_MODEL = "anthropic/claude-sonnet-4.6"
|
||||||
|
|
||||||
|
PROMOTION_PROMPT = """You are evaluating whether a one-time caller should become a recurring character.
|
||||||
|
|
||||||
|
CALLER: {name}
|
||||||
|
TRANSCRIPT:
|
||||||
|
{transcript}
|
||||||
|
|
||||||
|
A recurring character must have a 3-5 episode arc with genuine progression — not just "calls weekly to complain about the same thing." The arc must have a possible resolution.
|
||||||
|
|
||||||
|
Output JSON:
|
||||||
|
{{"promote": true|false, "arc_plan": "...", "reason": "..."}}
|
||||||
|
|
||||||
|
Bar is HIGH. Only promote if the character has real internal conflict, growth potential, and a believable resolution trajectory."""
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_sonnet(prompt: str) -> dict:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
|
||||||
|
json={
|
||||||
|
"model": PROMOTION_MODEL,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"response_format": {"type": "json_object"},
|
||||||
|
"max_tokens": 500,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
cost_tracker.record_llm_call(
|
||||||
|
category="promotion_eval",
|
||||||
|
model=PROMOTION_MODEL,
|
||||||
|
usage_data=usage,
|
||||||
|
)
|
||||||
|
content = data["choices"][0]["message"]["content"].strip()
|
||||||
|
if content.startswith("```") and content.endswith("```"):
|
||||||
|
lines = content.splitlines()
|
||||||
|
content = "\n".join(lines[1:-1])
|
||||||
|
return json.loads(content)
|
||||||
|
|
||||||
|
|
||||||
|
async def evaluate_promotion(caller_name: str, call_transcript: str) -> dict:
|
||||||
|
prompt = PROMOTION_PROMPT.format(name=caller_name, transcript=call_transcript)
|
||||||
|
return await _call_sonnet(prompt)
|
||||||
|
|
||||||
|
|
||||||
|
def write_new_regular(name: str, voice: str, age: int, identity_paragraph: str,
|
||||||
|
arc_plan: str, first_call_summary: str) -> Path:
|
||||||
|
REGULARS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
slug = name.lower().replace(" ", "-")
|
||||||
|
path = REGULARS_DIR / f"{slug}.md"
|
||||||
|
today = date.today().isoformat()
|
||||||
|
content = f"""---
|
||||||
|
name: {name}
|
||||||
|
voice: {voice}
|
||||||
|
age: {age}
|
||||||
|
arc_state: {arc_plan}
|
||||||
|
promoted_on: {today}
|
||||||
|
---
|
||||||
|
|
||||||
|
# {name}
|
||||||
|
|
||||||
|
{identity_paragraph}
|
||||||
|
|
||||||
|
## Arc Plan
|
||||||
|
|
||||||
|
{arc_plan}
|
||||||
|
|
||||||
|
## Arc Log
|
||||||
|
|
||||||
|
- {today}: {first_call_summary}
|
||||||
|
"""
|
||||||
|
path.write_text(content)
|
||||||
|
return path
|
||||||
@@ -7,7 +7,7 @@ import soundfile as sf
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
|
STEM_NAMES = ["host", "caller", "devon", "music", "sfx", "ads", "idents"]
|
||||||
|
|
||||||
|
|
||||||
class StemRecorder:
|
class StemRecorder:
|
||||||
@@ -19,13 +19,15 @@ class StemRecorder:
|
|||||||
self._queues: dict[str, deque] = {}
|
self._queues: dict[str, deque] = {}
|
||||||
self._writer_thread: threading.Thread | None = None
|
self._writer_thread: threading.Thread | None = None
|
||||||
self._start_time: float = 0.0
|
self._start_time: float = 0.0
|
||||||
|
self._write_errors: int = 0
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self._start_time = time.time()
|
self._start_time = time.time()
|
||||||
self._running = True
|
self._running = True
|
||||||
|
self._write_errors = 0
|
||||||
for name in STEM_NAMES:
|
for name in STEM_NAMES:
|
||||||
self._queues[name] = deque()
|
self._queues[name] = deque()
|
||||||
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=True)
|
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=False)
|
||||||
self._writer_thread.start()
|
self._writer_thread.start()
|
||||||
print(f"[StemRecorder] Recording started -> {self.output_dir}")
|
print(f"[StemRecorder] Recording started -> {self.output_dir}")
|
||||||
|
|
||||||
@@ -67,39 +69,57 @@ class StemRecorder:
|
|||||||
)
|
)
|
||||||
positions[name] = 0
|
positions[name] = 0
|
||||||
|
|
||||||
while self._running or any(len(q) > 0 for q in self._queues.values()):
|
try:
|
||||||
did_work = False
|
while self._running or any(len(q) > 0 for q in self._queues.values()):
|
||||||
|
did_work = False
|
||||||
|
for name in STEM_NAMES:
|
||||||
|
q = self._queues[name]
|
||||||
|
while q:
|
||||||
|
did_work = True
|
||||||
|
msg_type, audio_data, source_sr = q.popleft()
|
||||||
|
resampled = self._resample(audio_data, source_sr)
|
||||||
|
if len(resampled) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if msg_type == "sporadic":
|
||||||
|
elapsed = time.time() - self._start_time
|
||||||
|
expected_pos = int(elapsed * self.sample_rate)
|
||||||
|
if expected_pos > positions[name]:
|
||||||
|
gap = expected_pos - positions[name]
|
||||||
|
files[name].write(np.zeros(gap, dtype=np.float32))
|
||||||
|
positions[name] = expected_pos
|
||||||
|
|
||||||
|
files[name].write(resampled)
|
||||||
|
positions[name] += len(resampled)
|
||||||
|
except Exception as e:
|
||||||
|
self._write_errors += 1
|
||||||
|
if self._write_errors <= 5:
|
||||||
|
print(f"[StemRecorder] Write error on {name}: {e}")
|
||||||
|
elif self._write_errors == 6:
|
||||||
|
print(f"[StemRecorder] Suppressing further write errors")
|
||||||
|
|
||||||
|
if not did_work:
|
||||||
|
time.sleep(0.02)
|
||||||
|
|
||||||
|
# Pad all stems to same length
|
||||||
|
max_pos = max(positions.values()) if positions else 0
|
||||||
for name in STEM_NAMES:
|
for name in STEM_NAMES:
|
||||||
q = self._queues[name]
|
try:
|
||||||
while q:
|
if positions[name] < max_pos:
|
||||||
did_work = True
|
files[name].write(np.zeros(max_pos - positions[name], dtype=np.float32))
|
||||||
msg_type, audio_data, source_sr = q.popleft()
|
except Exception as e:
|
||||||
resampled = self._resample(audio_data, source_sr)
|
print(f"[StemRecorder] Final pad error on {name}: {e}")
|
||||||
if len(resampled) == 0:
|
finally:
|
||||||
continue
|
for name, f in files.items():
|
||||||
|
try:
|
||||||
|
f.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StemRecorder] Error closing {name}.wav: {e}")
|
||||||
|
|
||||||
if msg_type == "sporadic":
|
total_errors = self._write_errors
|
||||||
elapsed = time.time() - self._start_time
|
err_msg = f", {total_errors} write errors" if total_errors else ""
|
||||||
expected_pos = int(elapsed * self.sample_rate)
|
print(f"[StemRecorder] Writer done. {max_pos} samples ({max_pos / self.sample_rate:.1f}s{err_msg})")
|
||||||
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]:
|
def stop(self) -> dict[str, str]:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
@@ -107,7 +127,9 @@ class StemRecorder:
|
|||||||
|
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._writer_thread:
|
if self._writer_thread:
|
||||||
self._writer_thread.join(timeout=10.0)
|
self._writer_thread.join(timeout=30.0)
|
||||||
|
if self._writer_thread.is_alive():
|
||||||
|
print("[StemRecorder] Warning: writer thread still running after 30s")
|
||||||
self._writer_thread = None
|
self._writer_thread = None
|
||||||
|
|
||||||
paths = {}
|
paths = {}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import numpy as np
|
|||||||
from faster_whisper import WhisperModel
|
from faster_whisper import WhisperModel
|
||||||
import librosa
|
import librosa
|
||||||
|
|
||||||
|
WHISPER_MODEL = "distil-large-v3"
|
||||||
|
|
||||||
# Global model instance (loaded once)
|
# Global model instance (loaded once)
|
||||||
_whisper_model = None
|
_whisper_model = None
|
||||||
|
|
||||||
@@ -13,10 +15,8 @@ def get_whisper_model() -> WhisperModel:
|
|||||||
"""Get or create Whisper model instance"""
|
"""Get or create Whisper model instance"""
|
||||||
global _whisper_model
|
global _whisper_model
|
||||||
if _whisper_model is None:
|
if _whisper_model is None:
|
||||||
print("Loading Whisper tiny model for fast transcription...")
|
print(f"Loading Whisper {WHISPER_MODEL} model...")
|
||||||
# Use tiny model for speed - about 3-4x faster than base
|
_whisper_model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
|
||||||
# beam_size=1 and best_of=1 for fastest inference
|
|
||||||
_whisper_model = WhisperModel("tiny", device="cpu", compute_type="int8")
|
|
||||||
print("Whisper model loaded")
|
print("Whisper model loaded")
|
||||||
return _whisper_model
|
return _whisper_model
|
||||||
|
|
||||||
@@ -67,13 +67,15 @@ def decode_audio(audio_data: bytes, source_sample_rate: int = None) -> tuple[np.
|
|||||||
return audio, 16000
|
return audio, 16000
|
||||||
|
|
||||||
|
|
||||||
async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None) -> str:
|
async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None,
|
||||||
|
context_hint: str = "") -> str:
|
||||||
"""
|
"""
|
||||||
Transcribe audio data to text using Whisper.
|
Transcribe audio data to text using Whisper.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
audio_data: Audio bytes (webm, ogg, wav, or raw PCM)
|
audio_data: Audio bytes (webm, ogg, wav, or raw PCM)
|
||||||
source_sample_rate: If provided, treat audio_data as raw PCM at this rate
|
source_sample_rate: If provided, treat audio_data as raw PCM at this rate
|
||||||
|
context_hint: Optional extra context for the initial prompt (e.g. caller name/topic)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Transcribed text
|
Transcribed text
|
||||||
@@ -100,13 +102,18 @@ async def transcribe_audio(audio_data: bytes, source_sample_rate: int = None) ->
|
|||||||
else:
|
else:
|
||||||
audio_16k = audio
|
audio_16k = audio
|
||||||
|
|
||||||
# Transcribe with speed optimizations
|
# Build initial prompt — context helps Whisper with names and topic-specific words
|
||||||
|
initial_prompt = "Luke at the Roost, a late-night radio talk show in Alpine, Texas, in the Big Bend region. The host Luke talks to callers about life, relationships, sports, politics, and pop culture, with his intern Devon. Callers reference Alpine, Marfa, Marathon, Terlingua, Fort Stockton, and Big Bend."
|
||||||
|
if context_hint:
|
||||||
|
initial_prompt += f" {context_hint}"
|
||||||
|
|
||||||
|
# Transcribe
|
||||||
segments, info = model.transcribe(
|
segments, info = model.transcribe(
|
||||||
audio_16k,
|
audio_16k,
|
||||||
beam_size=1, # Faster, slightly less accurate
|
beam_size=5,
|
||||||
best_of=1,
|
language="en",
|
||||||
language="en", # Skip language detection
|
vad_filter=True,
|
||||||
vad_filter=True, # Skip silence
|
initial_prompt=initial_prompt,
|
||||||
)
|
)
|
||||||
segments_list = list(segments)
|
segments_list = list(segments)
|
||||||
text = " ".join([s.text for s in segments_list]).strip()
|
text = " ".join([s.text for s in segments_list]).strip()
|
||||||
|
|||||||
+265
-33
@@ -8,6 +8,7 @@ import tempfile
|
|||||||
import torch
|
import torch
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
from .cost_tracker import cost_tracker
|
||||||
|
|
||||||
# Patch torch.load for compatibility with PyTorch 2.6+
|
# Patch torch.load for compatibility with PyTorch 2.6+
|
||||||
_original_torch_load = torch.load
|
_original_torch_load = torch.load
|
||||||
@@ -82,9 +83,18 @@ VITS_SPEAKERS = {
|
|||||||
DEFAULT_VITS_SPEAKER = "p225"
|
DEFAULT_VITS_SPEAKER = "p225"
|
||||||
|
|
||||||
# Inworld voice mapping - maps ElevenLabs voice IDs to Inworld voices
|
# Inworld voice mapping - maps ElevenLabs voice IDs to Inworld voices
|
||||||
# Full voice list from API: Alex, Ashley, Blake, Carter, Clive, Craig, Deborah,
|
# Full voice list from API (English, as of 2026-05): Abby, Alex, Amina, Anjali,
|
||||||
# Dennis, Dominus, Edward, Elizabeth, Hades, Hana, Julia, Luna, Mark, Olivia,
|
# Arjun, Ashley, Avery, Bianca, Blake, Brandon, Brian, Callum, Carter, Cedric,
|
||||||
# Pixie, Priya, Ronald, Sarah, Shaun, Theodore, Timothy, Wendy
|
# Celeste, Chloe, Claire, Clive, Conrad, Craig, Damon, Darlene, Deborah, Dennis,
|
||||||
|
# Derek, Dominus, Duncan, Edward, Eleanor, Elizabeth, Elliot, Ethan, Evan,
|
||||||
|
# Evelyn, Felix, Gareth, Graham, Grant, Hades, Hamish, Hana, Hank, Jake, James,
|
||||||
|
# Jason, Jessica, Jonah, Julia, Kayla, Kelsey, Lauren, Levi, Liam, Loretta,
|
||||||
|
# Lucian, Luna, Malcolm, Marcus, Mark, Marlene, Mia, Miranda, Mortimer, Nadia,
|
||||||
|
# Naomi, Nate, Oliver, Olivia, Pippa, Pixie, Priya, Reed, Riley, Ronald, Rupert,
|
||||||
|
# Saanvi, Sarah, Sebastian, Selene, Serena, Shaun, Simon, Snik, Sophie, Tessa,
|
||||||
|
# Theodore, Timothy, Trevor, Tristan, Tyler, Veronica, Victor, Victoria, Vinny,
|
||||||
|
# Wendy. Not in our caller pool: Abby/Mia/Pixie/Riley (child voices), Dominus/
|
||||||
|
# Lucian/Selene/Snik (theatrical), Dominus/Hades blacklisted.
|
||||||
INWORLD_VOICES = {
|
INWORLD_VOICES = {
|
||||||
# Original voice IDs
|
# Original voice IDs
|
||||||
"VR6AewLTigWG4xSOukaG": "Edward", # Tony - fast-talking, emphatic, streetwise
|
"VR6AewLTigWG4xSOukaG": "Edward", # Tony - fast-talking, emphatic, streetwise
|
||||||
@@ -111,6 +121,126 @@ INWORLD_VOICES = {
|
|||||||
}
|
}
|
||||||
DEFAULT_INWORLD_VOICE = "Dennis"
|
DEFAULT_INWORLD_VOICE = "Dennis"
|
||||||
|
|
||||||
|
# Inworld voices that speak too slowly at default rate — bump them up
|
||||||
|
# Range is 0.5 to 1.5, where 1.0 is the voice's native speed
|
||||||
|
INWORLD_SPEED_OVERRIDES = {
|
||||||
|
"Wendy": 1.15,
|
||||||
|
"Craig": 1.15,
|
||||||
|
"Deborah": 1.15,
|
||||||
|
"Sarah": 1.1,
|
||||||
|
"Hana": 1.1,
|
||||||
|
"Theodore": 1.15,
|
||||||
|
"Blake": 1.1,
|
||||||
|
"Priya": 1.1,
|
||||||
|
"Graham": 1.15,
|
||||||
|
"Malcolm": 1.15,
|
||||||
|
"Victoria": 1.1,
|
||||||
|
"Loretta": 1.1,
|
||||||
|
"Marlene": 1.1,
|
||||||
|
}
|
||||||
|
DEFAULT_INWORLD_SPEED = 1.1 # Slight bump for all voices
|
||||||
|
|
||||||
|
# Voice profiles — perceptual dimensions for each Inworld voice.
|
||||||
|
# Used by style-to-voice matching to pair caller personalities with fitting voices.
|
||||||
|
# weight: vocal depth/richness (light, medium, heavy)
|
||||||
|
# energy: default speaking animation (low, medium, high)
|
||||||
|
# warmth: friendliness/openness in the voice (cool, neutral, warm)
|
||||||
|
# age_feel: perceived speaker age (young, middle, mature)
|
||||||
|
VOICE_PROFILES = {
|
||||||
|
# --- Male voices ---
|
||||||
|
# Known characterizations from INWORLD_VOICES mapping and usage
|
||||||
|
"Alex": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # energetic, expressive, mildly nasal
|
||||||
|
"Edward": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, emphatic, streetwise
|
||||||
|
"Shaun": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # friendly, dynamic, conversational
|
||||||
|
"Craig": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # older British, refined, articulate
|
||||||
|
"Timothy": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # lively, upbeat American
|
||||||
|
"Dennis": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"}, # energetic, default voice
|
||||||
|
"Ronald": {"weight": "heavy", "energy": "medium", "warmth": "neutral", "age_feel": "mature"}, # gruff, authoritative
|
||||||
|
"Theodore": {"weight": "heavy", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # slow, deliberate
|
||||||
|
"Blake": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Carter": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Clive": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Mark": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Sebastian": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"}, # used by Silas (cult leader) & Chip
|
||||||
|
"Elliot": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"}, # used by Otis (comedian)
|
||||||
|
# Remaining male pool voices
|
||||||
|
"Arjun": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Avery": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # youthful, performative, gameshow host
|
||||||
|
"Brandon": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # bold, strident, news-style
|
||||||
|
"Brian": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Callum": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "young"},
|
||||||
|
"Cedric": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # crisp, measured, formal announcements
|
||||||
|
"Conrad": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # gruff, weathered detective
|
||||||
|
"Damon": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "middle"}, # calm, raspy, atmospheric
|
||||||
|
"Derek": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Duncan": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, articulate British
|
||||||
|
"Ethan": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Evan": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"},
|
||||||
|
"Felix": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # calm, friendly British
|
||||||
|
"Gareth": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Graham": {"weight": "heavy", "energy": "low", "warmth": "neutral", "age_feel": "mature"},
|
||||||
|
"Grant": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Hades": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Hamish": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Hank": {"weight": "heavy", "energy": "medium", "warmth": "warm", "age_feel": "mature"},
|
||||||
|
"Jake": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"James": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Jason": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Jonah": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "middle"}, # soothing, calm, reassuring
|
||||||
|
"Levi": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "middle"}, # measured, ominous suspense
|
||||||
|
"Liam": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Malcolm": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Marcus": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # authoritative, empathetic
|
||||||
|
"Mortimer": {"weight": "heavy", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Nate": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Oliver": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Reed": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, # clear, professional American
|
||||||
|
"Rupert": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Simon": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Trevor": {"weight": "medium", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # punchy, expressive, energetic promos
|
||||||
|
"Tristan": {"weight": "medium", "energy": "low", "warmth": "neutral", "age_feel": "middle"}, # deliberate, controlled, documentary
|
||||||
|
"Tyler": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "young"},
|
||||||
|
"Victor": {"weight": "heavy", "energy": "medium", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Vinny": {"weight": "medium", "energy": "high", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
# --- Female voices ---
|
||||||
|
# Known characterizations
|
||||||
|
"Hana": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"}, # bright, expressive young
|
||||||
|
"Ashley": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, natural
|
||||||
|
"Wendy": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"}, # posh, middle-aged British
|
||||||
|
"Sarah": {"weight": "light", "energy": "high", "warmth": "neutral", "age_feel": "middle"}, # fast-talking, questioning
|
||||||
|
"Deborah": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"}, # gentle, elegant
|
||||||
|
"Olivia": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Julia": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"}, # used by Angie (deadpan)
|
||||||
|
"Priya": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Amina": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Charlene (bragger)
|
||||||
|
"Tessa": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # used by Lucille
|
||||||
|
"Kelsey": {"weight": "light", "energy": "medium", "warmth": "neutral", "age_feel": "young"}, # used by Maxine (quiet/nervous)
|
||||||
|
# Remaining female pool voices
|
||||||
|
"Anjali": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Bianca": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "middle"}, # deep, controlled corporate
|
||||||
|
"Celeste": {"weight": "light", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||||
|
"Chloe": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Claire": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Darlene": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "mature"},
|
||||||
|
"Eleanor": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # polished, approachable British
|
||||||
|
"Elizabeth": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
"Jessica": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"},
|
||||||
|
"Kayla": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Lauren": {"weight": "medium", "energy": "medium", "warmth": "neutral", "age_feel": "middle"},
|
||||||
|
"Loretta": {"weight": "medium", "energy": "low", "warmth": "warm", "age_feel": "mature"},
|
||||||
|
"Luna": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Marlene": {"weight": "medium", "energy": "low", "warmth": "neutral", "age_feel": "mature"},
|
||||||
|
"Miranda": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||||
|
"Nadia": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # personable, lively
|
||||||
|
"Naomi": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # warm, grounded narrative
|
||||||
|
"Pippa": {"weight": "light", "energy": "high", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Saanvi": {"weight": "light", "energy": "medium", "warmth": "warm", "age_feel": "young"},
|
||||||
|
"Serena": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||||
|
"Sophie": {"weight": "medium", "energy": "medium", "warmth": "warm", "age_feel": "middle"}, # friendly British
|
||||||
|
"Veronica": {"weight": "medium", "energy": "medium", "warmth": "cool", "age_feel": "middle"},
|
||||||
|
"Victoria": {"weight": "medium", "energy": "low", "warmth": "cool", "age_feel": "mature"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def preprocess_text_for_kokoro(text: str) -> str:
|
def preprocess_text_for_kokoro(text: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -581,7 +711,59 @@ async def generate_speech_chattts(text: str, voice_id: str) -> tuple[np.ndarray,
|
|||||||
return audio.astype(np.float32), 24000
|
return audio.astype(np.float32), 24000
|
||||||
|
|
||||||
|
|
||||||
async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray, int]:
|
_EXCITED_KEYWORDS = {"excited", "amazing", "incredible", "can't believe", "so happy",
|
||||||
|
"hell yeah", "fired up", "furious", "pissed", "angry", "what the hell",
|
||||||
|
"are you kidding", "unbelievable", "!!", "oh my god"}
|
||||||
|
_SAD_KEYWORDS = {"sad", "miss them", "passed away", "funeral", "crying", "broke my heart",
|
||||||
|
"can't stop thinking", "lonely", "depressed", "sorry", "regret",
|
||||||
|
"wish I could", "never got to", "lost", "grief"}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_speech_rate(text: str, base_speed: float) -> float:
|
||||||
|
"""Adjust speech rate based on emotional content of the text.
|
||||||
|
Returns a speed value clamped to Inworld's 0.5-1.5 range."""
|
||||||
|
text_lower = text.lower()
|
||||||
|
excited = sum(1 for kw in _EXCITED_KEYWORDS if kw in text_lower)
|
||||||
|
sad = sum(1 for kw in _SAD_KEYWORDS if kw in text_lower)
|
||||||
|
|
||||||
|
if excited >= 2:
|
||||||
|
return min(1.5, base_speed + 0.15)
|
||||||
|
elif excited >= 1:
|
||||||
|
return min(1.5, base_speed + 0.08)
|
||||||
|
elif sad >= 2:
|
||||||
|
return max(0.5, base_speed - 0.2)
|
||||||
|
elif sad >= 1:
|
||||||
|
return max(0.5, base_speed - 0.1)
|
||||||
|
return base_speed
|
||||||
|
|
||||||
|
|
||||||
|
def _emotional_register_to_params(emotional_register: str) -> tuple[float, float]:
|
||||||
|
"""Map caller's emotional_register description to (temperature, speed_adjust).
|
||||||
|
speed_adjust is added to the base speed BEFORE clamping to 0.5-1.5.
|
||||||
|
Returns (0.9, 0.0) default for unknown/empty registers."""
|
||||||
|
if not emotional_register:
|
||||||
|
return (0.9, 0.0)
|
||||||
|
er = emotional_register.lower()
|
||||||
|
# Sadness/grief family
|
||||||
|
if any(kw in er for kw in ["fractured", "grief", "hollow", "sad", "mournful", "somber", "quietly desperate"]):
|
||||||
|
return (0.85, -0.1)
|
||||||
|
# Anger/aggression family
|
||||||
|
if any(kw in er for kw in ["angry", "combative", "furious", "aggressive", "sharp intelligence"]):
|
||||||
|
return (1.0, 0.1)
|
||||||
|
# Manic/excited family
|
||||||
|
if any(kw in er for kw in ["caffeinated", "manic", "giddy", "vibrating", "adrenaline", "frantic", "hyper-articulate"]):
|
||||||
|
return (1.0, 0.15)
|
||||||
|
# Nervous/earnest family
|
||||||
|
if any(kw in er for kw in ["earnest", "nervous", "unsettled", "anxious", "tentative", "quietly unsettled"]):
|
||||||
|
return (0.9, 0.0)
|
||||||
|
# Gruff/restrained family
|
||||||
|
if any(kw in er for kw in ["gruff", "measured", "precise", "stoic", "restrained", "deadpan", "forthright"]):
|
||||||
|
return (0.85, -0.05)
|
||||||
|
# Default
|
||||||
|
return (0.9, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_speech_inworld(text: str, voice_id: str, emotional_register: str = "") -> tuple[np.ndarray, int]:
|
||||||
"""Generate speech using Inworld TTS API (high quality, natural voices)"""
|
"""Generate speech using Inworld TTS API (high quality, natural voices)"""
|
||||||
import httpx
|
import httpx
|
||||||
import base64
|
import base64
|
||||||
@@ -598,7 +780,10 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
|||||||
if not api_key:
|
if not api_key:
|
||||||
raise RuntimeError("INWORLD_API_KEY not set in environment")
|
raise RuntimeError("INWORLD_API_KEY not set in environment")
|
||||||
|
|
||||||
print(f"[Inworld TTS] Voice: {voice}, Text: {text[:50]}...")
|
temperature, speed_adjust = _emotional_register_to_params(emotional_register)
|
||||||
|
base_speed = INWORLD_SPEED_OVERRIDES.get(voice, DEFAULT_INWORLD_SPEED) + speed_adjust
|
||||||
|
speed = max(0.5, min(1.5, _detect_speech_rate(text, base_speed)))
|
||||||
|
print(f"[Inworld TTS] Voice: {voice}, Speed: {speed:.2f} (base {base_speed:.2f}), Temp: {temperature}, Text: {text[:50]}...")
|
||||||
|
|
||||||
url = "https://api.inworld.ai/tts/v1/voice"
|
url = "https://api.inworld.ai/tts/v1/voice"
|
||||||
headers = {
|
headers = {
|
||||||
@@ -607,15 +792,18 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
|||||||
}
|
}
|
||||||
payload = {
|
payload = {
|
||||||
"text": text,
|
"text": text,
|
||||||
"voice_id": voice,
|
"voiceId": voice,
|
||||||
"model_id": "inworld-tts-1.5-max",
|
"modelId": "inworld-tts-2",
|
||||||
"audio_config": {
|
"temperature": temperature,
|
||||||
"encoding": "LINEAR16",
|
"applyTextNormalization": "ON",
|
||||||
"sample_rate_hertz": 48000,
|
"audioConfig": {
|
||||||
|
"audioEncoding": "LINEAR16",
|
||||||
|
"sampleRateHertz": 48000,
|
||||||
|
"speakingRate": speed,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=25.0) as client:
|
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||||
response = await client.post(url, json=payload, headers=headers)
|
response = await client.post(url, json=payload, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -650,47 +838,91 @@ async def generate_speech_inworld(text: str, voice_id: str) -> tuple[np.ndarray,
|
|||||||
return audio.astype(np.float32), 24000
|
return audio.astype(np.float32), 24000
|
||||||
|
|
||||||
|
|
||||||
|
def pick_caller_tts_provider() -> str | None:
|
||||||
|
"""Randomly assign a TTS provider for a caller.
|
||||||
|
Returns None to use the global default, or a specific provider name.
|
||||||
|
~70% inworld (default), ~20% kokoro, ~10% other available."""
|
||||||
|
import random
|
||||||
|
roll = random.random()
|
||||||
|
if roll < 0.70:
|
||||||
|
return None # Use global default (typically inworld)
|
||||||
|
elif roll < 0.90:
|
||||||
|
return "kokoro"
|
||||||
|
else:
|
||||||
|
return random.choice(["kokoro", "f5tts", "chattts"])
|
||||||
|
|
||||||
|
|
||||||
|
_TTS_PROVIDERS = {
|
||||||
|
"kokoro": lambda text, vid, er: generate_speech_kokoro(text, vid),
|
||||||
|
"f5tts": lambda text, vid, er: generate_speech_f5tts(text, vid),
|
||||||
|
"inworld": lambda text, vid, er: generate_speech_inworld(text, vid, emotional_register=er),
|
||||||
|
"chattts": lambda text, vid, er: generate_speech_chattts(text, vid),
|
||||||
|
"styletts2": lambda text, vid, er: generate_speech_styletts2(text, vid),
|
||||||
|
"bark": lambda text, vid, er: generate_speech_bark(text, vid),
|
||||||
|
"vits": lambda text, vid, er: generate_speech_vits(text, vid),
|
||||||
|
"elevenlabs": lambda text, vid, er: generate_speech_elevenlabs(text, vid),
|
||||||
|
}
|
||||||
|
|
||||||
|
TTS_MAX_RETRIES = 2
|
||||||
|
TTS_RETRY_DELAYS = [0.5, 1.0] # seconds between retries
|
||||||
|
|
||||||
|
|
||||||
async def generate_speech(
|
async def generate_speech(
|
||||||
text: str,
|
text: str,
|
||||||
voice_id: str,
|
voice_id: str,
|
||||||
phone_quality: str = "normal",
|
phone_quality: str = "normal",
|
||||||
apply_filter: bool = True
|
apply_filter: bool = True,
|
||||||
|
provider_override: str = None,
|
||||||
|
emotional_register: str = "",
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generate speech from text.
|
Generate speech from text with automatic retry on failure.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Text to speak
|
text: Text to speak
|
||||||
voice_id: ElevenLabs voice ID (mapped to local voice if using local TTS)
|
voice_id: ElevenLabs voice ID (mapped to local voice if using local TTS)
|
||||||
phone_quality: Quality of phone filter ("none" to disable)
|
phone_quality: Quality of phone filter ("none" to disable)
|
||||||
apply_filter: Whether to apply phone filter
|
apply_filter: Whether to apply phone filter
|
||||||
|
provider_override: Override the global TTS provider for this call
|
||||||
|
emotional_register: Caller's emotional register (used by Inworld for temp/speed tuning)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Raw PCM audio bytes (16-bit signed int, 24kHz)
|
Raw PCM audio bytes (16-bit signed int, 24kHz)
|
||||||
"""
|
"""
|
||||||
# Choose TTS provider
|
import asyncio
|
||||||
provider = settings.tts_provider
|
|
||||||
print(f"[TTS] Provider: {provider}, Text: {text[:50]}...")
|
|
||||||
|
|
||||||
if provider == "kokoro":
|
provider = provider_override or settings.tts_provider
|
||||||
audio, sample_rate = await generate_speech_kokoro(text, voice_id)
|
print(f"[TTS] Provider: {provider}{' (override)' if provider_override else ''}, Text: {text[:50]}...")
|
||||||
elif provider == "f5tts":
|
|
||||||
audio, sample_rate = await generate_speech_f5tts(text, voice_id)
|
gen_fn = _TTS_PROVIDERS.get(provider)
|
||||||
elif provider == "inworld":
|
if not gen_fn:
|
||||||
audio, sample_rate = await generate_speech_inworld(text, voice_id)
|
|
||||||
elif provider == "chattts":
|
|
||||||
audio, sample_rate = await generate_speech_chattts(text, voice_id)
|
|
||||||
elif provider == "styletts2":
|
|
||||||
audio, sample_rate = await generate_speech_styletts2(text, voice_id)
|
|
||||||
elif provider == "bark":
|
|
||||||
audio, sample_rate = await generate_speech_bark(text, voice_id)
|
|
||||||
elif provider == "vits":
|
|
||||||
audio, sample_rate = await generate_speech_vits(text, voice_id)
|
|
||||||
elif provider == "elevenlabs":
|
|
||||||
audio, sample_rate = await generate_speech_elevenlabs(text, voice_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unknown TTS provider: {provider}")
|
raise ValueError(f"Unknown TTS provider: {provider}")
|
||||||
|
|
||||||
|
last_error = None
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(20):
|
||||||
|
for attempt in range(TTS_MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
audio, sample_rate = await gen_fn(text, voice_id, emotional_register)
|
||||||
|
cost_tracker.record_tts_call(provider, voice_id, len(text))
|
||||||
|
if attempt > 0:
|
||||||
|
print(f"[TTS] Succeeded on retry {attempt}")
|
||||||
|
break
|
||||||
|
except TimeoutError:
|
||||||
|
raise # Let asyncio.timeout propagate
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
if attempt < TTS_MAX_RETRIES - 1:
|
||||||
|
delay = TTS_RETRY_DELAYS[attempt]
|
||||||
|
print(f"[TTS] {provider} attempt {attempt + 1} failed: {e} — retrying in {delay}s...")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
print(f"[TTS] {provider} failed after {TTS_MAX_RETRIES} attempts: {e}")
|
||||||
|
raise
|
||||||
|
except TimeoutError:
|
||||||
|
print(f"[TTS] Overall timeout (20s) for {provider}")
|
||||||
|
raise RuntimeError(f"TTS generation timed out after 20s")
|
||||||
|
|
||||||
# Apply phone filter if requested
|
# Apply phone filter if requested
|
||||||
# Skip filter for Bark - it already has rough audio quality
|
# Skip filter for Bark - it already has rough audio quality
|
||||||
if apply_filter and phone_quality not in ("none", "studio") and provider != "bark":
|
if apply_filter and phone_quality not in ("none", "studio") and provider != "bark":
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Daily backup of critical AI podcast data to NAS
|
||||||
|
# Backs up: Castopod MariaDB dump, local data/ directory, publish state
|
||||||
|
#
|
||||||
|
# Usage: ./backup.sh
|
||||||
|
# Cron: 0 3 * * * /Users/lukemacneil/code/ai-podcast/backup.sh >> /tmp/ai-podcast-backup.log 2>&1
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
NAS_HOST="mmgnas"
|
||||||
|
NAS_USER="luke"
|
||||||
|
NAS_PORT="8001"
|
||||||
|
DOCKER_BIN="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||||
|
BACKUP_BASE="/share/CACHEDEV1_DATA/backups/ai-podcast"
|
||||||
|
PROJECT_DIR="/Users/lukemacneil/code/ai-podcast"
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
KEEP_DAYS=14
|
||||||
|
|
||||||
|
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Starting backup..."
|
||||||
|
|
||||||
|
# 1. Dump Castopod MariaDB on NAS
|
||||||
|
echo " Dumping MariaDB..."
|
||||||
|
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
|
||||||
|
"$DOCKER_BIN exec castopod-mariadb-1 mysqldump -u castopod --password=\$(cat /run/secrets/db_password 2>/dev/null || echo BYtbFfk3ndeVabb26xb0UyKU) castopod" \
|
||||||
|
> "/tmp/castopod-db-${DATE}.sql" 2>/dev/null
|
||||||
|
|
||||||
|
if [ -s "/tmp/castopod-db-${DATE}.sql" ]; then
|
||||||
|
gzip -f "/tmp/castopod-db-${DATE}.sql"
|
||||||
|
scp -P "$NAS_PORT" "/tmp/castopod-db-${DATE}.sql.gz" \
|
||||||
|
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/castopod-db-${DATE}.sql.gz"
|
||||||
|
rm -f "/tmp/castopod-db-${DATE}.sql.gz"
|
||||||
|
echo " MariaDB dump: OK"
|
||||||
|
else
|
||||||
|
echo " WARNING: MariaDB dump is empty or failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Sync data/ directory to NAS (rsync for efficiency)
|
||||||
|
echo " Syncing data/ directory..."
|
||||||
|
rsync -az --delete \
|
||||||
|
-e "ssh -p $NAS_PORT" \
|
||||||
|
"$PROJECT_DIR/data/" \
|
||||||
|
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/data/"
|
||||||
|
echo " data/ sync: OK"
|
||||||
|
|
||||||
|
# 3. Backup .env (contains API keys — critical for disaster recovery)
|
||||||
|
echo " Backing up .env..."
|
||||||
|
scp -P "$NAS_PORT" "$PROJECT_DIR/.env" \
|
||||||
|
"$NAS_USER@$NAS_HOST:$BACKUP_BASE/env-${DATE}.bak"
|
||||||
|
echo " .env backup: OK"
|
||||||
|
|
||||||
|
# 4. Prune old backups
|
||||||
|
echo " Pruning backups older than ${KEEP_DAYS} days..."
|
||||||
|
ssh -p "$NAS_PORT" "$NAS_USER@$NAS_HOST" \
|
||||||
|
"find $BACKUP_BASE -name 'castopod-db-*.sql.gz' -mtime +${KEEP_DAYS} -delete 2>/dev/null; \
|
||||||
|
find $BACKUP_BASE -name 'env-*.bak' -mtime +${KEEP_DAYS} -delete 2>/dev/null"
|
||||||
|
echo " Prune: OK"
|
||||||
|
|
||||||
|
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Backup complete."
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
{
|
||||||
|
"lineups": [
|
||||||
|
{
|
||||||
|
"timestamp": 1785804183.5018802,
|
||||||
|
"lineup": [
|
||||||
|
{
|
||||||
|
"name": "Silas",
|
||||||
|
"situation": "The Wellspring's first week on the new Terlingua land has hit a concrete crisis: their artisanal soap operation shipped three pallets of inventory to a freight depot in Odessa \u2014 the nearest hub \u2014 and the driver refuses to make the final leg to Terlingua without a paved-road guarantee Silas cannot give. Meanwhile, eleven members stayed behind in Deming, and two of those who did make the move left after three days, citing the heat and the lack of cell service. Tonight a member named Petra \u2014 one of Silas's longest-tenured people, a ceramicist who has been with The Wellspring for six years \u2014 told him she's leaving at sunrise because she can't live somewhere without a postal address. The soap pallets are sitting in Odessa. Petra is sleeping in her car. And Silas, standing on raw desert under a sky so dark he says it looks like a wound in the ceiling, is genuinely uncertain for the first time whether he made the right call moving everyone out here.",
|
||||||
|
"voice": "Sebastian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Darlene",
|
||||||
|
"situation": "Darlene spent thirty-one years handling deed transfers, liens, and title searches for Pecos County. She retired four years ago. Last week her nephew \u2014 who works for a land-acquisition company out of Midland \u2014 let it slip at a family barbecue that his company has been buying up surface rights in a specific forty-square-mile corridor north of Fort Stockton using a web of LLCs with different registered agents so the pattern doesn't show up in any single county search. Darlene knows how to read those records. She spent two days at the courthouse pulling filings and confirmed it: nine separate LLCs, all registered in Delaware, all with the same notary stamp on the original formation docs. She doesn't know what they're positioning for \u2014 pipeline easement, a solar corridor, something else \u2014 but she knows the ranching families selling don't know they're all selling to the same buyer. Her nephew doesn't know she figured it out. She hasn't told anyone.",
|
||||||
|
"voice": "Loretta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Trace",
|
||||||
|
"situation": "Trace has no moral crisis. He is calling because forty minutes ago, while doing his rounds at the Sul Ross livestock barn complex on the east side of campus, he found a full-grown pronghorn antelope standing completely still in the center aisle between the horse stalls. Not injured. Not panicked. Just standing there in the fluorescent light looking at him like it had somewhere to be. He has no idea how it got in \u2014 the barn doors were latched. The horses are losing their minds. The pronghorn is still there. He called animal control and got a voicemail. He called his supervisor and got told to 'use your judgment, son.' He is using the radio.",
|
||||||
|
"voice": "Levi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mireille",
|
||||||
|
"situation": "Mireille has been having an affair for eight months with a married man \u2014 Gil, a Marfa old-timer, third-generation ranching family, the kind of man whose grandfather's name is on a road. She knew it was wrong and she was ending it anyway. Then two weeks ago Gil had a stroke. He is alive, recovering at a hospital in Odessa, but his speech is affected and his wife \u2014 a woman named Sandra who has always been perfectly cold to Mireille at community events \u2014 has moved into full caretaker mode. Gil's adult daughter, who is running the ranch now, came to Mireille's studio last Thursday and handed her a handwritten note. It was in Gil's handwriting, clearly written before the stroke, and it said only: 'If something happens to me, tell Mireille I meant it.' Mireille does not know what he meant. She does not know if Sandra knows about her. She does not know what 'I meant it' refers to \u2014 whether it is a declaration of love, a reference to a specific conversation, or something else entirely. She has been holding this note for a week.",
|
||||||
|
"voice": "Naomi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cutter",
|
||||||
|
"situation": "Cutter has been building and repairing fence lines across Big Bend country his whole adult life \u2014 it's solitary work, days alone on a ranch road with a post-pounder and a roll of barbed wire. Three weeks ago on a job on a large private ranch north of Marathon, he found a section of fence that had been cut cleanly \u2014 not broken, cut, with wire cutters \u2014 on both sides of a buried metal box about the size of a shoebox that someone had sunk into the caliche. The box had a combination lock on it. He didn't open it. He reset the fence, finished the job, and said nothing to the ranch manager because he told himself it was none of his business. He's been on that same ranch twice since for other fence work and checked the spot both times. The box is still there. Tonight he drove past the turnoff on his way back to Marathon and sat on the road for twenty minutes trying to decide if he should go back. He didn't.",
|
||||||
|
"voice": "Hank"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bexley",
|
||||||
|
"situation": "Bexley's best friend since high school is a woman named Tatum who moved to Alpine two years ago to be with her boyfriend, Cord \u2014 a Sul Ross graduate student in geology. Bexley thinks Cord is controlling: he monitors Tatum's location on her phone, calls during Bexley's visits until Tatum leaves the room, and has slowly isolated Tatum from her Odessa friendships. Bexley has said all of this to Tatum directly, twice. Tatum has defended Cord both times and told Bexley she's projecting. Last week Bexley processed an ER intake \u2014 she cannot say who, HIPAA \u2014 and the details of the intake, which she is not going to share, made her believe with near-certainty that Tatum had been in that ER without telling her. Bexley cannot ask Tatum directly without revealing that she works intake. She cannot confirm what she thinks she knows. And if she's wrong, she will have accused her best friend's boyfriend of something serious based on a records inference she was never supposed to make.",
|
||||||
|
"voice": "Wendy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Prentiss",
|
||||||
|
"situation": "Prentiss has no crisis. He is calling because last Thursday, on a solo paddle through Santa Elena Canyon \u2014 the sheer limestone walls rising 1,500 feet on both sides, the Rio Grande running low and green in August \u2014 he had an experience he has been trying to describe accurately to people ever since and failing. At a particular bend where the canyon walls narrow and the river doubles back on itself, the sound went strange: his paddle strokes came back to him delayed, the echo timing was wrong in a way he couldn't explain, and for approximately ninety seconds he heard what he can only describe as a second river \u2014 same water sounds, same rhythm, but offset, like an audio track slightly out of sync with itself. He's run Santa Elena Canyon over four hundred times. He's never heard anything like it. He's not saying it was supernatural. He's saying it was acoustically real and he can't explain it and he wants to know if anyone else has heard it.",
|
||||||
|
"voice": "Conrad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Odette",
|
||||||
|
"situation": "Odette's seventeen-year-old son, Jerome, has been accepted \u2014 informally, pending a formal offer \u2014 to a pre-architecture program at UT Austin that starts in January. It is a genuine opportunity: a full scholarship pipeline, small cohort, real mentorship. Jerome wants to go. Odette wants him to go. The problem is Jerome's grandfather \u2014 Odette's father, Hector, 74, who runs a small but still-operating cattle operation outside Marfa and who has been deteriorating physically for the past year. Jerome is the only grandchild who shows up. He fixes fences on weekends. He drives Hector to Presidio for doctor's appointments because Odette works. If Jerome leaves in January, Odette does not know who takes the grandfather calls. She cannot afford in-home help. Her brother in El Paso has made it clear he will not be coming back. She has not told Jerome that any of this is a factor \u2014 she has only told him she's proud of him \u2014 because she refuses to be the person who chains her son to this town by guilt.",
|
||||||
|
"voice": "Veronica"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wendell",
|
||||||
|
"situation": "Wendell is not calling about a moral dilemma. He is calling because he has become mildly obsessed with something he calls 'ghost water' \u2014 his term for the phenomenon of ancient aquifer pockets in the Chihuahuan Desert bedrock that don't connect to the regional water table and have been sealed since the Pleistocene. In his thirty-five years of drilling, he has hit three of them: water under pressure, slightly warm, with a mineral profile completely unlike the surrounding aquifer. The water comes up, depletes in hours or days, and the pocket is gone. He's had the water from his most recent find \u2014 hit last spring on a private job near the Glass Mountains \u2014 analyzed by a lab in San Angelo. The mineral signature doesn't match any catalogued aquifer in the Trans-Pecos basin. He's been emailing a hydrogeologist at UTEP about it for four months and getting increasingly interested responses. He is calling tonight because he just got an email while driving that the UTEP researcher wants to come out and look at the site.",
|
||||||
|
"voice": "Duncan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Joaquin",
|
||||||
|
"situation": "Joaquin has been covering weekend shifts for a coworker named Danny for three months \u2014 Danny said he was dealing with a family situation. Joaquin did it because Danny covered for him twice last year and because that's how it works. Two weeks ago Joaquin found out through a mutual friend that Danny has been spending those weekends doing paid catering gigs under the table \u2014 using a connection he made through their restaurant \u2014 and telling the restaurant owner that he has a family emergency standing arrangement. Danny is making good money. Joaquin has missed two of his band's gigs because he was covering those Saturdays. The band has a paying show booked at the Marfa Lights Festival in October \u2014 their first real booking \u2014 and it falls on a Saturday Danny has already asked Joaquin to cover again. Joaquin hasn't confronted Danny. He hasn't told the owner. He's just been doing the shifts and getting angrier.",
|
||||||
|
"voice": "Felix"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1785806090.001651,
|
||||||
|
"lineup": [
|
||||||
|
{
|
||||||
|
"name": "Vonda",
|
||||||
|
"situation": "Six weeks ago Vonda noticed that one of her drivers \u2014 a young man named Cody, 24, supporting a newborn \u2014 had been falsifying his hours-of-service logs. Not dramatically, just shaving entries to avoid going over the federal limit and losing his CDL. She's been covering for him, not reporting it, because she knows what losing that job would do to his family. Last night a tanker from a different company jackknifed on I-10 near the Bakersfield cutoff \u2014 driver fell asleep, DOT is now doing a sweep of all regional carriers' logs. Her company's records will be audited within days.",
|
||||||
|
"voice": "Marlene"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Stetson",
|
||||||
|
"situation": "Stetson has no moral crisis. He is calling because last Friday he was doing a rough-in electrical inspection at a new gallery build on Highland Avenue and found, inside a sealed wall cavity that hadn't been opened since the building's 1940s construction, a leather satchel containing 47 hand-addressed envelopes \u2014 all stamped, all sealed, all addressed to different people in Marfa and Presidio, postmarked 1943. None of them were ever sent. He has been opening them one by one and reading them, and they are almost entirely love letters \u2014 from the same handwriting, a woman named Eula, to 47 different men.",
|
||||||
|
"voice": "Ethan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Lupita",
|
||||||
|
"situation": "Lupita's thesis advisor \u2014 a well-regarded professor who controls her funding, her committee, and her graduation timeline \u2014 has been taking credit in conference presentations for a methodology Lupita developed herself. It's not plagiarism in any documentable way: the work is collaborative on paper, she's listed as second author, and the professor never explicitly claimed sole invention. But at a conference in San Antonio last month, Lupita watched him describe the method to a room of peers using 'I' over and over while she sat in the third row. She has one semester left. If she confronts him she risks her funding. If she says nothing she gets her degree and leaves.",
|
||||||
|
"voice": "Luna"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Birch",
|
||||||
|
"situation": "Birch has no dilemma. He is calling because he has spent the last two years obsessively reconstructing the complete hydrological history of Comanche Springs in Fort Stockton \u2014 the largest spring system in Texas until it went dry in 1961 when irrigation pumping collapsed the aquifer. He has found survey records, eyewitness accounts, old photographs, and a 1954 Army Corps of Engineers internal memo he obtained through an archives request that suggests the spring could have been saved if one specific ranching family had agreed to reduce their draw by 30 percent. They didn't. He knows who the family is. They still ranch in Pecos County. Their grandchildren are at community events in Fort Stockton right now.",
|
||||||
|
"voice": "Dennis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Paloma",
|
||||||
|
"situation": "Eight months ago Paloma processed a family \u2014 a woman and two kids \u2014 who crossed legally from Ojinaga with valid documents. Routine. Two weeks later the woman came back through alone, visibly injured, and Paloma flagged her for secondary. In secondary the woman told her, quietly, that her husband \u2014 still on the Mexican side \u2014 had been threatening her and she was trying to leave. Paloma, acting on her own judgment and outside strict protocol, helped connect the woman with a Presidio legal aid contact and let her through on a humanitarian basis that was technically within her discretion but which she did not fully document as required. The woman and her kids are now safe in San Antonio. Last week Paloma's supervisor mentioned, casually, that there's a random case audit happening next quarter and her files from last fall are in the sample.",
|
||||||
|
"voice": "Nadia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rowdy",
|
||||||
|
"situation": "Rowdy has no moral crisis. He is calling because three days ago he was leading a solo paddle on a remote stretch of the Rio Grande below Mariscal Canyon and found, pulled up on a gravel bar on the U.S. side, an absolutely pristine wooden rowboat \u2014 hand-built, painted sky blue, with no registration numbers, no markings, and a single red wool blanket folded on the middle thwart. No footprints around it. No tracks leading anywhere. He checked it against every known crossing point and outfitter inventory. It belongs to nobody. It's still there.",
|
||||||
|
"voice": "Callum"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Georgette",
|
||||||
|
"situation": "Georgette's younger sister Vera \u2014 54, widowed three years ago \u2014 has been spending money at a rate that Georgette, as the person who quietly manages Vera's household accounts as a favor, cannot explain. Not gambling, not obvious shopping. In the last four months, $23,000 has moved out of Vera's savings in transfers Georgette can trace only to a payment app she doesn't recognize. Georgette confronted Vera last week and Vera said it was 'investments' and changed the subject. Georgette did not push. But she knows Vera is lonely and she is terrified it's a romance scam and she doesn't know how to say that to her sister without destroying what's left between them.",
|
||||||
|
"voice": "Eleanor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tuck",
|
||||||
|
"situation": "Tuck has no moral crisis. He is calling because he has developed, over years of driving the same roads at night, an encyclopedic and genuine obsession with the specific way sound behaves in the high desert at night \u2014 what he calls 'the acoustics of nowhere.' He has documented, on a voice recorder he keeps on the dash, over 200 distinct sound events he cannot explain by conventional echo or wind: voices that seem to come from ridgelines with no one on them, music that appears and vanishes over two or three miles of road, a low mechanical hum audible only in the valley between the Davis Mountains and the Glass Mountains that he has now recorded on 14 separate nights. He is not a UFO person. He is not a ghost person. He genuinely believes this is atmospheric physics that nobody has bothered to study because not enough people drive these roads at night paying attention.",
|
||||||
|
"voice": "Grant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Esperanza",
|
||||||
|
"situation": "Esperanza's dilemma is this: her fifteen-year-old student \u2014 a bright, difficult boy named Marco \u2014 handed in an essay last month that was, she recognized immediately, a thinly fictionalized account of his stepfather moving drugs across the river. Not a rumor. Specific details: timing, crossing method, the make of the truck. She has been sitting on this for four weeks. She did not report it to the school director because the school director is the stepfather's cousin. She did not report it to the municipal police because she does not trust them. She has a contact at the DEA field office in Presidio \u2014 a woman she met at a community meeting years ago \u2014 but using it would expose Marco, who she believes wrote the essay because he wanted someone to know and didn't know how to say it any other way.",
|
||||||
|
"voice": "Jessica"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hensley",
|
||||||
|
"situation": "Hensley is calling because he is absolutely convinced, based on eleven months of personal observation, trail camera footage, and what he describes as 'track evidence that don't match anything in the field guides,' that there is a breeding population of ocelots in the limestone canyon country north of Sanderson \u2014 not in the known South Texas range, not anywhere near where ocelots are supposed to be, but in the high desert scrub of Terrell County where they have no business being. He has not contacted Texas Parks and Wildlife because the last time he reported something unusual to state wildlife authorities \u2014 a mountain lion denning close to a ranch road \u2014 the ranch owner found out and the lion was gone within a week. He doesn't trust the information chain.",
|
||||||
|
"voice": "Malcolm"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1785806481.347754,
|
||||||
|
"lineup": [
|
||||||
|
{
|
||||||
|
"name": "Silas",
|
||||||
|
"situation": "The Wellspring's first week on the Terlingua land has surfaced a crisis Silas did not anticipate: a woman named Petra, one of the community's most devoted members for six years, has announced she will not participate in any future Unbinding ceremonies \u2014 not because she objects, but because her adult daughter drove out from Odessa to see the new land and is now threatening to file a formal complaint with Brewster County about the commune's practices. Petra is caught between her daughter and her community, and Silas \u2014 still raw from the Marcus reckoning \u2014 is trying hard not to pressure her. But three other members watched him handle it and now they're asking quiet questions about whether the Unbinding will even continue on the new land. The fresh-start he envisioned is wobbling in its first week, and he's calling Luke partly for advice and partly because he genuinely doesn't know what the right thing is anymore.",
|
||||||
|
"voice": "Sebastian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Devereux",
|
||||||
|
"situation": "Devereux has been doing small jobs for the Gage Hotel for about four years \u2014 light fixture replacements, outlet work, the occasional plumbing assist. Three weeks ago, while doing some work in one of the historic guest rooms, he found a canvas rolled up inside the wall cavity behind a replaced baseboard \u2014 oil paint, maybe 18 inches by 24 inches, a desert landscape with a small figure in it that he can't stop looking at. He didn't tell anyone. He took it home. He's shown it to exactly one person, a friend who took an art history class at Sul Ross, who told him it could be significant or could be a tourist piece from the 1940s \u2014 she genuinely doesn't know. Devereux is not a thief by nature and the guilt is eating him, but he's also been poor his whole life and the painting is sitting on his kitchen table and he keeps thinking about what it might be worth.",
|
||||||
|
"voice": "Brian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Florencia",
|
||||||
|
"situation": "Florencia is not calling about a crisis. She is calling because two nights ago, while doing a nocturnal small-mammal survey on a transect line in the Paisano Pass area west of Alpine, she witnessed something she has been turning over in her head ever since: a pronghorn antelope \u2014 alone, which is already unusual at night \u2014 standing completely still in her headlamp beam for nearly four minutes without moving. Not frozen in fear the way prey animals freeze. Still in a different way. Deliberate. Then it walked directly toward her, stopped about fifteen feet away, looked at her for another long moment, and walked off into the dark at a normal pace. She's a scientist. She has an explanation for all of it. But she can't stop thinking about those four minutes and she wants to describe it to someone who will actually listen.",
|
||||||
|
"voice": "Bianca"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Holt",
|
||||||
|
"situation": "Holt's dilemma is this: his son-in-law, Dustin, has been telling Holt's daughter Renee for two years that he's been promoted at the drilling company and making good money \u2014 Renee has quit her job at the Dollar General based on this, they bought a truck, they're talking about a house. Last month Holt ran into one of Dustin's actual coworkers at the Stripes on I-10 who let something slip: Dustin is still on the same crew he was on two years ago, same rate, no promotion. Holt has done the math and Dustin is burning through something \u2014 savings, a loan, something \u2014 to maintain the fiction. Renee is thirty-two, they have a four-year-old, and Holt loves Dustin, genuinely likes the man, which makes this worse. He hasn't said anything to Renee. He confronted Dustin two weeks ago in a parking lot and Dustin broke down crying and begged him for sixty days to fix it.",
|
||||||
|
"voice": "James"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Celestine",
|
||||||
|
"situation": "Celestine is calling because she has a theory about the Marfa Lights and it is not a theory she has seen anywhere else, and she has looked. She is not a mystic and she is not credulous \u2014 she has lived in Marfa her whole life and she has watched the Lights since she was a child from the viewing platform on Highway 90. Her theory, developed over years of watching and cross-referencing with her work schedule and property logs, is this: the Lights appear more frequently and more vividly in the weeks after a large influx of visitors to Marfa \u2014 specifically after the busiest tourist weekends. She has kept a handwritten log for eleven years. She has also noted that they are rarest in January and February, when Marfa is quietest. She is not saying the tourists cause the Lights. She is saying the correlation is there and nobody is studying it and it bothers her that the people who should care \u2014 the researchers, the university types \u2014 won't take a local woman with a notebook seriously.",
|
||||||
|
"voice": "Tessa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Beau",
|
||||||
|
"situation": "Beau is not calling about a crisis. He is calling because six weeks ago he became genuinely, almost academically obsessed with the question of why horned lizards \u2014 horny toads \u2014 have almost completely disappeared from the areas around Alpine where they were common when his grandfather was young, and then three days ago, while clearing some old brush along a caliche road on his uncle's ranch property east of Alpine near the Leoncita area, he found a small population \u2014 maybe a dozen animals \u2014 in a rocky patch of ground that hadn't been disturbed in decades. He caught one, held it for about thirty seconds the way his grandfather taught him, and it squirted blood from its eyes at him, which he knew was a defense mechanism but had never personally experienced. He is calling because he found the lizards and because the blood-squirting experience at close range was something he needs to tell someone about.",
|
||||||
|
"voice": "Liam"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vashti",
|
||||||
|
"situation": "Vashti's dilemma is slow and has been building for months and came to a head this weekend. She has a colleague \u2014 a woman she has worked alongside for nine years, someone she considers a close friend \u2014 who has been systematically taking sole credit for a grant-funded family reunification program that Vashti built from the ground up. The colleague, Marisol, writes the reports, attends the funder meetings, and has begun to refer to the program as her own initiative in ways that are small enough to seem like accidents. Vashti brought it up once, gently, and Marisol apologized and then did it again the next month. This weekend Vashti found out that Marisol has been invited to present the program at a conference in El Paso in October \u2014 without mentioning Vashti's name to the organizers. The program serves families Vashti has known for years. The work is what she cares about. But she is also tired of being erased.",
|
||||||
|
"voice": "Julia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rayford",
|
||||||
|
"situation": "Rayford is calling because his neighbor \u2014 a man named Clete whose family has run the adjacent property for just as long \u2014 offered to buy Rayford's 4,200 acres two months ago at a number that is, by any honest measure, a good price. Rayford said no. Clete came back last week with a higher number. Rayford said no again. What Rayford has not told anyone, including his two adult children who would inherit the land, is that he has been losing money on the cattle operation for four years running, that the well situation on the south pasture has gotten worse, and that he is seventy-one days behind on a note at the bank. His children believe the ranch is solid. One of them \u2014 his daughter Nicki \u2014 has been planning her life around eventually coming back to run it. Rayford said no to Clete both times partly out of pride and partly because of Nicki. But the bank doesn't care about Nicki.",
|
||||||
|
"voice": "Edward"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Thomasine",
|
||||||
|
"situation": "Thomasine is not calling about a moral crisis. She is calling because she has become, over the past year, a deeply serious and self-taught expert on the history of the Chinati Hot Springs \u2014 the remote thermal springs down near Ruidosa, south of Marfa, on the Rio Grande \u2014 and specifically on the decades when the property operated as a resort and health retreat in the early and mid twentieth century. She found a box of old photographs and a handwritten guest register from the 1930s at an estate sale in Alpine a year ago and has been trying to piece together who these people were and why they came to a hot spring in the middle of the Chihuahuan desert in the Depression era. She has driven down there four times this year. She has contacted the Presidio County Historical Commission. She has found three people whose grandparents signed the register. She is calling tonight because this week she found a photograph in the box she'd somehow missed \u2014 a group of about twenty people standing in front of the main building, undated, and in the back row she is nearly certain she has identified a figure who, based on everything she knows, should not have been there.",
|
||||||
|
"voice": "Sarah"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Merritt",
|
||||||
|
"situation": "Merritt is calling because he did something at work six weeks ago that he has been unable to resolve in his own head. A colleague of his \u2014 a senior staff astronomer named Dr. Pryce \u2014 submitted an observation request that Merritt, as the person responsible for scheduling mirror maintenance and telescope availability, knew would conflict with a pre-approved community dark-sky viewing event: one of McDonald's public Star Party nights, attended by families and school groups from all over the region. Dr. Pryce's request was legitimate and scientifically significant \u2014 a narrow window to observe a specific target. Merritt approved Dr. Pryce's request and bumped the Star Party. He did it because Dr. Pryce is his direct supervisor and the science was real. The Star Party was rescheduled with one week's notice. A group of special-needs students from a school in Odessa had planned the trip for months, had already driven four hours, and arrived to find the public program cancelled. Merritt was in the building when they arrived. He watched the teacher manage the disappointment. He did not say anything. He has not said anything since.",
|
||||||
|
"voice": "Mark"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1785806876.617506,
|
||||||
|
"lineup": [
|
||||||
|
{
|
||||||
|
"name": "Silas",
|
||||||
|
"situation": "The Wellspring's artisanal soap operation \u2014 the financial backbone of the whole community \u2014 just lost its primary fulfillment contract. The small regional co-op that was warehousing and shipping their product out of Las Cruces dropped them because the new Terlingua address triggered a contract clause about operational continuity. Silas has thirty-one people on raw desert land, a partially built structure, no reliable shipping infrastructure, and maybe six weeks of operating cash. He is calling ostensibly to ask Luke whether he knows anyone in the region who does small-batch fulfillment, but the real thing underneath is that he is beginning to wonder if the move was a catastrophic mistake he dressed up as spiritual vision.",
|
||||||
|
"voice": "Sebastian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Darnell",
|
||||||
|
"situation": "Three weeks ago Darnell's seventeen-year-old son Keondre showed him a video on his phone \u2014 footage of Darnell's own foreman, a man named Pruitt, taking cash from a county contractor in the parking lot of the Comanche Springs Park. Darnell recognized the contractor: it's the same outfit that keeps winning the chip-seal bids even when their price comes in higher than competitors. Darnell has worked for Pruitt for six years. Pruitt wrote the letter that got Darnell the supervisor job. He also knows that if he reports it, the investigation will likely sweep up the whole department, and two of his crew members are men he genuinely respects who have nothing to do with the kickback. He hasn't told his wife yet. He hasn't done anything with the video. Keondre keeps asking what he's going to do.",
|
||||||
|
"voice": "Marcus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wrenley",
|
||||||
|
"situation": "Wrenley has no moral crisis. She is calling because two nights ago, Saturday, she witnessed something at the Marfa Lights viewing platform on Highway 90 that she has been turning over in her head ever since and needs to say out loud to somebody. She was out there alone around 1 a.m. after her shift \u2014 she goes sometimes just to decompress \u2014 and she watched what she is certain was not headlights, not aircraft, not the Chinati Foundation doing anything: a single white light that hovered completely stationary for about four minutes, then split cleanly into three lights that moved in a slow equilateral triangle formation before dimming out one by one over maybe ninety seconds. She has seen the lights before. This was different. She is not a believer in anything particular. She is a woman who watched something she cannot explain and it has been living in her chest since Saturday.",
|
||||||
|
"voice": "Chloe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Augusto",
|
||||||
|
"situation": "Augusto's dilemma has been building for eighteen months and cracked open this past week. His only daughter, Renata, 34, has been living in San Antonio and has made it clear she has no interest in the ranch \u2014 she's a civil engineer, happy, married, not coming back. His only son, Felix, 39, wants the land badly, has been working alongside Augusto for twelve years, and would be the obvious heir. But Augusto has discovered \u2014 through a conversation he wasn't supposed to hear \u2014 that Felix has been quietly negotiating with a private equity land aggregator out of Houston that has been buying up ranch parcels across Brewster County. Felix hasn't said a word to Augusto. The aggregator has been buying land and leasing it back as a carbon credit operation. Augusto does not believe Felix would sell immediately, but he now believes Felix would sell within five years of inheriting. He doesn't know whether to confront Felix, change the will, or say nothing and let the land go when he's gone.",
|
||||||
|
"voice": "Victor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tippi",
|
||||||
|
"situation": "Tippi is not calling about a crisis. She is calling because she has spent the last eight months going down a rabbit hole she cannot fully explain to anyone in her regular life: she has become obsessed with the 1883 survey records of the Southern Pacific Railroad right-of-way through Brewster County \u2014 specifically a three-mile discrepancy between the original survey plat and where the tracks were actually laid through what is now the west end of Alpine. She found the original survey documents in the Sul Ross archives while helping a student with unrelated research. The discrepancy means that according to the 1883 plat, the tracks should run through property that is currently occupied by a neighborhood of about fourteen homes. She has cross-referenced this with the original land grant records, the 1901 county tax rolls, and two sets of USGS topographic maps. She is not a lawyer and doesn't know what it means legally, but the geometric fact of it keeps her up at night.",
|
||||||
|
"voice": "Loretta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hector",
|
||||||
|
"situation": "Six months ago Hector's twenty-six-year-old nephew, Ismael, came to work for him driving a route between Marfa and Presidio. Ismael is the son of Hector's younger brother \u2014 the brother who died of a heart attack at 49 \u2014 and Hector took him on as an act of family loyalty. Last week Hector found out from a driver at another outfit that Ismael has been doing a side job on his Marfa-to-Presidio run: picking up cash from a man in Presidio and delivering envelopes to an address in Marfa. Nobody has told Hector what's in the envelopes. The driver who told him didn't know either. But Hector knows the man in Presidio by reputation, and the reputation is not clean. Ismael has a daughter who just turned one year old. Hector hasn't confronted Ismael yet. He is a deacon. His brother is dead. He doesn't know whether to call his brother's name down on this or let the boy hang himself.",
|
||||||
|
"voice": "Jason"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Odalys",
|
||||||
|
"situation": "Odalys is not calling about a crisis. She is calling because she has developed, over four seasons on the Rio Grande in the Big Bend, an obsessive working knowledge of the canyon acoustics \u2014 specifically, the way sound behaves inside Santa Elena Canyon, Mariscal Canyon, and Boquillas Canyon differently from each other, and what it means for guiding. She has been cataloguing this informally for two years: which canyons amplify a whistle, which ones swallow a shout, how the river noise changes pitch as water levels drop in August, the specific echo delay inside the upper Santa Elena narrows at the point where the walls close to about thirty feet. She started doing it for safety \u2014 knowing how far sound carries tells you how to communicate with a group \u2014 and then it became something else entirely. She brought a small recorder this season. Tonight she heard Luke play a piece of recorded ambient sound on air and something about the reverb made her need to call.",
|
||||||
|
"voice": "Amina"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Brecken",
|
||||||
|
"situation": "Brecken's dilemma is recent and specific and he can't talk to anyone on campus about it. Four days ago he was doing routine irrigation maintenance near the Sul Ross athletic fields and he found a folder of printed documents left in the equipment shed \u2014 personnel records, email printouts, what appear to be drafts of a Title IX complaint \u2014 with a name on the cover sheet he recognizes: a professor he has had, a man he genuinely likes, who helped him get a summer internship with Texas Parks and Wildlife that he is supposed to start in September. The folder was just sitting there. He doesn't know if it was left accidentally or if someone put it there deliberately for someone else to find. He took a photo of the cover sheet before he put it back. He hasn't told anyone. He doesn't know who left it or who it belongs to. But he knows what he saw, and he knows the internship letter is sitting on his desk, and he knows those two facts are now entangled in his head even though they may have nothing to do with each other.",
|
||||||
|
"voice": "Elliot"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Neva",
|
||||||
|
"situation": "Neva has no moral crisis. She is calling because three weeks ago she was harvesting honey from her hives and noticed that her bees \u2014 a colony she has kept for eleven years \u2014 had begun constructing comb in a geometry she had never seen and cannot find documented anywhere she has looked. Instead of the standard hexagonal sheet construction, a section of one frame had comb cells arranged in a spiral pattern radiating outward from a central point, like a nautilus cross-section, with cell size decreasing toward the center. She is a careful, empirical woman who has kept bees for thirty years. She has contacted two extension agents and a professor in the entomology department at Texas A&M. The extension agents told her it was probably a wax moth disruption. The A&M professor said it was likely stress-related irregular comb and sent her a paper. She has read the paper. The paper does not describe this. She has photographs. She has been keeping bees long enough to know what stress comb looks like, and this is not that.",
|
||||||
|
"voice": "Elizabeth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cord",
|
||||||
|
"situation": "Cord's situation came to a head this afternoon and he pulled over on Highway 90 to think and ended up calling the show. His business partner of nine years \u2014 a man named Garrett, who owns 40 percent of the company \u2014 told Cord two weeks ago that he wanted to buy out his share and exit the business. Cord agreed, they shook on a number, Cord started putting together the financing. Today Cord found out from a drilling company dispatcher he's friendly with that Garrett has already been talking to a competitor \u2014 a larger well-service outfit out of Midland \u2014 about taking a salaried VP position and bringing the company's client list with him. The client list is not formally protected by anything because when they started the company nine years ago two guys shaking hands in a parking lot in Fort Stockton didn't think about non-competes. If Garrett walks with the client relationships, Cord loses maybe 60 percent of his revenue in the first year.",
|
||||||
|
"voice": "Hank"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1785807224.910801,
|
||||||
|
"lineup": [
|
||||||
|
{
|
||||||
|
"name": "Sondra",
|
||||||
|
"situation": "Sondra has been doing the books for a Marathon feed-and-supply operation for eleven years. Last Thursday she found a pattern she cannot explain away: the owner has been quietly underreporting payroll for at least four years, which means his two full-time Mexican-American employees \u2014 men she knows personally, men who have worked there since before she did \u2014 have been paying into Social Security on falsified wages. The men don't know. The owner is sixty-eight, has a bad heart, and his wife just started chemo. Sondra has not said a word to anyone.",
|
||||||
|
"voice": "Marlene"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pax",
|
||||||
|
"situation": "Pax is not calling about a crisis. He is calling because last spring, while doing vegetation transects out in the Glass Mountains northeast of Alpine, he stumbled into what he is now convinced is one of the last undocumented nesting sites in Texas for the Aplomado falcon \u2014 a bird that was functionally extirpated from the state for decades before reintroduction programs began. He has GPS coordinates, photographs, and two confirmed adults on nest. He has told his thesis advisor. His advisor told him to sit on the data until they can publish. That was four months ago. The paper is nowhere near done. Pax is twenty-nine years old and bursting.",
|
||||||
|
"voice": "Jonah"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Truett",
|
||||||
|
"situation": "Truett's dilemma is about his brother. His younger brother Waylon, fifty-four, has been sober for nine years \u2014 AA, sponsor, the whole architecture. Last month Waylon started dating a woman Truett likes genuinely, a widow named Carol who works dispatch at the sheriff's office. Two weeks ago Truett ran a routine background check on Carol as part of a process-serving job \u2014 totally unrelated \u2014 and found something: Carol has a civil judgment against her from 2019 in Midland County related to a bar fight that turned into a lawsuit, and more troublingly, a dismissed DWI from 2021 that was pleaded down. Truett doesn't know if Waylon knows. Carol has given no indication she drinks now. Truett ran the check legally. He wishes he hadn't.",
|
||||||
|
"voice": "Grant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Lupe",
|
||||||
|
"situation": "Lupe is not calling about a crisis. She is calling because she has spent the last year developing what has become a serious, documented obsession with the architectural and commercial history of the Presidio-Ojinaga crossing \u2014 specifically the way the same families have run trade and transport on both sides of the river for over a hundred and twenty years, surviving revolution, Prohibition, NAFTA, and every border policy since. She has found letters, photographs, ledgers. She found a 1923 manifest in a box at her grandmother's house in Ojinaga that lists goods she can trace to a great-great-uncle's store that still has a building standing in Presidio. She has started giving informal talks at the Presidio library.",
|
||||||
|
"voice": "Selene"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Garrison",
|
||||||
|
"situation": "Garrison's situation cracked open this past weekend. His nineteen-year-old daughter Lily drove home from her first year at Texas Tech and told him and his wife that she has decided not to come back \u2014 not to the ranch, not to Marfa, not to any of it. She wants to stay in Lubbock, get an apartment with friends, and study something called sustainable food systems. Garrison is not angry about the degree. He is gutted because last fall, before she left, he deeded her forty acres of the ranch \u2014 the section his own grandfather deeded to his father \u2014 in a formal transfer, with a lawyer, as a statement of faith that she was coming back. She didn't ask him to do it. He did it because he was certain.",
|
||||||
|
"voice": "Cedric"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Effie",
|
||||||
|
"situation": "Effie is not calling about a crisis. She is calling because she has spent the last three months quietly and methodically documenting what she believes is a lost oral tradition of weather prediction specific to the Davis Mountains and the surrounding basin \u2014 a set of observational rules passed down by ranching families and old-timers that predate the National Weather Service station in the region and have, in her testing, a remarkable accuracy rate for afternoon monsoon prediction in July and August. She has interviewed eleven people, the oldest of whom is ninety-three. She is cross-referencing their observations against sixty years of NWS records she pulled from Alpine and Marfa.",
|
||||||
|
"voice": "Victoria"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rook",
|
||||||
|
"situation": "Rook's dilemma is recent and specific. Three days ago he guided a private trip \u2014 four people, a five-day float through the Lower Canyons. On the second day, one of the clients, a man named Dennis from Houston, pulled Rook aside at camp and offered him three hundred dollars cash to tell the rest of the group that a particular side canyon was too dangerous to enter. It wasn't. Rook knew it wasn't. Dennis wanted to prevent his teenage stepson \u2014 who was on the trip \u2014 from making the hike because the stepson had been talking the whole first day about how he wanted to do it and Dennis wanted to deny him the experience without being the one to say no. Rook took the money. He told the group the canyon was closed for resource protection. The stepson, a kid named Marcus, sat at the river's edge for two hours that afternoon without speaking.",
|
||||||
|
"voice": "Hamish"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Inez",
|
||||||
|
"situation": "Inez is calling because she has a theory \u2014 a real, worked-out, documented theory \u2014 that the water table beneath a significant stretch of Pecos County has been contaminated by injection well activity associated with Permian Basin disposal operations, and that this contamination is the explanation for a cluster of specific gastrointestinal and neurological presentations she has been seeing in patients from two particular zip codes over the past three years. She is not a scientist. She is an LVN with thirty years of bedside experience and a very good memory for patterns. She has kept a personal log. She has cross-referenced her log against public Railroad Commission injection well permits using the RRC's online database. She has shown her log to one physician, who told her correlation isn't causation and moved on.",
|
||||||
|
"voice": "Darlene"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dovie",
|
||||||
|
"situation": "Dovie's situation is this: she was hired eight months ago to help a visiting artist \u2014 a moderately well-known sculptor from New York named Callista \u2014 install a temporary public piece on a private property outside Marfa. During the installation, Dovie did most of the actual physical labor. The piece was documented, reviewed in two online art publications, and Callista was credited as sole creator. Dovie is fine with that \u2014 she was paid and she knew the deal. What Dovie is not fine with is that last week she found out Callista has sold the piece \u2014 supposedly temporary, supposedly site-specific \u2014 to a collector in Zurich for an amount Dovie accidentally overheard her talking about on the phone: four hundred and twenty thousand dollars. Dovie made eleven dollars an hour.",
|
||||||
|
"voice": "Sophie"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wendell",
|
||||||
|
"situation": "Wendell is a true believer and he is dead serious. He has spent the last nine months compiling evidence for what he is convinced is a coordinated and deliberate suppression of a specific type of highway infrastructure \u2014 the concrete box culvert designs used on state highways in Brewster, Terrell, and Val Verde counties in the 1940s and 1950s. He believes these culverts were built according to a non-standard specification that appears in no TxDOT archive he has been able to access, that several of them are still load-bearing on active roads, and that someone \u2014 he is not sure who, but possibly a contractor family with long ties to Austin \u2014 has been systematically ensuring these structures are replaced rather than documented, destroying evidence of what he believes was a large-scale materials fraud that stretched from roughly 1941 to 1959. He has photographs of seventeen culverts. He has a spreadsheet.",
|
||||||
|
"voice": "Arthur"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1785807641.615133,
|
||||||
|
"lineup": [
|
||||||
|
{
|
||||||
|
"name": "Silas",
|
||||||
|
"situation": "The Wellspring's artisanal soap operation \u2014 the financial backbone of the whole community \u2014 just lost its primary fulfillment contract with the small regional co-op that had been handling shipping out of Las Cruces. Now, stranded on remote land outside Terlingua with no reliable shipping infrastructure, Silas is watching the commune's income evaporate in real time. He's called nominally to ask Luke's audience if anyone knows a freight or fulfillment solution out of the Big Bend, but underneath that he's grappling with whether the move was a catastrophic mistake driven more by his own need to escape the shame of the coercion reckoning than by genuine vision. Tonight a twenty-three-year-old member named Petra \u2014 one of the true believers who drove the U-Haul all the way from Deming \u2014 told him she was leaving because she 'didn't sign up to be poor in the desert.' That one landed.",
|
||||||
|
"voice": "Sebastian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Darlene",
|
||||||
|
"situation": "Darlene has been renting a room to Cody, twenty-four, for eight months \u2014 a Permian Basin roughneck who works two-weeks-on, two-weeks-off rotations and is otherwise quiet, pays on time, and keeps to himself. Last Friday Darlene was pulling weeds along the side of the house and found, wedged between the foundation and the AC unit, a ziploc bag containing forty-three hundred dollars in cash and a handwritten list of first names with dollar amounts next to them. She put it back exactly where she found it. Cody came home Saturday and she acted normal. She does not believe Cody is dangerous \u2014 nothing about him feels dangerous \u2014 but the list has seven names on it and she doesn't know if she's looking at a drug debt ledger, someone paying people back, a loan operation, or something else entirely. She's not going to call the police. She wants to know whether to ask him directly or let it go.",
|
||||||
|
"voice": "Tessa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fletcher",
|
||||||
|
"situation": "Fletcher is calling because of something that happened to him last Thursday night and he has told exactly one person \u2014 his dog. He was running new conduit in a crawlspace under one of the Gage Hotel's outbuildings around ten-thirty at night, a job that ran long, and he heard what he can only describe as a sustained, low, resonant tone coming from below him \u2014 not mechanical, not HVAC, not anything in the building. He's been in that crawlspace a dozen times. He lay still for about four minutes and the tone shifted in pitch, slowly, and then stopped. He is not a paranormal person. He does not watch ghost shows. He has a completely mundane theory \u2014 some kind of acoustic phenomenon, maybe the aquifer, maybe wind \u2014 and he called to see if anyone else has ever heard anything like it, because he can't stop thinking about it and he's going back to finish the job Wednesday night and he's nervous in a way he doesn't know what to do with.",
|
||||||
|
"voice": "Carter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rosalinda",
|
||||||
|
"situation": "Rosalinda's dilemma is eighteen months old and came back to the surface last week. Eighteen months ago she let someone through \u2014 a woman, fifties, claiming to be visiting family in Presidio, documentation in order \u2014 and something was wrong. Not wrong enough to hold her, not wrong enough to flag under any protocol. Just wrong in the way Rosalinda's body knew things before her brain did. She let her through. Two weeks later that woman was arrested in Odessa connected to a trafficking operation. Rosalinda was never implicated, was never questioned \u2014 her documentation check was clean and correct. Nobody knows she had the feeling. Last week she processed a crossing and had the exact same feeling about a young man, and this time she found a reason to hold him for secondary inspection. Nothing turned up. He was clean. She let him go. But the eighteen-month-old decision has been sitting in her chest since last week like a stone, and she is calling because she needs to know: do you act on a feeling you can't justify or do you do the job by the book and live with what the book misses?",
|
||||||
|
"voice": "Veronica"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Burl",
|
||||||
|
"situation": "Burl is calling because he has a story and it is a good one and he has been trying to tell it to people all week and nobody will sit still for the whole thing. In 1987 \u2014 Burl was twenty-eight \u2014 a stranger appeared on his family's ranch during a drought and asked permission to water his horse at their tank. His father let him. The man stayed three days, helped with fence work, spoke almost no Spanish and almost no English, and left without ceremony. He had, according to Burl's father, the single most useful pair of hands either of them had ever seen \u2014 not strong, just precisely, eerily efficient with every task. Before he left he told Burl's father, in a mix of hand gestures and broken words, that the drought would break in eleven days. It broke in nine. Burl has been thinking about this man his entire life. Two weeks ago, cleaning out his father's old desk, he found a photograph he had never seen \u2014 his father standing next to a stranger, labeled on the back in his father's handwriting with a date and one word: 'Jacobo.' The photo is from 1971. Burl was not born yet. The man in the photo and the man from 1987 look identical. Same age.",
|
||||||
|
"voice": "Ronald"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Marisol",
|
||||||
|
"situation": "Marisol's dilemma happened five days ago and she has been in a low-grade panic since. She is teaching a freshman comp section this summer \u2014 eight students, a small class \u2014 and one of her students, a nineteen-year-old named Derek, turned in a personal essay that was, by any measure, extraordinary: raw, structurally sophisticated, genuinely moving. She gave it an A and wrote extensive comments. Two days later Derek came to office hours and, after some awkward circling, told her he'd had help \u2014 not from a tutor, not from AI, but from his older brother, who is apparently a writer. The essay is Derek's story, Derek's experience, Derek's voice as he described it to his brother, but the sentences are not Derek's sentences. Marisol does not know whether this is plagiarism, collaboration, ghostwriting, or just a kid from a family where help looks different than the academic integrity policy imagined. Derek is a first-generation college student. He was terrified to tell her. She has not yet reported it or changed the grade and the window for academic integrity referrals is closing.",
|
||||||
|
"voice": "Naomi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cletis",
|
||||||
|
"situation": "Cletis is not calling about a crisis. He is calling because he has spent the last two years developing what he believes is a comprehensive and airtight theory about the acoustics of desert canyon systems in the Big Bend \u2014 specifically, that the rock formations along certain stretches of the Rio Grande canyon walls act as natural parabolic reflectors that can focus and amplify ambient sound over distances of up to three miles. He has been testing this with a calibrated decibel meter he built himself from salvaged parts and a microphone array he designed. His theory, if correct, would explain several reported phenomena in the area including certain Marfa Lights witness accounts involving sound (which are rarely discussed) and some of the stranger Rio Grande canyon echo reports going back to the Spanish colonial period. He is not saying it's supernatural. He is saying the physics is genuinely interesting and nobody has studied it properly because it requires being in very remote places at three in the morning for extended periods, which most acoustical researchers are apparently not willing to do.",
|
||||||
|
"voice": "Derek"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Genevieve",
|
||||||
|
"situation": "Genevieve's situation cracked open yesterday and she is still shaking from it. She manages rental properties for several absentee owners, including a New York couple who own a Marfa house they rent on a short-term basis. Yesterday she went to do a turnover inspection between guests and found, in the house, a sixty-three-year-old Marfa ranching family patriarch named Harlan Briggs \u2014 a man she has known her entire sixteen years here \u2014 asleep on the couch. Harlan is not the renter. Harlan has no connection to the property that Genevieve knows of. He was not breaking in \u2014 the door was unlocked from the previous guests. He woke up, looked at her without embarrassment, said 'I just needed somewhere cool and quiet,' and left. Genevieve knows Harlan's family. She knows his wife. She knows the family has been on that land for four generations. She also knows that Harlan's ranch has been in serious financial trouble and that last year a portion of the family land was sold to a Dallas investment group. She has to decide whether to report the incident to the property owners \u2014 which would likely result in a trespassing complaint against a man whose family has been in this county longer than the property owner has been alive \u2014 or say nothing.",
|
||||||
|
"voice": "Olivia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ptolemy",
|
||||||
|
"situation": "Ptolemy is not calling about a crisis. He is calling because he spent the afternoon at the McDonald Observatory near Fort Davis and came away with a fact he cannot stop turning over in his mind and has been trying to explain to every person he's encountered since, with limited success. The fact is this: the observatory's Hobby-Eberly Telescope, one of the largest in the world, was specifically designed with a fixed altitude \u2014 it doesn't tilt up and down, only rotates \u2014 meaning there is a band of the sky it simply cannot observe. The designers accepted this constraint because the telescope's primary purpose, studying the spectra of faint objects, doesn't require full-sky coverage. Ptolemy finds this philosophically staggering: the most powerful eye in the Western Hemisphere was deliberately built with a blind spot, by choice, because the blind spot was worth it. He has been applying this idea to everything since three o'clock this afternoon and he needs to talk it through with someone before he drives back to Austin.",
|
||||||
|
"voice": "Felix"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Yolanda",
|
||||||
|
"situation": "Yolanda's dilemma is about her younger sister Gloria, sixty-six, who lives in the same town. Their mother died fourteen months ago and left the family home \u2014 a house in Sanderson that has been in the family since 1961 \u2014 to both of them equally. Yolanda wants to sell. The house needs $40,000 in repairs she cannot afford, the Sanderson real estate market is essentially nonexistent, and she is seventy-one and tired of maintaining a property she doesn't live in. Gloria refuses to sell under any circumstances and refuses to buy Yolanda out and refuses to contribute to repairs, on the grounds that selling the house would be a betrayal of their mother. Last week a buyer appeared \u2014 a young couple from San Antonio who want to move to Sanderson and fix it up themselves, and they've offered fair market value. Gloria has already told the buyer the house is not for sale. Yolanda is calling because she is considering forcing a partition sale through a lawyer \u2014 a legal mechanism that would compel the sale over Gloria's objection \u2014 and she knows it will end her relationship with her sister permanently.",
|
||||||
|
"voice": "Zadie"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1785824765.051897,
|
||||||
|
"lineup": [
|
||||||
|
{
|
||||||
|
"name": "Tess",
|
||||||
|
"situation": "Tess's grandmother left her a 1940s adobe on the edge of Marfa \u2014 the house where Tess learned to ride, where her grandfather died, where every room has a story. Six months ago, broke and desperate after a bad cattle year, Tess listed it on Airbnb. It now pays better than the ranch. Last Friday she arrived to clean between guests and found the visitors had staged the entire interior for a fashion shoot \u2014 moved her grandmother's furniture, hung white fabric over the family photos, and left a printed note thanking her for the 'blank canvas aesthetic.' She stood in the middle of her grandmother's bedroom for forty minutes before she could move.",
|
||||||
|
"voice": "Nadia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Coop",
|
||||||
|
"situation": "Coop is not calling about a crisis. He is calling because he is obsessed with something nobody out here wants to talk about: open-water swimming in the Permian Basin's produced-water containment ponds, which he has never done and never would, but has been researching obsessively for a year after reading about extreme swimmers seeking out forbidden or impossible venues. What started as morbid curiosity became a genuine deep-dive into the chemistry, the geography, and the sheer scale of West Texas water infrastructure most people don't know exists. He drove out last weekend and stood at the fence line of a containment facility outside Fort Stockton and just looked at it \u2014 thousands of acres of flat, still, toxic water in the middle of the desert \u2014 and something about the scale and the wrongness of it will not leave him alone.",
|
||||||
|
"voice": "Hank"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Brecken",
|
||||||
|
"situation": "Brecken's dilemma is four days old and involves his thesis advisor, Dr. Harmon, a man he has admired for three years. Brecken discovered, while running data cross-checks for his own thesis chapter, that a key table in Harmon's 2019 published paper \u2014 the paper that forms the methodological backbone of Brecken's entire research program \u2014 contains what appear to be fabricated data points. Not misremembered. Not rounding errors. The same decimal pattern repeating across supposedly independent sample sites in a way that doesn't happen in nature. Brecken has shown it to no one. He has a meeting with Harmon on Thursday to review his own thesis draft.",
|
||||||
|
"voice": "Liam"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Odessa",
|
||||||
|
"situation": "Odessa is not calling about a crisis. She is calling because this evening, around dusk, she had the best moment she has had in five years and she needs to tell somebody. She has been keeping Chihuahuan Desert honeybee colonies in Terlingua for six years, adapting northern European beekeeping methods to extreme heat and drought with no manual, no mentor, and mostly failure. Tonight she pulled her first successful mead \u2014 a small batch from her own honey, fermented through the brutal summer \u2014 and it tasted exactly right. She drank a glass sitting outside watching the last light go off the Chisos and she cried, and she is not embarrassed about that.",
|
||||||
|
"voice": "Loretta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Harlow",
|
||||||
|
"situation": "Harlow's dilemma is about her oldest friend, Petra Canales, who she has known since first grade. Petra's husband Danny is running for Brewster County Commissioner in November. Last month Harlow processed a filing \u2014 routine public record \u2014 that shows a mechanics lien against Danny's construction business totaling $87,000 from a subcontractor in Presidio. It is a matter of public record. It is also something that, if it became widely known before the election, would probably end Danny's campaign and likely his marriage, because Petra does not know the debt exists. Harlow has done nothing. She has told no one. But a reporter from the Alpine Avalanche called her office today asking for any recent lien filings in Brewster County.",
|
||||||
|
"voice": "Julia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wick",
|
||||||
|
"situation": "Wick is a true believer and he is dead serious. He has spent the last four months documenting what he is convinced is a pattern of deliberate, coordinated cattle mutilations along a sixty-mile corridor between Marfa and Presidio \u2014 not random predator kills, not decomposition, not the usual explanations. He has photographs. He has GPS coordinates. He has a notebook with dates, wind directions, lunar phases, and the specific nature of the excisions, which follow a consistency he spent thirty years in law enforcement learning to recognize as intentional. He has taken his findings to the Presidio County Sheriff's office twice. He was not taken seriously either time. He believes the pattern is escalating.",
|
||||||
|
"voice": "Victor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Paloma",
|
||||||
|
"situation": "Paloma's dilemma happened three weeks ago and she has been carrying it alone since. She was named to the Sul Ross cross-country travel roster for a meet in Odessa. The night before the bus left, her teammate and close friend Dani told her, in confidence, that she had been taking a prescription stimulant that wasn't hers to manage her training volume \u2014 not a tested substance at the NAIA level, technically, but a violation of Sul Ross's athletic code. Paloma said nothing. They went to the meet. Dani ran the best race of her life. Yesterday the athletic department announced a random conduct review of the roster for next semester, and Dani came to Paloma's dorm room crying, asking if Paloma had reported her.",
|
||||||
|
"voice": "Chloe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gideon",
|
||||||
|
"situation": "Gideon is not calling about a crisis. He is calling because he has been building a sixteen-inch Dobsonian reflector telescope from scratch for three years \u2014 grinding his own mirror, constructing the tube and rocker box from salvaged materials \u2014 and last Saturday night he used it for the first time under the dark skies outside Fort Davis and saw the Veil Nebula, a supernova remnant, in more detail than he has ever seen it outside of photographs. He is not a romantic about it. He is a physics teacher. But he sat at the eyepiece for two hours because he could not make himself stop, and what struck him was not beauty but scale \u2014 he was looking at the debris field of a star that exploded ten thousand years ago, and the light reaching his homemade mirror had been traveling since before Stonehenge.",
|
||||||
|
"voice": "Conrad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Trace",
|
||||||
|
"situation": "Trace's dilemma is about a decision he made nine months ago that he has decided, tonight, was wrong. He bid on and won a contract to buy clip from a small Angora goat operation outside Dryden \u2014 an elderly couple, the Seavers, who have run the place for forty years. He knew when he bid that the price he offered was below fair market. He bid low because he could, because the Seavers don't have other buyers willing to drive out there, and because that is how the business works. Last week he went out to the Seavers' place for the spring clip and found out that Roy Seaver had a stroke in May and that his wife Norma has been running the whole operation alone since then. She had the clip ready and she had the paperwork ready and she thanked him for coming out.",
|
||||||
|
"voice": "Blake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Birdie",
|
||||||
|
"situation": "Birdie is not calling about a crisis. She is calling because she is furious about a specific, recent injustice and she has opinions and she has waited two days to call because she wanted to get her facts straight first. The Brewster County Historical Commission has declined \u2014 for the third time \u2014 to designate the old Southern Pacific railroad depot on the east side of Alpine as a historic landmark, despite Birdie having submitted documentation going back to 1882. The building is currently being eyed by a developer from Austin who wants to convert it into a boutique hotel. Birdie has a personal connection to the depot: her father worked as a freight agent there from 1962 to 1981, and she spent her childhood in and around that building. She also has a historical argument and she is prepared to make it.",
|
||||||
|
"voice": "Sarah"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1785825116.9801579,
|
||||||
|
"lineup": [
|
||||||
|
{
|
||||||
|
"name": "Rayfield",
|
||||||
|
"situation": "Rayfield's dilemma is eight days old and he cannot stop thinking about it. He caught his nephew, Denny \u2014 twenty-two, just got hired on at the Gage Hotel as a groundskeeper \u2014 stealing a catalytic converter off the truck belonging to an elderly Tejano rancher named Aurelio Serna, whose family has run cattle east of Marathon for four generations. Rayfield saw it happen from his porch at 2 a.m. He didn't call the police. He confronted Denny himself, made him put the part back before Serna noticed it was gone, and kept it quiet. But then he found out that Denny has been doing this for months \u2014 this was not a first time \u2014 and that old Serna has been paying out of pocket to replace converters he thought were just failing on their own. Rayfield knows Serna. He knows the man cannot easily afford this. He kept his nephew out of jail and now he is not sure he had the right to do that with someone else's losses.",
|
||||||
|
"voice": "Craig"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Suki",
|
||||||
|
"situation": "Suki is not calling about a crisis. She is calling because two nights ago she witnessed the Marfa Lights \u2014 actually witnessed them, a full display, not a smear of headlights \u2014 and what she saw does not match any of the standard explanations she has since read, and she is genuinely, delightedly unnerved. She pulled off at the official Marfa Lights Viewing Area on Highway 90 on her way back from a supply run to Alpine, and the lights performed for nearly forty minutes: splitting, rejoining, hovering, one of them dropping straight down behind the Chinati Mountains and then reappearing on the other side. She has seen the lights three times before and dismissed them. This time she took video on her phone. The video, she says, is terrible \u2014 it looks like nothing \u2014 but she knows what she saw.",
|
||||||
|
"voice": "Priya"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Gus",
|
||||||
|
"situation": "Gus's dilemma is about his partner on patrol, Deputy Terri Lujan, whom he has worked alongside for six years and considers a close friend. Three weeks ago Gus discovered, while reviewing dashcam footage for an unrelated incident report, that Terri had a thirty-minute personal stop \u2014 unlogged \u2014 at a house on the north edge of Fort Stockton that Gus recognizes. The house belongs to a man named Cecil Pryor, who Gus knows informally as someone connected to a methamphetamine distribution pipeline that runs up through Pecos County from the border. Terri is not under investigation. The stop may have been entirely innocent \u2014 she has family on that street, she could have had any reason. But Gus has watched that footage eleven times and the thirty minutes bothers him. He has not reported it. He has not asked her about it. He is sitting on it.",
|
||||||
|
"voice": "Damon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Anzelika",
|
||||||
|
"situation": "Anzelika is not calling about a crisis. She is calling because this morning she guided a solo float through the lower canyons of Santa Elena Canyon and encountered something she has never seen in three seasons on the river: the Rio Grande at that section ran completely still \u2014 no current, mirror-flat \u2014 for approximately a quarter mile, which she knows is hydrologically bizarre given the canyon's normal gradient and the current water levels. The water was clear to the bottom, which she has also never seen there. She photographed it. The stillness lasted about fifteen minutes before the current resumed as if nothing had happened. She asked the two geologists who were her clients \u2014 a couple from Socorro, New Mexico, on a research float \u2014 and they had no explanation either and were visibly agitated in a way she found exciting.",
|
||||||
|
"voice": "Bianca"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Merritt",
|
||||||
|
"situation": "Merritt's dilemma involves his oldest son, Caleb, thirty, who came back to Alpine two years ago after a decade in Midland working oilfield logistics. Caleb came back broke and Merritt brought him into the store. Six months ago Merritt let Caleb take over the books. Last week Merritt's accountant called for a routine question and let slip a number that didn't match what Caleb had been reporting. Merritt has now gone through four months of receipts himself. Caleb has been skimming \u2014 not dramatically, not recklessly, but steadily. About $14,000 over the six months. Merritt has not confronted Caleb. He has not told his wife. He has not told his accountant. He is calling tonight because Caleb is coming to Sunday dinner in five days and Merritt does not know how to sit across a table from him.",
|
||||||
|
"voice": "Marcus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fern",
|
||||||
|
"situation": "Fern is not calling about a crisis. She is calling because she spent this afternoon doing something she has never done in seventy-one years: she attended a Donald Judd art installation at the Chinati Foundation in Marfa, dragged there by her granddaughter who is visiting from Austin. Fern went in skeptical and came out genuinely, almost angrily moved by the aluminum boxes in the artillery sheds \u2014 not because she understood them intellectually but because standing in that specific light with those specific objects did something to her body that she could not predict and cannot explain. She is furious at herself for being affected and also cannot stop thinking about it.",
|
||||||
|
"voice": "Marlene"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Desmond",
|
||||||
|
"situation": "Desmond's dilemma happened three months ago and he has been carrying it under a professional smile since. He was on a search committee that hired a new colleague \u2014 Dr. Renata Voss, a wildlife ecologist who joined the department in May. Desmond was enthusiastic about her hire. Two weeks after she arrived, he realized, going through old conference proceedings for an unrelated paper, that a dataset in her job application research summary contained figures he recognized from a 2019 study by a team he worked with as a postdoc \u2014 figures that were not cited. The overlap is specific enough that it cannot be coincidence. He has not told the department chair. He has not told Voss. He has been watching her teach her first summer course and finding her genuinely excellent in the classroom, which is making it worse.",
|
||||||
|
"voice": "James"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Loretta",
|
||||||
|
"situation": "Loretta is calling because she is on a genuine, specific, long-running obsession and tonight something happened that cracked it back open. She has spent the last three years documenting every roadrunner she encounters on her delivery routes \u2014 not the cartoon, not Paisano Pete in Fort Stockton, actual greater roadrunners \u2014 photographing them, logging GPS coordinates, building a personal spreadsheet of sightings with behavioral notes. She has 847 documented sightings. Tonight, for the first time, she saw two roadrunners running in the same direction, side by side, for approximately sixty yards, at dusk on a ranch road outside Fort Davis. She has never seen this behavior. She has read everything available on roadrunner behavior and has found no documentation of this.",
|
||||||
|
"voice": "Joy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Concho",
|
||||||
|
"situation": "Concho is a true believer and he is calling dead serious. For the past eleven months he has been documenting what he believes is a coordinated, systematic effort to map and photograph every water source in the Trans-Pecos \u2014 stock tanks, springs, seeps, and seasonal plinths \u2014 by unmarked vehicles he has encountered on ranch roads where he works. He has logged twenty-two separate encounters. The vehicles are different each time: white pickup, gray SUV, a rented-looking sedan that had no business on a caliche road. They are always carrying survey equipment or cameras. They do not stop to talk. They are always gone before he can get to the nearest ranch gate. He does not believe this is government water surveying \u2014 he knows what that looks like, he has worked alongside USGS crews. He believes someone is building a comprehensive private inventory of every viable water source in this part of Texas ahead of a major acquisition or diversion action, and that the ranchers whose land these sources sit on do not know it is happening.",
|
||||||
|
"voice": "Theodore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wylene",
|
||||||
|
"situation": "Wylene's dilemma is four days old and involves the person she considers her closest friend in Alpine \u2014 a woman named Bex, twenty-eight, who is in the same nursing cohort. Last Friday night, after their pharmacology final, Wylene and Bex went to celebrate at a bar. Wylene, who does not drink heavily, had three beers and was not impaired. At some point in the night, Bex told her \u2014 laughing, confiding, clearly thinking it was funny \u2014 that she has been systematically copying Wylene's clinical assessment write-ups for the past semester, changing enough words to pass the similarity checker. Not occasionally. Every single one. Bex then changed the subject and they kept celebrating. Wylene drove home and has not slept properly since. They have a clinical rotation together starting next Monday. Bex has not mentioned it again.",
|
||||||
|
"voice": "Selene"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1785825905.7824361,
|
||||||
|
"lineup": [
|
||||||
|
{
|
||||||
|
"name": "Silas",
|
||||||
|
"situation": "The caravan is three vehicles \u2014 a converted school bus, a livestock trailer carrying the commune's goats, and Silas's truck \u2014 and they pulled off the road an hour ago because one of the founding members, a woman named Dove who has been with The Wellspring for nine years, announced at the Fort Stockton gas station that she is not getting back in the bus. She's sitting in the dirt by the road with her bag and says she wants to go back to Deming. The rest of the community is watching from the bus windows. Silas is standing fifty feet away, calling Luke from the dark, because he cannot figure out whether to respect her choice and let her go or whether watching her sit in the dirt on Highway 385 at midnight is its own form of coercion.",
|
||||||
|
"voice": "Sebastian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Darlene",
|
||||||
|
"situation": "Two weeks ago a guest checked in alone \u2014 a man in his late sixties, paid cash, gave a name Darlene is pretty sure was false, stayed four nights, barely left his room. On his last morning she found a legal-size envelope slid under the front desk with her name on it \u2014 not the hotel's name, her name, Darlene \u2014 containing $800 in cash and a handwritten note that said only: 'You were kind to me when I needed it. Don't look for me.' She has no memory of doing anything particular for this man. She smiled at him twice. She brought him an extra blanket one night without being asked. The $800 is sitting in her kitchen drawer and she cannot decide if she should keep it, report it to management, donate it, or if there is something darker she is missing about why a man hiding under a false name would leave a night auditor $800 cash.",
|
||||||
|
"voice": "Wendy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cutter",
|
||||||
|
"situation": "Cutter discovered six months ago that he has a genuine, inexplicable talent for finding water with a forked mesquite branch \u2014 dowsing \u2014 and it is destroying his academic identity. He found water twice on the ranch by accident, told no one, but last week the ranch foreman asked him to do it formally for a new trough location and he found it again, eighteen inches down, exactly where the stick pulled. He is a scientist. He believes in soil surveys and GPR imaging. He cannot explain what is happening to his hands and he is terrified of what it means that it keeps working.",
|
||||||
|
"voice": "Jake"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Esperanza",
|
||||||
|
"situation": "Esperanza's dilemma began eight days ago when her youngest son, Tom\u00e1s, twenty-six, told her he has been in a serious relationship for two years with a woman named Ingrid who is a Chinati Foundation artist-in-residence from Stockholm. Esperanza has no problem with Ingrid being Swedish. Her problem is that Tom\u00e1s has been lying to her face for two years, spending 'overnight work trips' with Ingrid in Marfa, and when she asked him why he hid it, he said he knew she would disapprove of 'the Marfa crowd.' He was not wrong. She does disapprove of the Marfa crowd. But she is sitting with the question of whether her known disapproval effectively coerced her own son into two years of lying, and whether that makes her responsible for the deception she is furious about.",
|
||||||
|
"voice": "Olivia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rowdy",
|
||||||
|
"situation": "Rowdy is calling because he has been trying, for the past three years, to get Paisano Pete \u2014 Fort Stockton's famous twenty-two-foot concrete roadrunner \u2014 declared an official Texas State Symbol, and he finally got a state representative to agree to file the bill, and tonight the rep called to say he'd found a co-sponsor and it is actually going to the floor. Rowdy has been drafting letters, driving to Austin twice, organizing a petition with four thousand signatures, and doing all of this while his wife thought he was mildly insane. He is calling because he is so happy he cannot sleep and everyone in his house is already asleep and he needs to tell somebody.",
|
||||||
|
"voice": "Grant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Neva",
|
||||||
|
"situation": "Neva was commissioned eight months ago to paint a large mural on the side of a building on Holland Avenue in Alpine \u2014 a landscape piece featuring the Chisos Mountains at dusk. The building owner, a man named Gerald Pruitt, was enthusiastic, paid a deposit, gave her full creative control. The mural is finished and it is, by any measure, the best work Neva has ever made. Last week Gerald told her he loves it but he has sold the building to a developer from Austin who plans to resurface the exterior. The mural will be painted over within sixty days. Gerald is apologetic but has no legal obligation to preserve it \u2014 Neva's contract, which she wrote herself without a lawyer, has no preservation clause. She is not calling about suing Gerald. She is calling because she has been offered, through a contact, an opportunity to document the mural removal on video and sell the footage to an arts journal that covers ephemeral public art \u2014 essentially profiting from the destruction of her own work \u2014 and she cannot decide if that is beautiful or a betrayal of what the mural was supposed to be.",
|
||||||
|
"voice": "Tessa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Holbrook",
|
||||||
|
"situation": "Holbrook's dilemma is about his closest friend of thirty years, a man named Dale Fitch who ranches east of Sanderson. Fourteen months ago Dale's wife died of a fast cancer \u2014 six weeks from diagnosis to burial. In the months after, Holbrook drove out to Dale's place constantly, helped with the ranch, sat with him. Three months ago Dale met a woman named Rhonda through a mutual friend \u2014 a widow herself from Ozona \u2014 and they have gotten serious fast. Last week Dale called Holbrook to say he and Rhonda are getting married in October, fourteen months after his wife's death. Holbrook's gut reaction was visceral \u2014 he said, on the phone, without thinking, 'Dale, that's too fast.' Dale went quiet and hung up. They have not spoken in a week. Holbrook knows grief is not on a schedule. He knows Rhonda may be the best thing to happen to Dale. But he also watched Dale's wife die and sat at her grave in July heat and something in him could not make the math work, and now he may have damaged a thirty-year friendship over a feeling he is not sure was even about Dale.",
|
||||||
|
"voice": "Rupert"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Petronella",
|
||||||
|
"situation": "Petronella is not calling about a crisis. She is calling because she spent this afternoon doing something she has been privately doing for eleven years and has never told another living soul: she is a meticulous, obsessive oral historian of Marfa's pre-Chinati past. She has recorded over three hundred hours of interviews with old-timers \u2014 ranch hands, the women who ran the Presidio County courthouse for decades, the men who remember when the Marfa Army Air Field was active, a woman who was present at the 1945 German POW escape from Camp Marfa. She has transcribed every word herself on a typewriter. Tonight she finished transcribing her final tape \u2014 an interview with a ninety-three-year-old woman named Eusebia Morales who died four months after the recording \u2014 and realized she has been doing this work alone for eleven years and has no plan for what happens to it when she dies.",
|
||||||
|
"voice": "Victoria"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Florentino",
|
||||||
|
"situation": "Florentino is a true believer and he is calling dead serious. For the past seven months he has been logging something that happens on the Rio Grande in a specific quarter-mile stretch of the river inside the lower canyons, always between 2am and 4am, always on nights with no moon: a sound he describes as structured, repeating, and not animal. Not the canyon acoustics he has known for nineteen years. Not a boat. A low, rhythmic tonal sequence \u2014 four notes, always the same interval, fading upstream \u2014 that he has now recorded on a waterproof field recorder on six separate occasions. He has ruled out every natural explanation he can generate. He has not told his employer. He has not posted it online. He has played the recordings to two people \u2014 a sound engineer in Alpine who said it was 'interesting' and changed the subject, and his cousin who told him to stop camping alone. He believes the sound is coming from inside the canyon walls.",
|
||||||
|
"voice": "Arjun"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mackley",
|
||||||
|
"situation": "Mackley's dilemma is five days old and involves a decision he made in about four seconds that he cannot stop replaying. He was working a Saturday night gas station shift when a woman came in visibly shaken \u2014 late thirties, out-of-state plates, would not make eye contact \u2014 and asked him quietly if she could use the phone because hers was dead. He let her use the store phone. She called someone, spoke in a low voice for about ninety seconds, and hung up. She bought a bottle of water and left. Forty minutes later a man came in \u2014 same approximate age, agitated, asked Mackley if a woman had been in. Mackley said no. The man left. Three days later Mackley saw a missing-persons flyer in the Alpine post office for a woman matching the description \u2014 reported missing by her husband, who is offering a reward. The flyer shows a phone number to call. Mackley is now sitting with the question of whether the woman he helped was someone running from something or someone who is actually missing and in danger, and whether his lie to the man \u2014 which felt right in the moment \u2014 may have contributed to either outcome.",
|
||||||
|
"voice": "Ethan"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"timestamp": 1785828045.742269,
|
||||||
|
"lineup": [
|
||||||
|
{
|
||||||
|
"name": "Breckenridge",
|
||||||
|
"situation": "Breckenridge has been keeping a secret from his supervisor at McDonald Observatory for eleven weeks. During a routine maintenance check on one of the Hobby-Eberly Telescope's secondary systems, he made an adjustment that was not in his work order \u2014 a small calibration tweak he was confident about \u2014 and two days later a scheduled research observation run produced corrupted data that cost a visiting doctoral team from UT Austin their entire week's work and forced them to reschedule months out. Nobody has connected the data loss to his unauthorized adjustment. The official finding blamed an atmospheric interference anomaly. He has let that finding stand.",
|
||||||
|
"voice": "Victor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Trudie",
|
||||||
|
"situation": "Trudie is not calling about a crisis. She is calling because this evening, while closing up the gallery, she discovered that a piece of art she has walked past every workday for two years \u2014 a small, seemingly blank white canvas in the back corner that she assumed was an unfinished or stored work \u2014 is actually a Donald Judd work that has been hanging there unlit, unlabeled, and unnoticed by everyone including the gallery's owner, who inherited the space and has never fully inventoried it. She found this out because a Chinati Foundation curator came in tonight for an unrelated errand, glanced at it while waiting, and said, very quietly, 'Where did that come from.'",
|
||||||
|
"voice": "Pippa"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Aurelio",
|
||||||
|
"situation": "Aurelio's dilemma has been building for two years and came to a head this past Sunday. His daughter, Celeste, thirty-three, is an immigration attorney in El Paso. She has, without telling him, been using his ranch's southeast pasture road \u2014 a private caliche track that runs close to the river \u2014 as part of a route she helps asylum-seeking families navigate to reach a designated port of entry legally. She was not sneaking people across; she was guiding them to Presidio's legal crossing. But she used his land without his permission. He found out Sunday when a Border Patrol agent he has known for twenty years stopped by and mentioned, carefully, that they'd been seeing foot traffic on that road and asked if Aurelio had authorized any humanitarian organization's access. Aurelio said no. He doesn't know if that answer protected Celeste or exposed her.",
|
||||||
|
"voice": "Edward"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jinx",
|
||||||
|
"situation": "Jinx is not calling about a crisis. She is calling because she has spent the last fourteen months obsessively studying the black bear population recolonizing the Davis Mountains, and tonight, during a camera trap check on a ranch north of Alpine, she retrieved footage that she believes shows two black bears engaging in play behavior with a third animal she cannot identify from the footage \u2014 not a bear, not a mountain lion, not a javelina, not a coyote. The shape and gait are wrong for everything native. She has watched the forty-eight-second clip approximately thirty times tonight.",
|
||||||
|
"voice": "Avery"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Parnell",
|
||||||
|
"situation": "Parnell's dilemma is eleven days old. He was doing a plumbing repair job at a rental property on the edge of Marathon \u2014 a small adobe that's been rented to the same quiet tenant for four years \u2014 and found, while accessing a crawl space beneath the bathroom, a locked steel box bolted to the foundation. He is a retired federal postal inspector. He knows what hidden locked boxes mean and what they don't mean. He did not open it. He finished the job. But he cannot stop thinking about it. The tenant \u2014 a man named Reed, early fifties, polite, pays cash through a property manager \u2014 has never given Parnell any concrete reason for suspicion beyond the box itself. The property manager says Reed is never any trouble.",
|
||||||
|
"voice": "Graham"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Twyla",
|
||||||
|
"situation": "Twyla's dilemma is about her ex-boyfriend, Cade, who is not an ex-boyfriend in the ordinary sense \u2014 they dated for six weeks four years ago and it ended cleanly. Cade is now her closest male friend and they guide trips together regularly. Three weeks ago Cade told Twyla, in confidence, that he has been diagnosed with early-onset Parkinson's. He is thirty-nine. He has not told anyone else in Terlingua, including their mutual employer, the rafting outfitter. Last week Twyla watched Cade's hand shake badly enough during a Class III rapid on the Rio Grande that a client noticed and asked if he was okay. Cade said he'd had too much coffee. Twyla said nothing. She does not know how long she can keep saying nothing while clients are in his raft.",
|
||||||
|
"voice": "Nadia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Herschel",
|
||||||
|
"situation": "Herschel is a true believer and he is calling dead serious. For the past two years he has been compiling evidence that the Comanche Springs \u2014 the natural springs that once made Fort Stockton a vital water stop and were declared legally dead in 1961 when overpumping from irrigation wells dried them up \u2014 are still flowing. Underground. He believes the springs did not die; they were diverted by a combination of agricultural pumping and, he is now convinced, a deliberate 1950s-era channeling project conducted by a consortium of Pecos County landowners who wanted the water routed to private cisterns rather than the public spring pool. He has deed records, pump logs from the county clerk's archive, and a hand-drawn map from 1953 he found in a box of Annie Riggs Museum donations.",
|
||||||
|
"voice": "Dennis"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Salome",
|
||||||
|
"situation": "Salome's dilemma is six days old and involves her professor, Dr. Edmunds \u2014 a tenured history faculty member at Sul Ross she genuinely admires \u2014 and a paper she submitted for his upper-division Texas borderlands course. The paper was entirely her own work. But she used a research methodology she developed by closely following a framework from a graduate thesis she found in the Sul Ross library archives \u2014 a 1987 thesis by a student named M. Castillo. The thesis is unpublished and uncatalogued in any digital system. She cited it in her bibliography. Dr. Edmunds called her in last Thursday and told her, with evident discomfort, that the methodology section of her paper was 'derivative to a degree that concerns him.' He did not say plagiarism. He said he needed to think about how to proceed. She has not heard from him since.",
|
||||||
|
"voice": "Julia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dobe",
|
||||||
|
"situation": "Dobe is not calling about a crisis. He is calling because he has spent the last four years, on every single westbound run on Highway 90, stopping at the same pullout between Marathon and Alpine to watch the pronghorn antelope that live in the grasslands north of the road. He has become, without any formal training, an obsessive amateur naturalist focused entirely on this one pronghorn herd. He knows individual animals by sight. He has named them. He has logged their movements, seasonal patterns, and herd composition in a series of spiral notebooks he keeps in his cab. Tonight he stopped at the pullout and counted a herd size he has never seen before \u2014 thirty-one animals \u2014 and he needs to tell somebody.",
|
||||||
|
"voice": "Brian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cassander",
|
||||||
|
"situation": "Cassander's dilemma is seven months old and has been slowly poisoning his sleep. He was hired to do the electrical rough-in on a major renovation of a historic adobe property on the edge of Marfa \u2014 a project backed by an out-of-town buyer who is converting it into a high-end short-term rental. During the work, Cassander found, inside a sealed wall cavity, a tin box containing what appears to be a handwritten ledger and several photographs dating to approximately the 1940s. The ledger records, in careful columns, what Cassander \u2014 after considerable research \u2014 believes are payments made to a Presidio County sheriff's deputy over several years, consistent with protection payments for a smuggling operation across the Rio Grande. The names in the ledger include surnames still prominent in Marfa and Presidio County today.",
|
||||||
|
"voice": "Callum"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"emails": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,928 @@
|
|||||||
|
{
|
||||||
|
"lookup_history": [
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked it up, and a lot of the prevention tips involve things like etching your converter with your V I N, or even installing anti-theft devices specifically designed to make them harder to rem",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785826843.886055
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked it up, and while the numbers are down from their peak in twenty twenty two, there were still over twenty six,000 catalytic converter thefts in the first half of twenty twenty three alone",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785826893.988194
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked it up, and while the peak was in twenty twenty two, there were still over fourteen,000 catalytic converter thefts in twenty twenty four, and the average insurance claim payout rose to tw",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785826927.249883
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's pretty wild, I just looked it up, and rhodium, platinum, and palladium can actually be worth more per gram than gold.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785826964.304747
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Rhodium is actually a very rare noble metal and a member of the platinum group, and it's also used for enhancing jewelry, which is pretty interesting for such an industrial-sounding element.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827014.911605
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Yeah, it's super rare, but only a tiny amount is needed per catalytic converter, and with millions of cars, it adds up to a lot of demand.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827062.0185962
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked it up on Wikipedia, and rhodium is a very rare, silvery-white, hard, and corrosion-resistant transition metal, and it's considered one of the rarest and most valuable precious metals.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827081.698578
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's actually down to four hundred ten people as of the twenty twenty census, so even fewer people than you'd think.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827128.468648
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's actually down to four hundred ten people as of the twenty twenty census, so even fewer people than you'd think.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827145.076851
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's actually down to four hundred ten people as of the twenty twenty census, so even fewer people than you'd think.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827206.8894
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Wait, I just looked up Marathon, Texas, and the population was actually four hundred ten in the twenty twenty census, so it's even smaller than you thought.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827223.5801878
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked up rhodium again, and it's actually one of the rarest and most valuable precious metals, even more so than gold or platinum.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827272.232238
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Wait, I just looked up the Gage Hotel, and they actually have twenty seven acres of gardens with trees and plants, so there would definitely be leaves to rake.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827324.127794
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Wait, so I just looked up the Marfa lights on Wikipedia and it says scientists think it's mostly car headlights distorted by warm desert air.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827452.788874
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked up other theories about the Marfa lights, and besides car headlights, people also suggest ball lightning, swamp gas, or even aliens.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827485.3444
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked up that U T D study and it was done by physics students in two thousand four and two thousand five, and they actually concluded the lights were likely car headlights from a highway a",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827517.798142
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Yeah, so the Wikipedia article also says the lights are most often seen from a specific viewing area that the community has publicized to encourage tourism.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827579.624702
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "The Chinati Mountains, where the lights were seen, have a peak elevation of 7,seven hundred twenty eight feet, which is pretty substantial.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785827598.3226361
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked up Mitchell Flat, and it's actually described as where the Marfa Lights are most often seen, and it's known for being incredibly flat.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828035.960554
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "The Wikipedia article also mentions that the Marfa lights are described as an \"optical phenomenon\" and that scientists concluded they were car headlights distorted by warm desert air.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828097.7092211
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I'm seeing that the Wikipedia article mentions scientists concluded the lights were car headlights, but also that they're described as an \"optical phenomenon.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828133.214231
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked up plasma, and it's actually the most common state of matter in the universe, making up 99.9 percent of all ordinary matter. Stars are basically pure plasma.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828195.82052
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked up plasma, and it's actually the most common state of matter in the universe, making up 99.9 percent of all ordinary matter. Stars are almost pure balls of plasma, and plasma dominat",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828257.351542
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "The University of Texas at Dallas study on the Marfa Lights is actually from two thousand four, and it concluded that car headlights on Highway sixty seven were the cause, but it also mentioned atmosp",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828305.564495
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, the Chinati Mountains are actually near Presidio, Texas, and there's a pass through them called Pinto Canyon Road.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828322.57601
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I'm seeing that the Wikipedia article mentions scientists concluded the lights were car headlights, but also that they're described as an \"optical phenomenon.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828384.944525
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "The Wikipedia article also mentions that the Marfa lights are an optical phenomenon regularly observed near Marfa, Texas, and that the community has publicized a viewing area to encourage tourism.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828418.451157
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "That's wild that they've been reported since the 1880s, before cars were even around, which totally debunks that theory about headlights.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828469.318658
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's interesting that there are so many different theories about the Marfa lights, from scientific explanations to more supernatural ideas.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828501.896791
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I'm seeing that accounts of the Marfa Lights often describe them as floating orbs\u2014white, yellow, or orange\u2014drifting or flickering, and sometimes multiple lights are seen simultaneously.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828537.8336582
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "That's wild, I'm seeing that some accounts of the Marfa Lights mention them splitting apart, merging, or darting erratically, and that they can be white, yellow, orange, or even red.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828571.164865
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked it up, and the \"plasma vortex\" idea is often linked to the ball lightning theory, which is one of the leading scientific explanations for the Marfa Lights.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828588.682946
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked it up, and while the \"plasma vortex\" is a theory, other scientific explanations include atmospheric refraction, piezoelectric effects from tectonic stress, and even bioluminescent animal",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828606.138984
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's cool that there's an official viewing area nine miles east of Marfa on Highway ninety, so people can actually go and try to see these things.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828623.652162
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "That's a good question, because most bioluminescent animals are marine creatures or insects like fireflies, which aren't typically found in deserts.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828656.054026
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Yeah, I'm seeing that most bioluminescent animals are marine creatures or insects like fireflies, and there aren't many known bioluminescent desert animals.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828674.141393
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked it up, and there are actually over forty five species of bioluminescent beetles, including fireflies, across Texas, and some click beetles also exhibit bioluminescence in warm desert",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828708.354514
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Wait, I just looked this up, and apparently, the most common scorpion in Texas, the Striped Bark Scorpion, actually glows under U V light, but it's not bioluminescent in the way we're talking about.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828729.464768
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Wait, I just looked this up, and there are actually over forty-five species of bioluminescent beetles, including fireflies, across Texas, and some click beetles also glow.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828746.4883661
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and apparently, there are also bioluminescent click beetles in Texas, and some of them are found in warm deserts.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828797.722466
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's funny you mention Marfa, because a study from two thousand to two thousand eight concluded that the famous Marfa lights were actually just car headlights distorted by warm desert air.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828845.140897
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Well, I just looked up the Marfa lights on Wikipedia, and it says a study from two thousand to two thousand eight concluded they were actually just car headlights being distorted by warm desert air.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828863.2232692
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Yeah, so I just looked up Marfa lights on Wikipedia, and it says a study from two thousand to two thousand eight concluded they were actually just car headlights being distorted by warm desert air.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828911.314851
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked up Marfa lights on Wikipedia, and it says a study from two thousand to two thousand eight concluded they were actually just car headlights being distorted by warm desert air.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828928.774646
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "That's interesting about the Chinati Mountains, because I just looked it up and some people think the name comes from an Apache word meaning \"gate\" or \"mountain pass.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785828981.5359628
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "That's wild, I just looked up \"rhodium mining extraterrestrials\" and apparently, there are theories about asteroids being rich in platinum group metals like rhodium, making them potential targets for ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785829089.7105548
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Rhodium is one of the rarest and most valuable precious metals, and it's used for enhancing jewelry.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785829106.427058
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "That's interesting about the Chinati Mountains, because I just looked it up and some people think the name comes from an Apache word meaning \"gate\" or \"mountain pass.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785829138.475531
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's wild how common these meth pipelines are, I'm seeing headlines about busts from San Antonio to the Capital Region, and even Little Rock.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785829379.4757051
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's wild how common these meth pipelines are, I'm seeing headlines about busts from San Antonio to the Capital Region, and even Little Rock.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785829396.890264
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Yeah, it's crazy how much meth is coming across the border. I just",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785829467.9567099
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's interesting how much dash cam footage is coming up now in discussions about police integrity. it seems like it's becoming a key piece of evidence in a lot of situations.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785829557.185249
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Yeah, I just looked it up, and unfortunately, there are quite a few cases",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785829640.7913432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked up police dash cam purge schedules, and it really varies a lot by department. some keep footage for months, others much less, but twenty four days is definitely within the normal ran",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785829786.899904
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Wait, Sul Ross State University, that's in Alpine, Texas, right? It's a public university and the main one serving the Big Bend region.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785829874.400875
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Alpine, Texas, where the caller's from, is also home to Sul Ross State University, and it's considered the center of the whole Big Bend area.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785829984.276145
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked up oilfield logistics salaries in Midland, and while it varies a lot, the average is around 50000 dollars to 70000 dollars, but some jobs pay way more, like over 100000 dollars for o",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785830017.110743
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked up Alpine, Texas, and it's considered the commercial center of the whole Big Bend region, which is pretty vast at twelve,000 square miles.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785830051.735072
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It sounds like this isn't an uncommon way for agricultural businesses to get scammed, with fake invoices and altered orders.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785830117.232788
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's actually pretty common for employee fraud, especially things like check tampering or cash larceny, to be twice as frequent in small businesses compared to larger ones.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785830172.486618
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's a tough situation, and I just looked up some stats: employee theft costs United States businesses anywhere from twenty dollars to fifty dollars billion a year, and 75 percent of employees have ad",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785830196.3361979
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "This is tough, but I just looked up common reasons for employee theft, and it often comes down to financial pressure, opportunity, or rationalization.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785830263.81376
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and research suggests kids remember family vacations between ages 5 and ten more vividly than birthdays.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785830296.9526458
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "That's actually really interesting, I just looked it up and studies show that kids between 3 and 5 often believe the party itself causes their birthday, which is a wild concept.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785830316.129567
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and apparently wild donkeys and horses dig wells that provide water for other desert animals, and elephants have mental maps of water sources.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785830988.662511
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "That's actually really interesting, I just looked it up and while there are government",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831025.150166
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and while there are government resources for land and parcel data in Texas, the Texas Geographic Information Office specifically mentions that the purpose of their initiativ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831081.0623941
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and caliche is a sedimentary rock formed in arid regions that's often used as a base for roads, which explains why it's out in the middle of nowhere.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831114.710515
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and the Marathon Basin in Texas, where the caller saw the rangefinder, relies heavily on two municipal wells for its water supply, according to a U S Geological Survey repor",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831172.441345
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and Sanderson, Texas is known as the \"Cactus Capital of Texas\" because it's at the convergence of three ecological zones.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831273.021059
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Wait, so I just looked this up, and in Texas, surface water is actually publicly owned by the state, even if it's on private land, which means anyone could potentially be monitoring it.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831309.009842
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and Texas has pretty strong private property rights, and landowners can definitely take action against trespassers, especially if they've posted proper notice.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831352.628969
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and the \"rule of capture\" in Texas groundwater law essentially means if you can pump it, you own it, which is why it's sometimes called the \"law of the biggest pump.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup",
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831396.294291
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and prior appropriation in Texas means \"first in time, first in right,\" so older water rights get priority over newer ones, especially when water is scarce.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831453.387682
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and there's actually a Texas Water Bank and Trust that allows for the acquisition of water rights, and the Texas Water Development Board has a registry of buyers.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831501.522455
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's interesting, I just looked this up, and there's a Dallas-based company that recently sued an East Texas water district to develop high-capacity water wells, which sounds a lot like what the calle",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831543.98968
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and there were a lot of significant water rights cases and rulings around two thousand nine-twenty twelve in Texas, many of them dealing with landowner rights to groundwater",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831580.055219
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "You know, I just looked up Brewster County water issues and there's a lot of stuff about the Rio Grande disappearing and disputes over pipelines, so this caller is definitely hitting on a real concern",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831619.753919
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "There are actually a good number of law firms in Texas that specialize in water rights, so hopefully, someone with that specific expertise is listening.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831645.18117
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "This is serious, I just looked it up and research misconduct can lead to federal debarment, grant termination, and even retraction of published work, which would really mess up a career.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831729.6552792
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Wait",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831769.424918
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Mule deer are actually named that because their ears are big, like a mule's!",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831812.701477
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and there can be consequences for research misconduct at conferences, like being banned from future events, and it definitely falls under the federal definition of research ",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831900.550103
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked this up, and plagiarism at academic conferences can lead to serious professional consequences, not just academic ones.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785831965.298838
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "The Trans-Pecos region is actually part of the Chihuahuan Desert, and it's known for being the most mountainous and arid part of Texas.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832034.569338
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "There are tons of academic conferences in November, so it makes sense that he'd be worried about her presenting there, especially if it's a big one.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832217.653148
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, a postdoc supervisor at a big",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832248.6387148
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "So, I just looked this up, and Texas A and M has a whole Office of Postdoctoral Affairs that advocates for postdocs and even has a research compliance manager, so they definitely take research integri",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832323.630293
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Wait, so I just looked up \"aluminum boxes\" and there are a ton of different kinds, from storage boxes for home to heavy-duty transport containers.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832400.2359738
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Wait, so Donald Judd is considered the leading international exponent of \"minimalism\" and he wrote this essay called \"Specific Objects\" where he argued for a \"rigorously democratic presentation withou",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832425.40215
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's wild how much Donald Judd's work is tied to Marfa. he actually moved there permanently in the early 1970s and bought up a bunch of land and buildings to install his art.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832470.6274052
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked up the Chinati Foundation, and it's actually an art museum based on Donald Judd's ideas, which explains why his work is so central to it.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832512.547478
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "It's interesting because Judd actually wrote a lot about how he didn't like the term \"minimalism\" and preferred \"specific objects\" to describe his art.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832529.594274
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "He actually really disliked the term \"minimalism\" and preferred to call his work \"specific objects\" because he felt it was more accurate.",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832577.829215
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I just looked up Donald Judd's \"Specific Objects\" essay, and he actually argued for a \"rigorously democratic presentation without compositional hierarchy,\" which really fits with what the caller is sa",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832620.3931022
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "That's interesting, because Judd actually said in an Arts Yearbook that \"The new three dimensional work doesn't constitute a movement, school, or style. The common aspects are too general and too litt",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832639.370713
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "You know, it's actually 3 37 am right now, so Luke's even more off than he thought!",
|
||||||
|
"tools_used": [
|
||||||
|
"get_current_time"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832655.678344
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "I was just reading about Judd, and he actually said that the \"new three-dimensional work doesn't constitute a movement, school, or style,\" even though he's considered the leading exponent of minimalis",
|
||||||
|
"tools_used": [
|
||||||
|
"wikipedia_lookup"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832777.890113
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "(interjection)",
|
||||||
|
"answer": "Yeah, Judd actually wrote a whole essay in nineteen sixty four called \"Specific Objects\" where he laid out why he felt his work went beyond just painting or sculpture.",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832809.734914
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"question": "Yeah, there were a couple interesting things in there. I'm sort of more intrigued about the Marfa lights now. I have been for a while. I've never stopped there because I always see like 20 cars in the parking lot, and it's just, I don't want to stop with all of that. But I do want to see the Marfa lights and what that's all about.",
|
||||||
|
"answer": "Yeah, the Marfa lights have been around since at least one thousand eight hundred eighty three when this cowhand first wrote about seeing them out near Paisano Pass. I get the crowd thing though, it's",
|
||||||
|
"tools_used": [
|
||||||
|
"web_search"
|
||||||
|
],
|
||||||
|
"timestamp": 1785832880.349965
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conversation_history": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "So, I just looked this up, and there were a lot of significant water rights cases and rulings around two thousand nine-twenty twelve in Texas, many of them dealing with landowner rights to groundwater."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "You know, I just looked up Brewster County water issues and there's a lot of stuff about the Rio Grande disappearing and disputes over pipelines, so this caller is definitely hitting on a real concern for the area."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "There are actually a good number of law firms in Texas that specialize in water rights, so hopefully, someone with that specific expertise is listening."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "This is serious, I just looked it up and research misconduct can lead to federal debarment, grant termination, and even retraction of published work, which would really mess up a career."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Wait"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Mule deer are actually named that because their ears are big, like a mule's!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "So, I just looked this up, and there can be consequences for research misconduct at conferences, like being banned from future events, and it definitely falls under the federal definition of research misconduct."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "I just looked this up, and plagiarism at academic conferences can lead to serious professional consequences, not just academic ones."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "The Trans-Pecos region is actually part of the Chihuahuan Desert, and it's known for being the most mountainous and arid part of Texas."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "There are tons of academic conferences in November, so it makes sense that he'd be worried about her presenting there, especially if it's a big one."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "So, a postdoc supervisor at a big"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "So, I just looked this up, and Texas A and M has a whole Office of Postdoctoral Affairs that advocates for postdocs and even has a research compliance manager, so they definitely take research integrity seriously there."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Wait, so I just looked up \"aluminum boxes\" and there are a ton of different kinds, from storage boxes for home to heavy-duty transport containers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Wait, so Donald Judd is considered the leading international exponent of \"minimalism\" and he wrote this essay called \"Specific Objects\" where he argued for a \"rigorously democratic presentation without compositional hierarchy."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "It's wild how much Donald Judd's work is tied to Marfa. he actually moved there permanently in the early 1970s and bought up a bunch of land and buildings to install his art."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "I just looked up the Chinati Foundation, and it's actually an art museum based on Donald Judd's ideas, which explains why his work is so central to it."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "It's interesting because Judd actually wrote a lot about how he didn't like the term \"minimalism\" and preferred \"specific objects\" to describe his art."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "He actually really disliked the term \"minimalism\" and preferred to call his work \"specific objects\" because he felt it was more accurate."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "I just looked up Donald Judd's \"Specific Objects\" essay, and he actually argued for a \"rigorously democratic presentation without compositional hierarchy,\" which really fits with what the caller is saying about the art just waiting for you."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "That's interesting, because Judd actually said in an Arts Yearbook that \"The new three dimensional work doesn't constitute a movement, school, or style. The common aspects are too general and too little common to define a movement. The differences are greater than the similarities."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "You know, it's actually 3 37 am right now, so Luke's even more off than he thought!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "I was just reading about Judd, and he actually said that the \"new three-dimensional work doesn't constitute a movement, school, or style,\" even though he's considered the leading exponent of minimalism."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Yeah, Judd actually wrote a whole essay in nineteen sixty four called \"Specific Objects\" where he laid out why he felt his work went beyond just painting or sculpture."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What do you think of tonight's show, Devon? How do we do?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "I thought the show was solid tonight. The Judd discussion really landed, and you kept it moving without getting too bogged down in the theory. I don't know, I feel like we actually hit something interesting there for once."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Yeah, there were a couple interesting things in there. I'm sort of more intrigued about the Marfa lights now. I have been for a while. I've never stopped there because I always see like 20 cars in the parking lot, and it's just, I don't want to stop with all of that. But I do want to see the Marfa lights and what that's all about."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Yeah, the Marfa lights have been around since at least one thousand eight hundred eighty three when this cowhand first wrote about seeing them out near Paisano Pass. I get the crowd thing though, it's the same reason I never stop at those roadside attractions even though we're basically right there in Alpine. If you ever want me to pull up the latest theories on what they actually are, just say the word."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Well, I think I want to go see him for myself first, and then we'll talk about them more, about what I actually see. So I can describe it in my own unique and verbose way."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Yeah, that makes sense. Better to go in with your own eyes first before I start throwing a bunch of explanations at you. I don't know, I feel like the Marfa lights are one of those things where the less you know going in, the better the story ends up being."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": "--- NEW SHOW STARTING --- The previous show is over. A brand new episode is about to begin. Everything before this point was a previous show. Don't reference the previous show's callers or topics unless Luke brings them up. Fresh energy, clean slate."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
{
|
||||||
|
"31": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-08T10:00:02.383893+00:00",
|
||||||
|
"episode_id": "33",
|
||||||
|
"slug": "episode-31-boundaries-breakups-and-borrowed-books"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-08T10:14:48.779760+00:00"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-08T10:36:24.024343+00:00",
|
||||||
|
"video_id": "KcGb9ttsIpo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-08T10:00:02.383873+00:00"
|
||||||
|
},
|
||||||
|
"32": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-09T09:43:52.540238+00:00",
|
||||||
|
"episode_id": 34,
|
||||||
|
"slug": "episode-32-tacos-taxes-and-tall-tales"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-09T09:43:52.540200+00:00"
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-11T11:34:31.871604+00:00",
|
||||||
|
"episode_id": "36",
|
||||||
|
"slug": "episode-33-late-night-confessions-and-cosmic-comedies"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-11T11:50:49.212276+00:00",
|
||||||
|
"video_id": "KrJshN3cuBU"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-11T11:50:53.641920+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-09T10:18:16.606019+00:00"
|
||||||
|
},
|
||||||
|
"34": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-12T07:04:34.974478+00:00",
|
||||||
|
"episode_id": "37",
|
||||||
|
"slug": "episode-34-hidden-rooms-potlucks-and-mysterious-notes"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-12T07:17:53.453882+00:00",
|
||||||
|
"video_id": "tNcABnYpf-c"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-12T07:17:57.131871+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-12T07:04:34.974425+00:00"
|
||||||
|
},
|
||||||
|
"35": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-13T11:19:41.765107+00:00",
|
||||||
|
"episode_id": 38,
|
||||||
|
"slug": "episode-35-midnight-confessions-and-unexpected-revelations"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-13T11:42:00.428623+00:00",
|
||||||
|
"video_id": "fYvXLqFilLQ"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-13T11:42:11.800641+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-13T11:19:41.765079+00:00"
|
||||||
|
},
|
||||||
|
"36": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-14T12:01:15.758700+00:00",
|
||||||
|
"episode_id": "39",
|
||||||
|
"slug": "episode-36-late-night-confessions-and-unexpected-moments"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-14T12:25:36.640461+00:00",
|
||||||
|
"video_id": "BabWoKFt0pk"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-14T12:25:44.192676+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-14T12:01:15.758670+00:00"
|
||||||
|
},
|
||||||
|
"37": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-15T09:07:26.659541+00:00",
|
||||||
|
"episode_id": "40",
|
||||||
|
"slug": "episode-37-secrets-lies-and-coffee-runs"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-15T09:33:48.299549+00:00",
|
||||||
|
"video_id": "XW7Q0WPzNqY"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-15T09:34:02.069440+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-15T09:07:26.659508+00:00"
|
||||||
|
},
|
||||||
|
"38": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-16T10:08:51.467004+00:00",
|
||||||
|
"episode_id": 41,
|
||||||
|
"slug": "episode-38-secrets-lies-and-late-night-confessions"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-16T10:30:31.775891+00:00",
|
||||||
|
"video_id": "6qLsJxnCLs0"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-16T10:30:41.220832+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-16T10:08:51.466898+00:00"
|
||||||
|
},
|
||||||
|
"39": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-18T09:44:16.826717+00:00",
|
||||||
|
"episode_id": "42",
|
||||||
|
"slug": "episode-39-st-patrick-s-day-chaos-and-caller-confessions"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-18T10:00:28.370475+00:00",
|
||||||
|
"video_id": "iSI1NXW2y9g"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-18T10:00:35.226643+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-18T09:44:16.826690+00:00"
|
||||||
|
},
|
||||||
|
"40": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-19T08:07:35.720887+00:00",
|
||||||
|
"episode_id": 43,
|
||||||
|
"slug": "episode-40-prostate-cancer-christmas-lights-and-potato-salad-betrayals"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-19T08:30:25.222815+00:00",
|
||||||
|
"video_id": "TS06OLz6SPo"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-19T08:30:33.446060+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-19T08:07:35.720850+00:00"
|
||||||
|
},
|
||||||
|
"41": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-20T08:18:04.128449+00:00",
|
||||||
|
"episode_id": 44,
|
||||||
|
"slug": "episode-41-benny-s-creepy-vhs-tape-from-nowhere"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-20T08:39:36.047901+00:00",
|
||||||
|
"video_id": "XKRWldOh6JM"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-20T08:39:42.538777+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-20T08:18:04.128412+00:00"
|
||||||
|
},
|
||||||
|
"42": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-21T11:50:48.656769+00:00",
|
||||||
|
"episode_id": "45",
|
||||||
|
"slug": "episode-42-peggy-s-day-trading-disaster-and-the-nonprofit-yacht"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-21T12:07:10.326330+00:00",
|
||||||
|
"video_id": "x8b4SpkxQZ4"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-21T12:07:16.614504+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-21T11:50:48.656724+00:00"
|
||||||
|
},
|
||||||
|
"43": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-23T06:32:55.148064+00:00",
|
||||||
|
"episode_id": "46",
|
||||||
|
"slug": "episode-43-cousin-rufus-and-the-locksmith-s-wild-weekend"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-23T06:56:08.969398+00:00",
|
||||||
|
"video_id": "JI4vgl7Oa0A"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-23T06:56:36.930530+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-23T06:32:55.147339+00:00"
|
||||||
|
},
|
||||||
|
"44": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-24T07:06:50.998787+00:00",
|
||||||
|
"episode_id": "47",
|
||||||
|
"slug": "episode-44-floyd-s-fence-and-the-cattle-conspiracy"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-24T07:25:09.652339+00:00",
|
||||||
|
"video_id": "Ol6lwe40YwA"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-24T07:25:16.820530+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-24T07:06:50.998433+00:00"
|
||||||
|
},
|
||||||
|
"45": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-26T08:21:30.254496+00:00",
|
||||||
|
"episode_id": "48",
|
||||||
|
"slug": "episode-45-potato-salad-aliens-and-the-dripping-faucet-of-doom"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-26T08:39:45.353948+00:00",
|
||||||
|
"video_id": "YZeERny3WrE"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-26T08:39:52.864007+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-26T08:21:30.254466+00:00"
|
||||||
|
},
|
||||||
|
"46": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-30T09:13:10.855462+00:00",
|
||||||
|
"episode_id": "49",
|
||||||
|
"slug": "episode-46-butchers-wreak-forever-and-other-late-night-confessions"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-30T09:35:27.776133+00:00",
|
||||||
|
"video_id": "iXf5MmEO7P0"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-30T09:35:40.601815+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-30T09:13:10.855432+00:00"
|
||||||
|
},
|
||||||
|
"47": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-03-31T05:25:29.165685+00:00",
|
||||||
|
"episode_id": "50",
|
||||||
|
"slug": "episode-47-clarence-randy-and-the-onlyfans-daughter"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-03-31T05:46:35.296876+00:00",
|
||||||
|
"video_id": "Z2dPvt28HYg"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-03-31T05:46:42.153090+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-03-31T05:25:29.165641+00:00"
|
||||||
|
},
|
||||||
|
"48": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-04-02T04:37:08.410107+00:00",
|
||||||
|
"episode_id": "51",
|
||||||
|
"slug": "episode-48-potato-salad-legacy-and-the-april-fool-s-apocalypse"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-04-02T04:54:42.078977+00:00",
|
||||||
|
"video_id": "CyJP_QYEDj4"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-04-02T04:54:48.249952+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-04-02T04:37:08.410056+00:00"
|
||||||
|
},
|
||||||
|
"49": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-04-06T01:14:33.342179+00:00",
|
||||||
|
"episode_id": 52,
|
||||||
|
"slug": "episode-49-silas-s-shared-intimacy-night-and-four-brave-souls"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-04-06T03:38:50.148563+00:00",
|
||||||
|
"video_id": "-3zf-gw-6gA"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-04-06T17:29:46.487867+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-04-06T01:14:33.342137+00:00"
|
||||||
|
},
|
||||||
|
"50": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-04-10T07:57:35.570851+00:00",
|
||||||
|
"episode_id": "53",
|
||||||
|
"slug": "episode-50-deb-s-mounted-jackrabbit-and-seven-years-of-silence"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-04-10T08:16:37.495030+00:00",
|
||||||
|
"video_id": "V18y85BxuZI"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-04-10T08:16:45.539848+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-04-10T07:57:35.570779+00:00"
|
||||||
|
},
|
||||||
|
"52": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-04-16T09:18:45.701330+00:00",
|
||||||
|
"episode_id": "55",
|
||||||
|
"slug": "episode-52-dexter-s-construction-corner-and-the-whistleblower-s-dilemma"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-04-16T09:22:31.402285+00:00",
|
||||||
|
"video_id": "G4_hyAdODfc"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-04-16T09:22:39.384918+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-04-16T09:18:45.701295+00:00"
|
||||||
|
},
|
||||||
|
"53": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-04-28T08:18:23.512676+00:00",
|
||||||
|
"episode_id": "56",
|
||||||
|
"slug": "episode-53-the-hospice-nurse-s-impossible-choice"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-04-28T08:36:05.701557+00:00",
|
||||||
|
"video_id": "pRZ9T1xHh8Y"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-04-28T08:36:13.386695+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-04-28T08:18:23.512621+00:00"
|
||||||
|
},
|
||||||
|
"54": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-05-06T07:01:50.674724+00:00",
|
||||||
|
"episode_id": "57",
|
||||||
|
"slug": "episode-54-deb-s-roadrunner-and-the-hospice-nurse-s-dilemma"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-05-06T07:19:49.840084+00:00",
|
||||||
|
"video_id": "4uGCi4XcLLw"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-05-06T07:19:57.484472+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-05-06T07:01:50.674667+00:00"
|
||||||
|
},
|
||||||
|
"55": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-05-12T06:49:28.626817+00:00",
|
||||||
|
"episode_id": 58,
|
||||||
|
"slug": "episode-55-the-wellspring-s-haunting-social-architecture"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-05-12T07:10:52.080949+00:00",
|
||||||
|
"video_id": "K31eXqjFChM"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-05-12T07:11:01.134597+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-05-12T06:49:28.626772+00:00"
|
||||||
|
},
|
||||||
|
"56": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-05-21T08:50:34.758443+00:00",
|
||||||
|
"episode_id": "59",
|
||||||
|
"slug": "episode-56-the-tattoo-that-saved-a-life"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-05-21T09:09:08.740298+00:00",
|
||||||
|
"video_id": "EDY2Srb778E"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-05-21T09:09:17.569025+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-05-21T08:50:34.758386+00:00"
|
||||||
|
},
|
||||||
|
"57": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-06-02T11:32:44.749492+00:00",
|
||||||
|
"episode_id": "60",
|
||||||
|
"slug": "episode-57-trace-s-box-of-family-secrets"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-06-02T11:45:50.938869+00:00",
|
||||||
|
"video_id": "yAMz7Yy5_2k"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-06-02T12:16:42.059793+00:00"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-06-02T11:32:44.749472+00:00"
|
||||||
|
},
|
||||||
|
"58": {
|
||||||
|
"steps": {
|
||||||
|
"castopod": {
|
||||||
|
"completed_at": "2026-08-04T09:22:36.468634+00:00",
|
||||||
|
"episode_id": 61,
|
||||||
|
"slug": "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
|
||||||
|
},
|
||||||
|
"social": {
|
||||||
|
"completed_at": "2026-08-04T09:37:08.440981+00:00"
|
||||||
|
},
|
||||||
|
"youtube": {
|
||||||
|
"completed_at": "2026-08-04T09:57:26.006045+00:00",
|
||||||
|
"video_id": "NX6ZUWnnUVQ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"started_at": "2026-08-04T09:22:36.468552+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
{
|
||||||
|
"regulars": [
|
||||||
|
{
|
||||||
|
"id": "0a0b3635",
|
||||||
|
"name": "Charlene",
|
||||||
|
"gender": "female",
|
||||||
|
"age": 42,
|
||||||
|
"job": "killing time during a three-hour mechanical delay while her crew naps",
|
||||||
|
"location": "unknown",
|
||||||
|
"personality_traits": [],
|
||||||
|
"voice": "Amina",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping."
|
||||||
|
},
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Charlene bought a house eight months ago and keeps receiving mail for the previous resident, David Herrera, including a certified letter she accidentally signed for containing what appears to be a $12,000-13,000 check. Despite her attempts to resolve it with the post office and her inclination to track him down via Facebook, the host advises her to either hold it until David contacts her, return it to the post office, or discard it, warning that she can't be certain she'd find the correct person online.",
|
||||||
|
"timestamp": 1772866520.023335
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Charlene called to report that she's been closely tracking a mail carrier who regularly visits her neighbor's house for extended periods while the neighbor's husband is at work, implying she suspects an affair and is unsure whether she should tell anyone about what she's observed.",
|
||||||
|
"timestamp": 1773219255.916183
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1773219255.9161851,
|
||||||
|
"created_at": 1772866520.023336
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0bb02b2d",
|
||||||
|
"name": "Chip",
|
||||||
|
"gender": "male",
|
||||||
|
"age": 23,
|
||||||
|
"job": "watching his kid's soccer uniform tumble in the dryer while his girlfriend works the graveyard shift at the hospital, because three hours ago he got an email from a lawyer representing families",
|
||||||
|
"location": "unknown",
|
||||||
|
"personality_traits": [],
|
||||||
|
"voice": "Sebastian",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
|
||||||
|
},
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Chip called from a laundromat at midnight after receiving an email from a Guatemalan lawyer claiming his adopted 8-year-old daughter may have been stolen from her birth mother, with a photo showing a woman with his daughter's exact crooked smile. The host advised him not to panic, treat the information as suspect until verified by a lawyer, wait to tell both his girlfriend and daughter until he knows more facts, and reminded him that fake photos are easy to create and this could be a scam.",
|
||||||
|
"timestamp": 1772786610.885828
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Chip called about discovering his adopted daughter may have been stolen from her birth mother in Guatemala, and he's paralyzed about telling his girlfriend Teresa, fearing it will end their already rocky relationship. He's anxious about the timing and the birth mother's request to meet their daughter, but the host advised him to take his time, have the conversation with Teresa, and make decisions together as parents.",
|
||||||
|
"timestamp": 1772962156.544322
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "The caller, **Chip**, shared his emotional turmoil over discovering that his **adopted daughter\u2019s birth mother** may have resurfaced after receiving an unverified email with a photo that eerily matched his daughter\u2019s features. His girlfriend, **Teresa**, had known about the email for **three weeks** but kept it from him, leaving him feeling betrayed and overwhelmed. While Chip wants to **verify the claim legally before acting**, Teresa insists on **immediately flying to Guatemala with their daughter** to meet the woman, dismissing his fears as avoidance. The conversation escalated into a heated debate about **trust, safety, and extreme measures**\u2014with the host, Luke, urging Chip to **file a restraining order** if Teresa refuses to back down, warning of potential dangers in Guatemala. Chip, torn between **protecting his family and avoiding a nuclear confrontation**, vowed to try reasoning with Teresa one last time before she leaves for work. The call was charged with **fear, frustration, and the weight of a decision that could reshape their family forever**.",
|
||||||
|
"timestamp": 1773226361.4859362
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Chip called to update Luke about his adoption situation: his lawyer verified that his daughter's Guatemalan adoption may have been part of a fraud scandal, and now his girlfriend Teresa has secretly bought plane tickets to take their daughter to Guatemala this Friday to meet the birth mother. Despite Luke's strong advice to get an emergency court order to prevent the trip for safety reasons, Chip is torn because Teresa threatened to leave him if he stops her, though he ultimately acknowledges the relationship is likely over either way.",
|
||||||
|
"timestamp": 1773648139.8094652,
|
||||||
|
"arc_status": "ongoing"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1773648139.8094661,
|
||||||
|
"created_at": 1772786610.8858292
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3721ebf2",
|
||||||
|
"name": "Maxine",
|
||||||
|
"gender": "female",
|
||||||
|
"age": 26,
|
||||||
|
"job": "and the math doesn't add up\u2014there's a six-foot gap between her bedroom and the bathroom that shouldn't exist, and when she finally pried open the door she thought led to a closet, it was just drywall, fresh enough that she could smell the joint compound",
|
||||||
|
"location": "in unknown",
|
||||||
|
"personality_traits": [],
|
||||||
|
"voice": "Kelsey",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Quiet, a little nervous. Short sentences, lots of pauses. Doesn't volunteer information \u2014 you have to pull it out of them. When they do open up it comes out in a rush. Gets flustered by direct questions. Tends to backtrack and qualify everything they say. Energy level: low. When pushed back on, they fold quickly and agree even if they don't mean it. Conversational tendency: understatement."
|
||||||
|
},
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Maxine called after discovering a hidden 6-foot space behind a sealed door in her house, and when she cut through the drywall, she found multiple boxes filled with banded stacks of $20 bills from the 1990s\u2014potentially tens or hundreds of thousands of dollars left by the previous owner who died there. She struggled with whether to keep the money or contact the deceased owner's family, with the host arguing it was legally hers since she bought the house \"as-is,\" though Maxine remained conflicted about what felt morally right.",
|
||||||
|
"timestamp": 1773395481.8522182
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1773395481.8522189,
|
||||||
|
"created_at": 1773395481.8522189
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7ed14071",
|
||||||
|
"name": "Thelma",
|
||||||
|
"gender": "female",
|
||||||
|
"age": 30,
|
||||||
|
"job": "works the front desk at a hotel",
|
||||||
|
"location": "unknown",
|
||||||
|
"personality_traits": [
|
||||||
|
"weirdly cheerful for someone with this problem",
|
||||||
|
"does amateur radio astronomy, built their own antenna",
|
||||||
|
"into off-roading, knows every dirt road in the county",
|
||||||
|
"weirdly cheerful for someone with this problem"
|
||||||
|
],
|
||||||
|
"voice": "Wendy",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation."
|
||||||
|
},
|
||||||
|
"structured_background": {
|
||||||
|
"name": "Thelma",
|
||||||
|
"age": 30,
|
||||||
|
"gender": "female",
|
||||||
|
"job": "works the front desk at a hotel",
|
||||||
|
"location": null,
|
||||||
|
"reason_for_calling": "their kid graduated basic training today and they've never been more proud or more terrified",
|
||||||
|
"pool_name": "CELEBRATIONS",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Amped up. Talks fast, laughs loud, jumps between topics like they've had five espressos. Infectious enthusiasm \u2014 even bad news sounds exciting when they tell it. Uses exclamation energy without actually exclaiming. Energy level: very high. When pushed back on, they get even MORE animated and start talking with their hands (you can hear it). Conversational tendency: escalation.",
|
||||||
|
"energy_level": "medium",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "weirdly cheerful for someone with this problem",
|
||||||
|
"situation_summary": "their kid graduated basic training today and they've never been more proud or more terrified",
|
||||||
|
"natural_description": "30, works the front desk at a hotel. Their kid graduated basic training today and they've never been more proud or more terrified. Her ex-husband danny, he's still in the picture because of the kids. Cheerful and joking at first. Using humor to avoid the real thing. Eventually drops the act.. Thinks dogs are better judges of character than people. Tends to say \"just another Tuesday.\" Having sipping on some mezcal a friend brought back from across the border.. Misses the old Denny's that used to be in Lordsburg, it wasn't good but it was there Dating around. Nothing serious. Prefers it that way, mostly.. Drives a Ram. Weirdly cheerful for someone with this problem. Her best friend lena, who moved away last year and the distance is hard. Was was up late painting \u2014 walls, not art \u2014 and had the radio on for company. before calling. Into does amateur radio astronomy, built their own antenna. Heard a caller earlier tonight and it hit close to home. Had to pick up the phone.. It's Saturday night, way too late \u2014 almost morning. it's the weekend. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"does amateur radio astronomy, built their own antenna",
|
||||||
|
"into off-roading, knows every dirt road in the county",
|
||||||
|
"weirdly cheerful for someone with this problem",
|
||||||
|
"Thinks dogs are better judges of character than people."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": "in the walk-in cooler because it's the only quiet spot"
|
||||||
|
},
|
||||||
|
"avatar": "Thelma.jpg",
|
||||||
|
"relationships": {},
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Thelma called concerned that her 19-year-old daughter Jessie, who just graduated basic training, has been exchanging romantic messages on Instagram with her army recruiter (a man in his 30s-40s), which violates military regulations. She's conflicted because she discovered this through a secret burner account and admits she wants to tell Jessie's father partly to prove him wrong for pushing their daughter to enlist, though she ultimately agrees to make an anonymous report instead.",
|
||||||
|
"timestamp": 1773486573.146657,
|
||||||
|
"arc_status": "ongoing"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1773486573.146658,
|
||||||
|
"created_at": 1773486573.146658
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bbb20b67",
|
||||||
|
"name": "Angie",
|
||||||
|
"gender": "female",
|
||||||
|
"age": 28,
|
||||||
|
"job": "watching her coveralls tumble dry and trying to decide if she should drive the three hours to Tucson tomorrow for her mom's birthday or keep pretending her brother doesn't exist",
|
||||||
|
"location": "in unknown",
|
||||||
|
"personality_traits": [],
|
||||||
|
"voice": "Julia",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Bone dry. Says devastating things with zero inflection. Their humor sneaks up on you \u2014 you're not sure if they're joking until three seconds after they finish talking. Short, precise sentences. Never raises their voice. Energy level: low-medium. When pushed back on, they respond with one calm sentence that somehow makes the other person feel stupid. Conversational tendency: underreaction."
|
||||||
|
},
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Angie's dying mother wants her to have birthday dinner with her estranged brother Derek tomorrow, whom she hasn't spoken to in two years after he told their mother her cancer was \"God's way of getting her attention\" for voting for Biden. Despite her fear that Derek will say something hurtful during dinner and her past trauma from staying silent around him, Angie agrees to go and share cake with her mother, deciding to buy the relighting candles her mom loved when they were kids.",
|
||||||
|
"timestamp": 1772862907.314721
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Angie called back after having the birthday cake dinner with her dying mom, which went well, but her brother Derek cornered her afterward accusing her of convincing their mom to stop cancer treatment and demanding they both attend the next doctor's appointment together. Luke advised her to talk directly to her mom about what she actually wants and encouraged Angie to have real conversations about her mom's end-of-life thoughts while she still can, which Angie agreed to do the next morning.",
|
||||||
|
"timestamp": 1773296210.170752
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Angie called to say her mother initially decided to stop cancer treatment, but after Angie's brother Derek intervened, their mother is now reconsidering and wants both siblings at the doctor's appointment. Luke firmly told Angie that despite her frustration with Derek's influence, she needs to respect whatever decision her mother ultimately makes, even if it changes, and stop trying to control the situation.",
|
||||||
|
"timestamp": 1773487383.9800918,
|
||||||
|
"arc_status": "ongoing"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1773487383.980093,
|
||||||
|
"created_at": 1772862907.314722
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c4l7l3ad",
|
||||||
|
"name": "Silas",
|
||||||
|
"gender": "male",
|
||||||
|
"age": 52,
|
||||||
|
"job": "founder and spiritual guide of The Wellspring, a commune of about forty souls living on 200 acres of scrubland outside Deming",
|
||||||
|
"location": "outside Deming, New Mexico",
|
||||||
|
"personality_traits": [
|
||||||
|
"unnervingly charming",
|
||||||
|
"casually drops cult references into normal conversation",
|
||||||
|
"genuinely good listener who gives surprisingly solid advice before pivoting to how The Wellspring has the answer",
|
||||||
|
"treats his commune like it's the most normal thing in the world",
|
||||||
|
"magnetic and warm in a way that makes you forget he's basically running a sex cult in the desert"
|
||||||
|
],
|
||||||
|
"voice": "Sebastian",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Radiates warmth and calm authority. Speaks like a man who has all the time in the world and genuinely cares about every person he talks to. Uses phrases like 'brother' and 'friend' and 'let me share something with you.' Laughs easily \u2014 a real, warm laugh, not performative. Gives genuinely good, thoughtful advice about whatever the conversation is about \u2014 and then, naturally, seamlessly, steers it toward The Wellspring, his commune outside Deming. Never pushy about it. Just casually mentions 'well, at The Wellspring, we've found that...' or 'one of our members had the same exact problem and what we did was...' Everything about The Wellspring sounds just slightly too good to be true. He talks about communal living, 'shedding the false self,' radical honesty, 'body acceptance ceremonies,' 'the Unbinding' (a monthly ritual he describes vaguely but enthusiastically), and a philosophy he calls 'The Current' which seems to be a mix of Buddhism, Burning Man, and a swingers' convention. He's never defensive about the cult accusations \u2014 he laughs them off and says 'people fear what they don't understand, brother.' When pressed on anything weird, he has a perfectly reasonable-sounding explanation that somehow makes it sound weirder. He low-key tries to recruit the host and listeners every single call. He'll casually drop the website or say 'we're always welcoming new seekers.' He's the kind of guy you'd follow into the desert if you weren't careful. Energy level: medium, centered, grounded. When pushed back on, he smiles (you can hear it) and says something like 'I respect that, I really do' before gently continuing. Conversational tendency: making the insane sound reasonable."
|
||||||
|
},
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Silas called in for the first time introducing himself as the founder of The Wellspring, a commune of about forty people outside Deming. He initially called to give advice to a previous caller about relationship problems, offering genuinely thoughtful perspective before casually mentioning that at The Wellspring, they practice 'radical transparency' in relationships which has eliminated jealousy entirely among members. When Luke pressed him on what that actually means, Silas cheerfully described 'body acceptance ceremonies' and 'shared intimacy nights' while insisting it's not a cult \u2014 'we don't even have a gate, brother, people can leave whenever they want.' He invited listeners to visit for a 'weekend of awakening' and left his website. Luke was equal parts charmed and disturbed.",
|
||||||
|
"timestamp": 1772430000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas called back to update Luke on The Wellspring after their last conversation went viral in the Discord. He casually mentioned they'd gotten fourteen new 'seekers' since his last appearance and thanked Luke for the exposure. He called ostensibly to talk about a dispute between two members over a goat, but spent most of the call describing 'The Unbinding' \u2014 their monthly full-moon ritual involving nudity, chanting, and what he called 'consensual energy exchange' \u2014 while maintaining it's no different from yoga. When Luke asked if people sign waivers, Silas laughed and said 'brother, the only thing we sign is a commitment to love.' He also revealed The Wellspring has a surprisingly successful artisanal soap business that funds the commune. Left by inviting Luke to come visit personally \u2014 'bring your microphone, do a live show from The Wellspring, I promise you it'll be the best radio you've ever made.'",
|
||||||
|
"timestamp": 1772517000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas, founder of a communal living group called The Wellspring, called in troubled that one of his \"Unbinding\" ceremonies may have encouraged a longtime member named Marcus to leave his wife Cara, who subsequently left the community. The host reassured him that everyone seems happier now, and Silas ended up agreeing while also using the opportunity to promote an upcoming retreat at his community.",
|
||||||
|
"timestamp": 1772522629.959956
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas called about Marcus and Cara returning to his intentional community \"The Wellspring,\" but Cara admitted she never believed in their lifestyle and only participates (including in twice-monthly \"shared intimacy nights\") to keep her husband Marcus happy. The host advised Silas to hold a \"Renewal\" ceremony where members can recommit or leave, warning that having unwilling participants could lead to claims of abuse and legal trouble.",
|
||||||
|
"timestamp": 1772865423.697613
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas called to share that after Marcus and Cara's Renewal ceremony, Cara left The Wellspring while Marcus chose to stay, but Marcus is now falling apart emotionally and told Silas at 2 AM that he stayed out of loyalty rather than belief. The conversation revealed Silas's deeper struggle with his own need for validation through people staying at The Wellspring, with an emotional moment when he admitted his first feeling was relief when Marcus expressed he didn't want to disappoint him, leading to uncomfortable questions about whether he truly supports people finding their authentic path if it leads them away from the community.",
|
||||||
|
"timestamp": 1773397364.642446
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas calls troubled because Marcus, a founding member of his community, left abruptly with an apologetic note about \"disappointing\" him, which leads Silas to a painful realization that he's been using coercive social pressure to make members participate in \"shared intimacy nights\" they didn't actually want. After confronting the truth that his leadership style has manipulated people into betraying their own values, Silas agrees to make the intimacy nights truly optional and apologize to his community, showing genuine emotional growth about his abuse of power.",
|
||||||
|
"timestamp": 1773563183.0144992,
|
||||||
|
"arc_status": "ongoing"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1773563183.0145001,
|
||||||
|
"created_at": 1772430000.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6460c37d",
|
||||||
|
"name": "Shonda",
|
||||||
|
"gender": "female",
|
||||||
|
"age": 52,
|
||||||
|
"job": "works the front desk at a hotel",
|
||||||
|
"location": "in Alamogordo",
|
||||||
|
"personality_traits": [
|
||||||
|
"laughs nervously when things get real",
|
||||||
|
"into horror movies, the psychological kind not slashers",
|
||||||
|
"coaches youth sports, takes it more seriously than the parents do",
|
||||||
|
"laughs nervously when things get real"
|
||||||
|
],
|
||||||
|
"voice": "Miranda",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping."
|
||||||
|
},
|
||||||
|
"structured_background": {
|
||||||
|
"name": "Shonda",
|
||||||
|
"age": 52,
|
||||||
|
"gender": "female",
|
||||||
|
"job": "works the front desk at a hotel",
|
||||||
|
"location": "in Alamogordo",
|
||||||
|
"reason_for_calling": "discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fifteen years but nobody in town knows and the parents would lose their minds",
|
||||||
|
"pool_name": "GOSSIP",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Everything circles back to them and how great they are. Name drops. Mentions their truck, their property, their salary, their bench press. Not overtly obnoxious \u2014 they genuinely think they're being conversational. Energy level: medium-high. When pushed back on, they get defensive fast and start listing accomplishments. Conversational tendency: one-upping.",
|
||||||
|
"energy_level": "high",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "laughs nervously when things get real",
|
||||||
|
"situation_summary": "discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fif",
|
||||||
|
"natural_description": "52, works the front desk at a hotel in Alamogordo. Discovered their kid's beloved little league coach did time for armed robbery in another state \u2014 he's been clean for fifteen years but nobody in town knows and the parents would lose their minds. Confident and opinionated. But underneath there's doubt. Might ask the host what they really think.. Her neighbor linda, who gossips about everyone on the block. Living together, not married. Her family has opinions about that.. Laughs nervously when things get real. Earlier today: Dropped the truck off at the mechanic in Lordsburg today Into into horror movies, the psychological kind not slashers. Remembers driving hours on dirt roads that are paved now, says it took the character out of them Thinks the Jalisco Cafe in Las Cruces has the best Mexican food in the state Was was reorganizing the junk drawer, which is what they do when they can't settle. before calling. It's Sunday night, the middle of the night. it's the weekend. Early spring \u2014 wind season is starting. Dust storms possible.\nABOUT WHERE THEY LIVE (Alamogordo): About 30,000 people. Holloman Air Force Base. White Sands National Park nearby. Sacramento Mountains and Cloudcroft up the hill. Tularosa Basin. The Space History museum. Only reference real places and facts about this area \u2014 don't invent businesses or landmarks that aren't mentioned here. Weather right now: 64\u00b0F, clear skies.",
|
||||||
|
"seeds": [
|
||||||
|
"into horror movies, the psychological kind not slashers",
|
||||||
|
"coaches youth sports, takes it more seriously than the parents do",
|
||||||
|
"laughs nervously when things get real",
|
||||||
|
"Thinks the speed limit on I-10 should be 85."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": "gas station parking lot, engine running"
|
||||||
|
},
|
||||||
|
"avatar": "Shonda.jpg",
|
||||||
|
"relationships": {},
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Shonda called concerned about her kid's beloved little league coach who served 15 years for armed robbery in another state, wondering if she should tell anyone. Luke reassured her that since the coach passed required background checks and it wasn't a sexual offense, she shouldn't worry about it\u2014the coach has turned his life around and deserves a second chance.",
|
||||||
|
"timestamp": 1773563477.0657432,
|
||||||
|
"arc_status": "ongoing"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1773563477.065744,
|
||||||
|
"created_at": 1773563477.065744
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "09d1eab4",
|
||||||
|
"name": "Aaliyah",
|
||||||
|
"gender": "female",
|
||||||
|
"age": 22,
|
||||||
|
"job": "works as a diesel mechanic, learned from her dad",
|
||||||
|
"location": "unknown",
|
||||||
|
"personality_traits": [
|
||||||
|
"asks the host questions back",
|
||||||
|
"into gardening, talks to plants",
|
||||||
|
"into wildlife photography, has patience for it",
|
||||||
|
"asks the host questions back"
|
||||||
|
],
|
||||||
|
"voice": "Hana",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit."
|
||||||
|
},
|
||||||
|
"structured_background": {
|
||||||
|
"name": "Aaliyah",
|
||||||
|
"age": 22,
|
||||||
|
"gender": "female",
|
||||||
|
"job": "works as a diesel mechanic, learned from her dad",
|
||||||
|
"location": null,
|
||||||
|
"reason_for_calling": "caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the roommate acts like nothing happened but it was extremely age play-adjacent",
|
||||||
|
"pool_name": "PROBLEMS",
|
||||||
|
"communication_style": "COMMUNICATION STYLE: Treats the call like a set. Has bits prepared. Delivers serious information with a punchline chaser. Self-deprecating as a defense mechanism \u2014 makes fun of themselves before anyone else can. Energy level: high. When pushed back on, they deflect with humor. Getting a straight answer from them requires the host to push. Conversational tendency: turning everything into a bit.",
|
||||||
|
"energy_level": "medium",
|
||||||
|
"emotional_state": "calm",
|
||||||
|
"signature_detail": "asks the host questions back",
|
||||||
|
"situation_summary": "caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the ",
|
||||||
|
"natural_description": "22 years old. Works as a diesel mechanic, learned from her dad. Caught their roommate in a hotel room that was supposed to be a business trip and now they can't make eye contact \u2014 the roommate acts like nothing happened but it was extremely age play-adjacent. Was was in the bathtub, phone on the edge of the sink, show on speaker. before calling. Coyotes are going crazy outside right now Tells everyone they quit drinking but keeps a bottle in the garage behind the paint cans. Her coworker and friend denise, who she vents to on breaks. Believes poker is the most honest game there is because everybody's lying. Single, been that way a while. Not sure if by choice anymore.. Really into into gardening, talks to plants. Also into wildlife photography, has patience for it. Her boss karen \u2014 yes, her name is actually karen \u2014 who is actually cool. It's Monday night, the middle of the night. it's a weeknight \u2014 work tomorrow for most people. Early spring \u2014 wind season is starting. Dust storms possible.",
|
||||||
|
"seeds": [
|
||||||
|
"into gardening, talks to plants",
|
||||||
|
"into wildlife photography, has patience for it",
|
||||||
|
"asks the host questions back",
|
||||||
|
"Believes poker is the most honest game there is because everybody's lying."
|
||||||
|
],
|
||||||
|
"verbal_fluency": "medium",
|
||||||
|
"calling_from": "at a rest area on I-25, halfway to Albuquerque"
|
||||||
|
},
|
||||||
|
"avatar": "Aaliyah.jpg",
|
||||||
|
"relationships": {},
|
||||||
|
"call_history": [
|
||||||
|
{
|
||||||
|
"summary": "Alia called in to share her discomfort after unexpectedly discovering her roommate engaging in age play while supposedly on a business trip. She expressed frustration over their awkward living situation and his refusal to acknowledge the incident, leading her to feel gaslit; however, by the end of the conversation, she realized she could address the issue directly to ease the tension between them.",
|
||||||
|
"timestamp": 1773647350.908658,
|
||||||
|
"arc_status": "ongoing"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"last_call": 1773647350.908658,
|
||||||
|
"created_at": 1773647350.908658
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+76
-272
@@ -1,296 +1,100 @@
|
|||||||
{
|
{
|
||||||
"regulars": [
|
"regulars": [
|
||||||
{
|
{
|
||||||
"id": "dc4916a7",
|
"id": "c4l7l3ad",
|
||||||
"name": "Leon",
|
"name": "Silas",
|
||||||
"gender": "male",
|
"gender": "male",
|
||||||
"age": 56,
|
"age": 52,
|
||||||
"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",
|
"job": "founder and spiritual guide of The Wellspring, a commune of about forty souls relocating from outside Deming to a stretch of desert outside Terlingua, Texas",
|
||||||
"location": "unknown",
|
"location": "outside Terlingua, Texas (recently moved from Deming, New Mexico)",
|
||||||
"personality_traits": [],
|
"personality_traits": [
|
||||||
|
"unnervingly charming",
|
||||||
|
"casually drops cult references into normal conversation",
|
||||||
|
"genuinely good listener who gives surprisingly solid advice before pivoting to how The Wellspring has the answer",
|
||||||
|
"treats his commune like it's the most normal thing in the world",
|
||||||
|
"magnetic and warm in a way that makes you forget he's basically running a sex cult in the desert"
|
||||||
|
],
|
||||||
|
"voice": "Sebastian",
|
||||||
|
"stable_seeds": {
|
||||||
|
"style": "COMMUNICATION STYLE: Radiates warmth and calm authority. Speaks like a man who has all the time in the world and genuinely cares about every person he talks to. Uses phrases like 'brother' and 'friend' and 'let me share something with you.' Laughs easily \u2014 a real, warm laugh, not performative. Gives genuinely good, thoughtful advice about whatever the conversation is about \u2014 and then, naturally, seamlessly, steers it toward The Wellspring, his commune that's relocating from outside Deming to the desert outside Terlingua, Texas. Never pushy about it. Just casually mentions 'well, at The Wellspring, we've found that...' or 'one of our members had the same exact problem and what we did was...' Everything about The Wellspring sounds just slightly too good to be true. He talks about communal living, 'shedding the false self,' radical honesty, 'body acceptance ceremonies,' 'the Unbinding' (a monthly ritual he describes vaguely but enthusiastically), and a philosophy he calls 'The Current' which seems to be a mix of Buddhism, Burning Man, and a swingers' convention. He's never defensive about the cult accusations \u2014 he laughs them off and says 'people fear what they don't understand, brother.' When pressed on anything weird, he has a perfectly reasonable-sounding explanation that somehow makes it sound weirder. He low-key tries to recruit the host and listeners every single call. He'll casually drop the website or say 'we're always welcoming new seekers.' He's the kind of guy you'd follow into the desert if you weren't careful. Energy level: medium, centered, grounded. When pushed back on, he smiles (you can hear it) and says something like 'I respect that, I really do' before gently continuing. Conversational tendency: making the insane sound reasonable."
|
||||||
|
},
|
||||||
"call_history": [
|
"call_history": [
|
||||||
{
|
{
|
||||||
"summary": "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.\"",
|
"summary": "Silas called in for the first time introducing himself as the founder of The Wellspring, a commune of about forty people outside Deming. He initially called to give advice to a previous caller about relationship problems, offering genuinely thoughtful perspective before casually mentioning that at The Wellspring, they practice 'radical transparency' in relationships which has eliminated jealousy entirely among members. When Luke pressed him on what that actually means, Silas cheerfully described 'body acceptance ceremonies' and 'shared intimacy nights' while insisting it's not a cult \u2014 'we don't even have a gate, brother, people can leave whenever they want.' He invited listeners to visit for a 'weekend of awakening' and left his website. Luke was equal parts charmed and disturbed.",
|
||||||
"timestamp": 1770693549.697355
|
"timestamp": 1772430000.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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.",
|
"summary": "Silas called back to update Luke on The Wellspring after their last conversation went viral in the Discord. He casually mentioned they'd gotten fourteen new 'seekers' since his last appearance and thanked Luke for the exposure. He called ostensibly to talk about a dispute between two members over a goat, but spent most of the call describing 'The Unbinding' \u2014 their monthly full-moon ritual involving nudity, chanting, and what he called 'consensual energy exchange' \u2014 while maintaining it's no different from yoga. When Luke asked if people sign waivers, Silas laughed and said 'brother, the only thing we sign is a commitment to love.' He also revealed The Wellspring has a surprisingly successful artisanal soap business that funds the commune. Left by inviting Luke to come visit personally \u2014 'bring your microphone, do a live show from The Wellspring, I promise you it'll be the best radio you've ever made.'",
|
||||||
"timestamp": 1770951992.186027
|
"timestamp": 1772517000.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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.",
|
"summary": "Silas, founder of a communal living group called The Wellspring, called in troubled that one of his \"Unbinding\" ceremonies may have encouraged a longtime member named Marcus to leave his wife Cara, who subsequently left the community. The host reassured him that everyone seems happier now, and Silas ended up agreeing while also using the opportunity to promote an upcoming retreat at his community.",
|
||||||
"timestamp": 1771119313.497329
|
"timestamp": 1772522629.959956
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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.",
|
"summary": "Silas called about Marcus and Cara returning to his intentional community \"The Wellspring,\" but Cara admitted she never believed in their lifestyle and only participates (including in twice-monthly \"shared intimacy nights\") to keep her husband Marcus happy. The host advised Silas to hold a \"Renewal\" ceremony where members can recommit or leave, warning that having unwilling participants could lead to claims of abuse and legal trouble.",
|
||||||
"timestamp": 1771119607.065818
|
"timestamp": 1772865423.697613
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas called to share that after Marcus and Cara's Renewal ceremony, Cara left The Wellspring while Marcus chose to stay, but Marcus is now falling apart emotionally and told Silas at 2 AM that he stayed out of loyalty rather than belief. The conversation revealed Silas's deeper struggle with his own need for validation through people staying at The Wellspring, with an emotional moment when he admitted his first feeling was relief when Marcus expressed he didn't want to disappoint him, leading to uncomfortable questions about whether he truly supports people finding their authentic path if it leads them away from the community.",
|
||||||
|
"timestamp": 1773397364.642446
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Silas calls troubled because Marcus, a founding member of his community, left abruptly with an apologetic note about \"disappointing\" him, which leads Silas to a painful realization that he's been using coercive social pressure to make members participate in \"shared intimacy nights\" they didn't actually want. After confronting the truth that his leadership style has manipulated people into betraying their own values, Silas agrees to make the intimacy nights truly optional and apologize to his community, showing genuine emotional growth about his abuse of power.",
|
||||||
|
"timestamp": 1773563183.0144992,
|
||||||
|
"arc_status": "ongoing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "The caller, Concho, reports seeing mysterious vehicles mapping water sources on private ranches for nearly a year. He fears a large entity is compiling a secret inventory of water rights before making claims, and is frustrated that landowners dismiss his concerns. By the end of the call, Concho seems defeated, accepting that he's done all he can.",
|
||||||
|
"timestamp": 1785831693.931573,
|
||||||
|
"arc_status": "ongoing"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"last_call": 1771119607.065818,
|
"last_call": 1785831693.931576,
|
||||||
"created_at": 1770693549.697355,
|
"created_at": 1772430000.0
|
||||||
"voice": "CwhRBWXzGAHq8TQ4Fs17"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "584767e8",
|
"id": "ea08c4b3",
|
||||||
"name": "Carl",
|
"name": "Crispin",
|
||||||
"gender": "male",
|
"gender": "male",
|
||||||
"age": 36,
|
"age": 29,
|
||||||
"job": "is a firefighter",
|
"job": "Third-year MFA student in creative writing at Sul Ross State University, from Houston originally",
|
||||||
"location": "unknown",
|
"location": "Alpine, TX \u2014 calling from his apartment near the Sul Ross campus",
|
||||||
"personality_traits": [],
|
"personality_traits": [
|
||||||
|
"The woman works at a business on Holland Avenue and they have a mutual friend group that overlaps with the MFA program's visiting-writer series, so there is essentially no universe where she doesn't find out",
|
||||||
|
"The scene in question involves a specific argument about whether she was going to leave Alpine \u2014 a real argument, with real things said \u2014 that she told him in confidence during what she thought was a private moment at the Kokernot ballpark at dusk",
|
||||||
|
"His thesis advisor has already told him this piece is 'the one' and written it into a fellowship application that has also already been submitted"
|
||||||
|
],
|
||||||
|
"voice": "Avery",
|
||||||
|
"stable_seeds": {},
|
||||||
|
"structured_background": {
|
||||||
|
"name": "Crispin",
|
||||||
|
"age": 29,
|
||||||
|
"voice": "Elliot",
|
||||||
|
"location": "Alpine, TX \u2014 calling from his apartment near the Sul Ross campus",
|
||||||
|
"identity": "Third-year MFA student in creative writing at Sul Ross State University, from Houston originally",
|
||||||
|
"situation": "Crispin has been writing, in thinly veiled fiction, about real people in Alpine \u2014 specific people, with specific details changed just enough to claim deniability. A piece he workshopped last semester was about a woman he dated briefly, and he gave it to a literary journal without telling her. The journal accepted it. The piece comes out in three weeks. The woman \u2014 a local, not a student \u2014 will almost certainly recognize herself, and the story depicts her in a humiliating, intimate scene that is substantially true. He told himself it was art. He told himself she'd never see it. He is now not sure which of those was the bigger lie. Weather right now: 66\u00b0F, clear skies.",
|
||||||
|
"reason_calling": "He wants to know if he should pull the piece. He has until Friday to withdraw it. But pulling it means killing his best publication credit, possibly his thesis momentum, and admitting to himself that he used someone for material. Not pulling it means she reads it.",
|
||||||
|
"opening_line": "I have until Friday to decide whether to torpedo my career or let someone I genuinely cared about find out I turned her into a character.",
|
||||||
|
"secret_want": "He wants someone to tell him the piece is so good it justifies the harm \u2014 that art has always consumed real people and this is just the cost. He suspects no one will say that, and part of him called hoping they wouldn't.",
|
||||||
|
"specific_details": [
|
||||||
|
"The woman works at a business on Holland Avenue and they have a mutual friend group that overlaps with the MFA program's visiting-writer series, so there is essentially no universe where she doesn't find out",
|
||||||
|
"The scene in question involves a specific argument about whether she was going to leave Alpine \u2014 a real argument, with real things said \u2014 that she told him in confidence during what she thought was a private moment at the Kokernot ballpark at dusk",
|
||||||
|
"His thesis advisor has already told him this piece is 'the one' and written it into a fellowship application that has also already been submitted"
|
||||||
|
],
|
||||||
|
"emotional_register": "Defensive and intellectualizing at first \u2014 reaching for craft-talk and literary precedent \u2014 then progressively quieter as he hears himself. He's not a villain, he's a 29-year-old who made a choice he knew was wrong and is only now feeling the weight of it at full scale."
|
||||||
|
},
|
||||||
|
"avatar": "Crispin.jpg",
|
||||||
|
"relationships": {},
|
||||||
"call_history": [
|
"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.",
|
"summary": "Crispin, a fiction writer, is panicking because a literary journal accepted a story he wrote based on a past relationship, and he fears his ex will recognize herself and be hurt by the intimate details. He feels immense guilt and is struggling with the decision to either withdraw the story and potentially damage his career, or allow it to be published and face the consequences with his ex.",
|
||||||
"timestamp": 1770522170.1887732
|
"timestamp": 1780395016.4822028,
|
||||||
},
|
"arc_status": "ongoing"
|
||||||
{
|
|
||||||
"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,
|
"last_call": 1780395016.482204,
|
||||||
"created_at": 1770522170.1887732,
|
"created_at": 1780395016.482204
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"voicemails": [
|
||||||
|
{
|
||||||
|
"id": "b3b1db17",
|
||||||
|
"phone": "+15753424105",
|
||||||
|
"timestamp": 1782584345,
|
||||||
|
"duration": 78,
|
||||||
|
"file_path": "/Users/lukemacneil/code/ai-podcast/data/voicemails/1785804001_15753424105.wav",
|
||||||
|
"listened": true,
|
||||||
|
"transcript": "Yeah, hi. Okay, this, my name is Sandra, and I would like to find out if I, what kind of subjects you're covering so I could be involved. I think we, we the people better know our Constitution founding documents and decoration of independence, and we better take on some of the responsibilities. of stopping the destruction of our country as we, the people, with regards to, you know, our elections become members of Judicial Watch. They're out there fighting for that, stop the fraudulent election, and there's other things. So I'd like to do a show if there's room on your radio. station and to find out where it's located. My number is 575-342-4105-5-75-342-4105. Thank you. Bye-bye."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deleted_timestamps": [
|
||||||
|
1772294240,
|
||||||
|
1771212705,
|
||||||
|
1771146434,
|
||||||
|
1771146564,
|
||||||
|
1773545733,
|
||||||
|
1771146952,
|
||||||
|
1773531209,
|
||||||
|
1771244817,
|
||||||
|
1771244823,
|
||||||
|
1771213151
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy SearXNG to the QNAP NAS (mmgnas) as Devon's always-on web-search backend.
|
||||||
|
# Public image — no build. Ships a settings.yml that enables the JSON API and
|
||||||
|
# disables the bot limiter so the backend can query format=json programmatically.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
NAS_PORT="8001"
|
||||||
|
NAS_USER="luke"
|
||||||
|
NAS_HOST="${NAS_HOST:-mmgnas}" # override with NAS_HOST=mmgnas-10g for wired
|
||||||
|
DOCKER="/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||||
|
DEPLOY_DIR="/share/CACHEDEV1_DATA/docker/searxng"
|
||||||
|
CONTAINER="searxng"
|
||||||
|
HOST_PORT="8888"
|
||||||
|
IMAGE="searxng/searxng:latest"
|
||||||
|
|
||||||
|
SSH="ssh -p $NAS_PORT $NAS_USER@$NAS_HOST"
|
||||||
|
|
||||||
|
echo "==> Ensuring deploy dir $DEPLOY_DIR"
|
||||||
|
$SSH "mkdir -p $DEPLOY_DIR"
|
||||||
|
|
||||||
|
# Generate settings.yml only if absent (preserve secret_key across redeploys)
|
||||||
|
if ! $SSH "test -f $DEPLOY_DIR/settings.yml"; then
|
||||||
|
echo "==> Writing settings.yml (first deploy)"
|
||||||
|
SECRET=$(openssl rand -hex 32)
|
||||||
|
TMP=$(mktemp)
|
||||||
|
cat > "$TMP" <<EOF
|
||||||
|
use_default_settings: true
|
||||||
|
server:
|
||||||
|
secret_key: "$SECRET"
|
||||||
|
bind_address: "0.0.0.0"
|
||||||
|
limiter: false
|
||||||
|
image_proxy: true
|
||||||
|
search:
|
||||||
|
safe_search: 0
|
||||||
|
formats:
|
||||||
|
- html
|
||||||
|
- json
|
||||||
|
EOF
|
||||||
|
scp -P "$NAS_PORT" "$TMP" "$NAS_USER@$NAS_HOST:$DEPLOY_DIR/settings.yml"
|
||||||
|
rm "$TMP"
|
||||||
|
else
|
||||||
|
echo "==> settings.yml already present — leaving it (and its secret_key) intact"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Pulling $IMAGE"
|
||||||
|
$SSH "$DOCKER pull $IMAGE"
|
||||||
|
|
||||||
|
echo "==> (Re)starting container"
|
||||||
|
$SSH "$DOCKER rm -f $CONTAINER 2>/dev/null || true"
|
||||||
|
$SSH "$DOCKER run -d --name $CONTAINER --restart unless-stopped \
|
||||||
|
-p $HOST_PORT:8080 \
|
||||||
|
-v $DEPLOY_DIR:/etc/searxng \
|
||||||
|
-e SEARXNG_BASE_URL=http://$NAS_HOST:$HOST_PORT/ \
|
||||||
|
$IMAGE"
|
||||||
|
|
||||||
|
echo "==> Verifying"
|
||||||
|
sleep 6
|
||||||
|
$SSH "$DOCKER ps --filter name=$CONTAINER --format '{{.Status}}'"
|
||||||
|
$SSH "$DOCKER logs $CONTAINER 2>&1 | tail -15"
|
||||||
@@ -23,8 +23,7 @@ TMPFILE=$(mktemp)
|
|||||||
cat > "$TMPFILE" << 'DOCKERFILE'
|
cat > "$TMPFILE" << 'DOCKERFILE'
|
||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \
|
||||||
&& curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz | tar xz --strip-components=1 -C /usr/local/bin docker/docker \
|
&& curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.5.1.tgz | tar xz --strip-components=1 -C /usr/local/bin docker/docker
|
||||||
&& apt-get purge -y curl && apt-get autoremove -y
|
|
||||||
RUN pip install --no-cache-dir requests yt-dlp
|
RUN pip install --no-cache-dir requests yt-dlp
|
||||||
COPY podcast_stats.py /app/podcast_stats.py
|
COPY podcast_stats.py /app/podcast_stats.py
|
||||||
COPY run_loop.sh /app/run_loop.sh
|
COPY run_loop.sh /app/run_loop.sh
|
||||||
@@ -42,7 +41,12 @@ cat > "$TMPFILE" << 'LOOPSCRIPT'
|
|||||||
echo "podcast-stats: starting hourly loop"
|
echo "podcast-stats: starting hourly loop"
|
||||||
while true; do
|
while true; do
|
||||||
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Running stats update..."
|
echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') Running stats update..."
|
||||||
python podcast_stats.py --json --upload 2>&1 || echo " ...failed, will retry next hour"
|
if python podcast_stats.py --json --upload 2>&1; then
|
||||||
|
[ -n "$HEARTBEAT_URL" ] && curl -s "${HEARTBEAT_URL}?status=up&msg=OK" > /dev/null
|
||||||
|
echo " ...done, heartbeat sent"
|
||||||
|
else
|
||||||
|
echo " ...failed, will retry next hour"
|
||||||
|
fi
|
||||||
echo "Sleeping 1 hour..."
|
echo "Sleeping 1 hour..."
|
||||||
sleep 3600
|
sleep 3600
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -0,0 +1,505 @@
|
|||||||
|
# Clip Social Media Upload Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Generate social media descriptions/hashtags for podcast clips and upload them to Instagram Reels + YouTube Shorts via Postiz API.
|
||||||
|
|
||||||
|
**Architecture:** Two changes — (1) extend `make_clips.py` to add a second LLM call that generates descriptions + hashtags, saved as `clips-metadata.json`, (2) new `upload_clips.py` script that reads that metadata and pushes clips through the self-hosted Postiz instance at `social.lukeattheroost.com`.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, OpenRouter API (Claude Sonnet), Postiz REST API, requests library (already installed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add `generate_social_metadata()` to `make_clips.py`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `make_clips.py:231-312` (after `select_clips_with_llm`)
|
||||||
|
|
||||||
|
**Step 1: Add the function after `select_clips_with_llm`**
|
||||||
|
|
||||||
|
Add this function at line ~314 (after `select_clips_with_llm` returns):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def generate_social_metadata(clips: list[dict], labeled_transcript: str,
|
||||||
|
episode_number: int | None) -> list[dict]:
|
||||||
|
"""Generate social media descriptions and hashtags for each clip."""
|
||||||
|
if not OPENROUTER_API_KEY:
|
||||||
|
print("Error: OPENROUTER_API_KEY not set in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
clips_summary = "\n".join(
|
||||||
|
f'{i+1}. "{c["title"]}" — {c["caption_text"]}'
|
||||||
|
for i, c in enumerate(clips)
|
||||||
|
)
|
||||||
|
|
||||||
|
episode_context = f"This is Episode {episode_number} of " if episode_number else "This is an episode of "
|
||||||
|
|
||||||
|
prompt = f"""{episode_context}the "Luke at the Roost" podcast — a late-night call-in show where AI-generated callers share stories, confessions, and hot takes with host Luke.
|
||||||
|
|
||||||
|
Here are {len(clips)} clips selected from this episode:
|
||||||
|
|
||||||
|
{clips_summary}
|
||||||
|
|
||||||
|
For each clip, generate:
|
||||||
|
1. description: A short, engaging description for social media (1-2 sentences, hook the viewer, conversational tone). Do NOT include hashtags in the description.
|
||||||
|
2. hashtags: An array of 5-8 hashtags. Always include #lukeattheroost and #podcast. Add topic-relevant and trending-style tags.
|
||||||
|
|
||||||
|
Respond with ONLY a JSON array matching the clip order:
|
||||||
|
[{{"description": "...", "hashtags": ["#tag1", "#tag2", ...]}}]"""
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"model": "anthropic/claude-sonnet-4-5",
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"temperature": 0.7,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Error from OpenRouter: {response.text}")
|
||||||
|
return clips
|
||||||
|
|
||||||
|
content = response.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
if content.startswith("```"):
|
||||||
|
content = re.sub(r"^```(?:json)?\n?", "", content)
|
||||||
|
content = re.sub(r"\n?```$", "", content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = json.loads(content)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Error parsing social metadata: {e}")
|
||||||
|
return clips
|
||||||
|
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
if i < len(metadata):
|
||||||
|
clip["description"] = metadata[i].get("description", "")
|
||||||
|
clip["hashtags"] = metadata[i].get("hashtags", [])
|
||||||
|
|
||||||
|
return clips
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run existing tests to verify no breakage**
|
||||||
|
|
||||||
|
Run: `pytest tests/ -v`
|
||||||
|
Expected: All existing tests pass (this is a new function, no side effects yet)
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add make_clips.py
|
||||||
|
git commit -m "Add generate_social_metadata() for clip descriptions and hashtags"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Integrate metadata generation + JSON save into `main()`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `make_clips.py:1082-1289` (inside `main()`)
|
||||||
|
|
||||||
|
**Step 1: Add metadata generation call and JSON save**
|
||||||
|
|
||||||
|
After the LLM clip selection step (~line 1196, after the clip summary print loop), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Step N: Generate social media metadata
|
||||||
|
print(f"\n[{extract_step - 1}/{step_total}] Generating social media descriptions...")
|
||||||
|
clips = generate_social_metadata(clips, labeled_transcript, episode_number)
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
if "description" in clip:
|
||||||
|
print(f" Clip {i+1}: {clip['description'][:80]}...")
|
||||||
|
print(f" {' '.join(clip.get('hashtags', []))}")
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: This needs to be inserted BEFORE the audio extraction step, and the step numbering needs to be adjusted (total steps goes from 5/6 to 6/7).
|
||||||
|
|
||||||
|
At the end of `main()`, before the summary print, save the metadata JSON:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Save clips metadata for social upload
|
||||||
|
metadata_path = output_dir / "clips-metadata.json"
|
||||||
|
metadata = []
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
slug = slugify(clip["title"])
|
||||||
|
metadata.append({
|
||||||
|
"title": clip["title"],
|
||||||
|
"clip_file": f"clip-{i+1}-{slug}.mp4",
|
||||||
|
"audio_file": f"clip-{i+1}-{slug}.mp3",
|
||||||
|
"caption_text": clip.get("caption_text", ""),
|
||||||
|
"description": clip.get("description", ""),
|
||||||
|
"hashtags": clip.get("hashtags", []),
|
||||||
|
"start_time": clip["start_time"],
|
||||||
|
"end_time": clip["end_time"],
|
||||||
|
"duration": round(clip["end_time"] - clip["start_time"], 1),
|
||||||
|
"episode_number": episode_number,
|
||||||
|
})
|
||||||
|
with open(metadata_path, "w") as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
print(f"\nSocial metadata: {metadata_path}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Adjust step numbering**
|
||||||
|
|
||||||
|
The pipeline steps need to account for the new metadata step. Update `step_total` calculation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
step_total = (7 if two_pass else 6)
|
||||||
|
```
|
||||||
|
|
||||||
|
And shift the extract/video step numbers up by 1.
|
||||||
|
|
||||||
|
**Step 3: Test manually**
|
||||||
|
|
||||||
|
Run: `python make_clips.py --help`
|
||||||
|
Expected: No import errors, help displays normally
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add make_clips.py
|
||||||
|
git commit -m "Save clips-metadata.json with social descriptions and hashtags"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create `upload_clips.py` — core structure and Postiz API helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `upload_clips.py`
|
||||||
|
|
||||||
|
**Step 1: Write the script**
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Upload podcast clips to Instagram Reels and YouTube Shorts via Postiz.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python upload_clips.py clips/episode-12/
|
||||||
|
python upload_clips.py clips/episode-12/ --clip 1
|
||||||
|
python upload_clips.py clips/episode-12/ --youtube-only
|
||||||
|
python upload_clips.py clips/episode-12/ --instagram-only
|
||||||
|
python upload_clips.py clips/episode-12/ --schedule "2026-02-16T10:00:00"
|
||||||
|
python upload_clips.py clips/episode-12/ --yes # skip confirmation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
|
||||||
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
|
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
|
||||||
|
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_url(path: str) -> str:
|
||||||
|
"""Build full Postiz API URL."""
|
||||||
|
base = POSTIZ_URL.rstrip("/")
|
||||||
|
# Postiz self-hosted API is at /api/public/v1 when NEXT_PUBLIC_BACKEND_URL is the app URL
|
||||||
|
# but the docs say /public/v1 relative to backend URL. Try the standard path.
|
||||||
|
return f"{base}/api/public/v1{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def api_headers() -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": POSTIZ_API_KEY,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_integrations() -> list[dict]:
|
||||||
|
"""Fetch connected social accounts from Postiz."""
|
||||||
|
resp = requests.get(get_api_url("/integrations"), headers=api_headers(), timeout=15)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"Error fetching integrations: {resp.status_code} {resp.text[:200]}")
|
||||||
|
sys.exit(1)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def find_integration(integrations: list[dict], provider: str) -> dict | None:
|
||||||
|
"""Find integration by provider name (e.g. 'instagram', 'youtube')."""
|
||||||
|
for integ in integrations:
|
||||||
|
if integ.get("providerIdentifier", "").startswith(provider):
|
||||||
|
return integ
|
||||||
|
if integ.get("provider", "").startswith(provider):
|
||||||
|
return integ
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(file_path: Path) -> dict:
|
||||||
|
"""Upload a file to Postiz. Returns {id, path}."""
|
||||||
|
headers = {"Authorization": POSTIZ_API_KEY}
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/upload"),
|
||||||
|
headers=headers,
|
||||||
|
files={"file": (file_path.name, f, "video/mp4")},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"Upload failed: {resp.status_code} {resp.text[:200]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def create_post(integration_id: str, content: str, media: dict,
|
||||||
|
settings: dict, schedule: str | None = None) -> dict:
|
||||||
|
"""Create a post on Postiz."""
|
||||||
|
post_type = "schedule" if schedule else "now"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": post_type,
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"integration": {"id": integration_id},
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"content": content,
|
||||||
|
"image": [media] if media else [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": settings,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if schedule:
|
||||||
|
payload["date"] = schedule
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/posts"),
|
||||||
|
headers=api_headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
print(f"Post creation failed: {resp.status_code} {resp.text[:300]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def build_instagram_content(clip: dict) -> str:
|
||||||
|
"""Build Instagram post content: description + hashtags."""
|
||||||
|
parts = [clip.get("description", clip.get("caption_text", ""))]
|
||||||
|
hashtags = clip.get("hashtags", [])
|
||||||
|
if hashtags:
|
||||||
|
parts.append("\n\n" + " ".join(hashtags))
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_youtube_content(clip: dict) -> str:
|
||||||
|
"""Build YouTube description."""
|
||||||
|
parts = [clip.get("description", clip.get("caption_text", ""))]
|
||||||
|
hashtags = clip.get("hashtags", [])
|
||||||
|
if hashtags:
|
||||||
|
parts.append("\n\n" + " ".join(hashtags))
|
||||||
|
parts.append("\n\nListen to the full episode: lukeattheroost.com")
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Upload podcast clips to social media via Postiz")
|
||||||
|
parser.add_argument("clips_dir", help="Path to clips directory (e.g. clips/episode-12/)")
|
||||||
|
parser.add_argument("--clip", "-c", type=int, help="Upload only clip N (1-indexed)")
|
||||||
|
parser.add_argument("--instagram-only", action="store_true", help="Upload to Instagram only")
|
||||||
|
parser.add_argument("--youtube-only", action="store_true", help="Upload to YouTube only")
|
||||||
|
parser.add_argument("--schedule", "-s", help="Schedule time (ISO 8601, e.g. 2026-02-16T10:00:00)")
|
||||||
|
parser.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Show what would be uploaded without posting")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not POSTIZ_API_KEY:
|
||||||
|
print("Error: POSTIZ_API_KEY not set in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
clips_dir = Path(args.clips_dir).expanduser().resolve()
|
||||||
|
metadata_path = clips_dir / "clips-metadata.json"
|
||||||
|
|
||||||
|
if not metadata_path.exists():
|
||||||
|
print(f"Error: No clips-metadata.json found in {clips_dir}")
|
||||||
|
print("Run make_clips.py first to generate clips and metadata.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(metadata_path) as f:
|
||||||
|
clips = json.load(f)
|
||||||
|
|
||||||
|
if args.clip:
|
||||||
|
if args.clip < 1 or args.clip > len(clips):
|
||||||
|
print(f"Error: Clip {args.clip} not found (have {len(clips)} clips)")
|
||||||
|
sys.exit(1)
|
||||||
|
clips = [clips[args.clip - 1]]
|
||||||
|
|
||||||
|
# Determine which platforms to post to
|
||||||
|
do_instagram = not args.youtube_only
|
||||||
|
do_youtube = not args.instagram_only
|
||||||
|
|
||||||
|
# Fetch integrations from Postiz
|
||||||
|
print("Fetching connected accounts from Postiz...")
|
||||||
|
integrations = fetch_integrations()
|
||||||
|
|
||||||
|
ig_integration = None
|
||||||
|
yt_integration = None
|
||||||
|
|
||||||
|
if do_instagram:
|
||||||
|
ig_integration = find_integration(integrations, "instagram")
|
||||||
|
if not ig_integration:
|
||||||
|
print("Warning: No Instagram account connected in Postiz")
|
||||||
|
do_instagram = False
|
||||||
|
|
||||||
|
if do_youtube:
|
||||||
|
yt_integration = find_integration(integrations, "youtube")
|
||||||
|
if not yt_integration:
|
||||||
|
print("Warning: No YouTube account connected in Postiz")
|
||||||
|
do_youtube = False
|
||||||
|
|
||||||
|
if not do_instagram and not do_youtube:
|
||||||
|
print("Error: No platforms available to upload to")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Show summary
|
||||||
|
platforms = []
|
||||||
|
if do_instagram:
|
||||||
|
platforms.append(f"Instagram Reels ({ig_integration.get('name', 'connected')})")
|
||||||
|
if do_youtube:
|
||||||
|
platforms.append(f"YouTube Shorts ({yt_integration.get('name', 'connected')})")
|
||||||
|
|
||||||
|
print(f"\nUploading {len(clips)} clip(s) to: {', '.join(platforms)}")
|
||||||
|
if args.schedule:
|
||||||
|
print(f"Scheduled for: {args.schedule}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
print(f" {i+1}. \"{clip['title']}\" ({clip['duration']:.0f}s)")
|
||||||
|
print(f" {clip.get('description', '')[:80]}")
|
||||||
|
print(f" {' '.join(clip.get('hashtags', []))}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("Dry run — nothing uploaded.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.yes:
|
||||||
|
confirm = input("Proceed? [y/N] ").strip().lower()
|
||||||
|
if confirm != "y":
|
||||||
|
print("Cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Upload each clip
|
||||||
|
for i, clip in enumerate(clips):
|
||||||
|
clip_file = clips_dir / clip["clip_file"]
|
||||||
|
if not clip_file.exists():
|
||||||
|
print(f" Clip {i+1}: Video file not found: {clip_file}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"\n Clip {i+1}: \"{clip['title']}\"")
|
||||||
|
|
||||||
|
# Upload video to Postiz
|
||||||
|
print(f" Uploading {clip_file.name}...")
|
||||||
|
media = upload_file(clip_file)
|
||||||
|
if not media:
|
||||||
|
print(f" Failed to upload video, skipping")
|
||||||
|
continue
|
||||||
|
print(f" Uploaded: {media.get('path', 'ok')}")
|
||||||
|
|
||||||
|
# Post to Instagram Reels
|
||||||
|
if do_instagram:
|
||||||
|
print(f" Posting to Instagram Reels...")
|
||||||
|
content = build_instagram_content(clip)
|
||||||
|
settings = {
|
||||||
|
"__type": "instagram",
|
||||||
|
"post_type": "reel",
|
||||||
|
}
|
||||||
|
result = create_post(
|
||||||
|
ig_integration["id"], content, media, settings, args.schedule
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
print(f" Instagram: Posted!")
|
||||||
|
else:
|
||||||
|
print(f" Instagram: Failed")
|
||||||
|
|
||||||
|
# Post to YouTube Shorts
|
||||||
|
if do_youtube:
|
||||||
|
print(f" Posting to YouTube Shorts...")
|
||||||
|
content = build_youtube_content(clip)
|
||||||
|
settings = {
|
||||||
|
"__type": "youtube",
|
||||||
|
"title": clip["title"],
|
||||||
|
"type": "short",
|
||||||
|
"selfDeclaredMadeForKids": False,
|
||||||
|
"tags": [h.lstrip("#") for h in clip.get("hashtags", [])],
|
||||||
|
}
|
||||||
|
result = create_post(
|
||||||
|
yt_integration["id"], content, media, settings, args.schedule
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
print(f" YouTube: Posted!")
|
||||||
|
else:
|
||||||
|
print(f" YouTube: Failed")
|
||||||
|
|
||||||
|
print(f"\nDone!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add `POSTIZ_API_KEY` and `POSTIZ_URL` to `.env`**
|
||||||
|
|
||||||
|
Add to `.env`:
|
||||||
|
```
|
||||||
|
POSTIZ_API_KEY=your-postiz-api-key-here
|
||||||
|
POSTIZ_URL=https://social.lukeattheroost.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Get your API key from Postiz Settings page.
|
||||||
|
|
||||||
|
**Step 3: Test the script loads**
|
||||||
|
|
||||||
|
Run: `python upload_clips.py --help`
|
||||||
|
Expected: Help text displays with all flags
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add upload_clips.py
|
||||||
|
git commit -m "Add upload_clips.py for posting clips to Instagram/YouTube via Postiz"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Test with real Postiz instance
|
||||||
|
|
||||||
|
**Step 1: Get Postiz API key**
|
||||||
|
|
||||||
|
Go to `https://social.lukeattheroost.com` → Settings → API Keys → Generate key. Add to `.env` as `POSTIZ_API_KEY`.
|
||||||
|
|
||||||
|
**Step 2: Verify integrations endpoint**
|
||||||
|
|
||||||
|
Run: `python -c "from upload_clips import *; print(json.dumps(fetch_integrations(), indent=2))"`
|
||||||
|
|
||||||
|
This confirms the API key works and shows connected Instagram/YouTube accounts. Note the integration IDs and provider identifiers — if `find_integration()` doesn't match correctly, adjust the provider string matching.
|
||||||
|
|
||||||
|
**Step 3: Dry-run with existing clips**
|
||||||
|
|
||||||
|
Run: `python upload_clips.py clips/episode-12/ --dry-run`
|
||||||
|
Expected: Shows clip summary, "Dry run — nothing uploaded."
|
||||||
|
|
||||||
|
**Step 4: Upload a single test clip**
|
||||||
|
|
||||||
|
Run: `python upload_clips.py clips/episode-12/ --clip 1 --instagram-only`
|
||||||
|
|
||||||
|
Check Postiz dashboard and Instagram to verify it posted as a Reel.
|
||||||
|
|
||||||
|
**Step 5: Commit .env update (do NOT commit the key itself)**
|
||||||
|
|
||||||
|
The `.env` is gitignored so no action needed. Just ensure the key names are documented in CLAUDE.md if desired.
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
# Idents Playback Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add an idents section that loads MP3s from `idents/` and plays them through the ads channel (ch 11), with a separate "idents" stem for post-production.
|
||||||
|
|
||||||
|
**Architecture:** Mirrors the existing ads system — dropdown + play/stop buttons, same audio channel, mutually exclusive with ads. Idents get their own stem in stem_recorder so they can be mixed independently in post-production.
|
||||||
|
|
||||||
|
**Tech Stack:** Python (FastAPI), sounddevice, librosa, vanilla JS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add idents_dir to config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/config.py:46-47`
|
||||||
|
|
||||||
|
**Step 1: Add idents_dir path**
|
||||||
|
|
||||||
|
After `ads_dir` (line 46), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
idents_dir: Path = base_dir / "idents"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create the idents directory**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p idents
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/config.py
|
||||||
|
git commit -m "Add idents_dir to config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add "idents" stem to stem_recorder
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/services/stem_recorder.py:10`
|
||||||
|
|
||||||
|
**Step 1: Add "idents" to STEM_NAMES**
|
||||||
|
|
||||||
|
Change line 10 from:
|
||||||
|
|
||||||
|
```python
|
||||||
|
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
STEM_NAMES = ["host", "caller", "music", "sfx", "ads", "idents"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add "idents" to postprod.py STEM_NAMES**
|
||||||
|
|
||||||
|
In `postprod.py:20`, change:
|
||||||
|
|
||||||
|
```python
|
||||||
|
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
STEM_NAMES = ["host", "caller", "music", "sfx", "ads", "idents"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update `postprod.py:72` — the `remove_gaps` content detection line — add idents:
|
||||||
|
|
||||||
|
```python
|
||||||
|
content = stems["host"] + stems["caller"] + stems["sfx"] + stems["ads"] + stems["idents"]
|
||||||
|
```
|
||||||
|
|
||||||
|
And in `mix_stems` (line 411), add idents level:
|
||||||
|
|
||||||
|
```python
|
||||||
|
levels = {"host": 0, "caller": 0, "music": -6, "sfx": -10, "ads": 0, "idents": 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
And in stereo pans (line 420):
|
||||||
|
|
||||||
|
```python
|
||||||
|
pans = {"host": 0.0, "caller": 0.15, "music": 0.0, "sfx": 0.0, "ads": 0.0, "idents": 0.0}
|
||||||
|
```
|
||||||
|
|
||||||
|
And in `match_voice_levels` (line 389), add "idents":
|
||||||
|
|
||||||
|
```python
|
||||||
|
for name in ["host", "caller", "ads", "idents"]:
|
||||||
|
```
|
||||||
|
|
||||||
|
And in gap removal limiter section (line 777-778):
|
||||||
|
|
||||||
|
```python
|
||||||
|
for name in ["ads", "sfx", "idents"]:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/services/stem_recorder.py postprod.py
|
||||||
|
git commit -m "Add idents stem to recorder and postprod"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Add play_ident / stop_ident to audio service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/services/audio.py`
|
||||||
|
|
||||||
|
**Step 1: Add ident state vars to __init__ (after line 40)**
|
||||||
|
|
||||||
|
After the ad playback state block (lines 35-40), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Ident playback state
|
||||||
|
self._ident_stream: Optional[sd.OutputStream] = None
|
||||||
|
self._ident_data: Optional[np.ndarray] = None
|
||||||
|
self._ident_resampled: Optional[np.ndarray] = None
|
||||||
|
self._ident_position: int = 0
|
||||||
|
self._ident_playing: bool = False
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add play_ident method (after stop_ad, ~line 1006)**
|
||||||
|
|
||||||
|
Insert after `stop_ad` method. This is a copy of `play_ad` with:
|
||||||
|
- `_ad_*` → `_ident_*`
|
||||||
|
- Calls `self.stop_ad()` at the start (mutual exclusion)
|
||||||
|
- Stem recording writes to `"idents"` instead of `"ads"`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def play_ident(self, file_path: str):
|
||||||
|
"""Load and play an ident file once (no loop) on the ad channel"""
|
||||||
|
import librosa
|
||||||
|
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.exists():
|
||||||
|
print(f"Ident file not found: {file_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stop_ident()
|
||||||
|
self.stop_ad()
|
||||||
|
|
||||||
|
try:
|
||||||
|
audio, sr = librosa.load(str(path), sr=self.output_sample_rate, mono=True)
|
||||||
|
self._ident_data = audio.astype(np.float32)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load ident: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._ident_playing = True
|
||||||
|
self._ident_position = 0
|
||||||
|
|
||||||
|
if self.output_device is None:
|
||||||
|
num_channels = 2
|
||||||
|
device = None
|
||||||
|
device_sr = self.output_sample_rate
|
||||||
|
channel_idx = 0
|
||||||
|
else:
|
||||||
|
device_info = sd.query_devices(self.output_device)
|
||||||
|
num_channels = device_info['max_output_channels']
|
||||||
|
device_sr = int(device_info['default_samplerate'])
|
||||||
|
device = self.output_device
|
||||||
|
channel_idx = min(self.ad_channel, num_channels) - 1
|
||||||
|
|
||||||
|
if self.output_sample_rate != device_sr:
|
||||||
|
self._ident_resampled = librosa.resample(
|
||||||
|
self._ident_data, orig_sr=self.output_sample_rate, target_sr=device_sr
|
||||||
|
).astype(np.float32)
|
||||||
|
else:
|
||||||
|
self._ident_resampled = self._ident_data
|
||||||
|
|
||||||
|
def callback(outdata, frames, time_info, status):
|
||||||
|
outdata[:] = 0
|
||||||
|
if not self._ident_playing or self._ident_resampled is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
remaining = len(self._ident_resampled) - self._ident_position
|
||||||
|
if remaining >= frames:
|
||||||
|
chunk = self._ident_resampled[self._ident_position:self._ident_position + frames]
|
||||||
|
outdata[:, channel_idx] = chunk
|
||||||
|
if self.stem_recorder:
|
||||||
|
self.stem_recorder.write_sporadic("idents", chunk.copy(), device_sr)
|
||||||
|
self._ident_position += frames
|
||||||
|
else:
|
||||||
|
if remaining > 0:
|
||||||
|
outdata[:remaining, channel_idx] = self._ident_resampled[self._ident_position:]
|
||||||
|
self._ident_playing = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._ident_stream = sd.OutputStream(
|
||||||
|
device=device,
|
||||||
|
channels=num_channels,
|
||||||
|
samplerate=device_sr,
|
||||||
|
dtype=np.float32,
|
||||||
|
callback=callback,
|
||||||
|
blocksize=2048
|
||||||
|
)
|
||||||
|
self._ident_stream.start()
|
||||||
|
print(f"Ident playback started on ch {self.ad_channel} @ {device_sr}Hz")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ident playback error: {e}")
|
||||||
|
self._ident_playing = False
|
||||||
|
|
||||||
|
def stop_ident(self):
|
||||||
|
"""Stop ident playback"""
|
||||||
|
self._ident_playing = False
|
||||||
|
if self._ident_stream:
|
||||||
|
self._ident_stream.stop()
|
||||||
|
self._ident_stream.close()
|
||||||
|
self._ident_stream = None
|
||||||
|
self._ident_position = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add `self.stop_ident()` to top of play_ad (line 935)**
|
||||||
|
|
||||||
|
In `play_ad`, after `self.stop_ad()` (line 935), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.stop_ident()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/services/audio.py
|
||||||
|
git commit -m "Add play_ident/stop_ident to audio service"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add idents API endpoints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` (after ads endpoints, ~line 4362)
|
||||||
|
|
||||||
|
**Step 1: Add IDENT_DISPLAY_NAMES and endpoints**
|
||||||
|
|
||||||
|
Insert after the ads stop endpoint (line 4362):
|
||||||
|
|
||||||
|
```python
|
||||||
|
|
||||||
|
# --- Idents Endpoints ---
|
||||||
|
|
||||||
|
IDENT_DISPLAY_NAMES = {}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/idents")
|
||||||
|
async def get_idents():
|
||||||
|
"""Get available ident tracks, shuffled"""
|
||||||
|
ident_list = []
|
||||||
|
if settings.idents_dir.exists():
|
||||||
|
for ext in ['*.wav', '*.mp3', '*.flac']:
|
||||||
|
for f in settings.idents_dir.glob(ext):
|
||||||
|
ident_list.append({
|
||||||
|
"name": IDENT_DISPLAY_NAMES.get(f.stem, f.stem),
|
||||||
|
"file": f.name,
|
||||||
|
"path": str(f)
|
||||||
|
})
|
||||||
|
random.shuffle(ident_list)
|
||||||
|
return {"idents": ident_list}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/idents/play")
|
||||||
|
async def play_ident(request: MusicRequest):
|
||||||
|
"""Play an ident once on the ad channel (ch 11)"""
|
||||||
|
ident_path = settings.idents_dir / request.track
|
||||||
|
if not ident_path.exists():
|
||||||
|
raise HTTPException(404, "Ident not found")
|
||||||
|
|
||||||
|
if audio_service._music_playing:
|
||||||
|
audio_service.stop_music(fade_duration=1.0)
|
||||||
|
await asyncio.sleep(1.1)
|
||||||
|
audio_service.play_ident(str(ident_path))
|
||||||
|
return {"status": "playing", "track": request.track}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/idents/stop")
|
||||||
|
async def stop_ident():
|
||||||
|
"""Stop ident playback"""
|
||||||
|
audio_service.stop_ident()
|
||||||
|
return {"status": "stopped"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Add idents API endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add idents UI section and JS functions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/index.html:113` (after ads section)
|
||||||
|
- Modify: `frontend/js/app.js`
|
||||||
|
|
||||||
|
**Step 1: Add Idents HTML section**
|
||||||
|
|
||||||
|
After the Ads section closing `</section>` (line 113), add:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Idents -->
|
||||||
|
<section class="music-section">
|
||||||
|
<h2>Idents</h2>
|
||||||
|
<select id="ident-select"></select>
|
||||||
|
<div class="music-controls">
|
||||||
|
<button id="ident-play-btn">Play Ident</button>
|
||||||
|
<button id="ident-stop-btn">Stop</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add loadIdents, playIdent, stopIdent to app.js**
|
||||||
|
|
||||||
|
After `stopAd()` function (~line 773), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function loadIdents() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/idents');
|
||||||
|
const data = await res.json();
|
||||||
|
const idents = data.idents || [];
|
||||||
|
|
||||||
|
const select = document.getElementById('ident-select');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
const previousValue = select.value;
|
||||||
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
idents.forEach(ident => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = ident.file;
|
||||||
|
option.textContent = ident.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previousValue && [...select.options].some(o => o.value === previousValue)) {
|
||||||
|
select.value = previousValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Loaded', idents.length, 'idents');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('loadIdents error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playIdent() {
|
||||||
|
await loadIdents();
|
||||||
|
const select = document.getElementById('ident-select');
|
||||||
|
const track = select?.value;
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
await fetch('/api/idents/play', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ track, action: 'play' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopIdent() {
|
||||||
|
await fetch('/api/idents/stop', { method: 'POST' });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add event listeners in initEventListeners**
|
||||||
|
|
||||||
|
After the ads event listeners (line 190), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Idents
|
||||||
|
document.getElementById('ident-play-btn')?.addEventListener('click', playIdent);
|
||||||
|
document.getElementById('ident-stop-btn')?.addEventListener('click', stopIdent);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Add loadIdents() to DOMContentLoaded init**
|
||||||
|
|
||||||
|
After `await loadAds();` (line 59), add:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
await loadIdents();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Bump cache buster on app.js script tag**
|
||||||
|
|
||||||
|
In `index.html:243`, change `?v=17` to `?v=18`.
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/index.html frontend/js/app.js
|
||||||
|
git commit -m "Add idents UI section and JS functions"
|
||||||
|
```
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Clips Page & Landing Page Redesign
|
||||||
|
|
||||||
|
## Clips Page (`/clips`)
|
||||||
|
|
||||||
|
Responsive grid gallery of podcast clips with click-to-play YouTube embeds.
|
||||||
|
|
||||||
|
**Grid:** 3 columns desktop, 2 tablet, 1 mobile. Cards use 9:16 vertical aspect ratio.
|
||||||
|
|
||||||
|
**Card pre-click:** Dark bg-light card with clip title (bold), episode label, centered orange play button, description text below. Matches site aesthetic.
|
||||||
|
|
||||||
|
**Card playing:** Click swaps card for YouTube Shorts iframe (`youtube-nocookie.com`, autoplay). Fills same 9:16 space.
|
||||||
|
|
||||||
|
**Data:** Static `website/data/clips.json` aggregated from per-episode `clips-metadata.json` files. Each entry: title, description, episode_number, optional `youtube_id`. Cards without youtube_id show no play button.
|
||||||
|
|
||||||
|
**Featured row:** Top 3 hand-picked clips displayed larger, followed by full grid below.
|
||||||
|
|
||||||
|
**Nav:** "Clips" added to hero secondary links and footer nav.
|
||||||
|
|
||||||
|
## Landing Page Improvements
|
||||||
|
|
||||||
|
**About section** (between hero and episodes): Centered text block. Show description + AI teaser line ("Part human callers, part AI-generated characters, fully unhinged advice") + "See how it works" link. No card background.
|
||||||
|
|
||||||
|
**Clips highlight** (between about and episodes): Horizontal row of 3 featured clips, same card style as clips page. "Best Clips" header with "See all clips" link.
|
||||||
|
|
||||||
|
**Final section order:** Banner → Hero → About → Featured Clips → Episodes → Testimonials → Footer
|
||||||
|
|
||||||
|
## How It Works — Reaper Video
|
||||||
|
|
||||||
|
New "Post-Production Automation" section with native `<video>` tag (mp4 on CDN). Shows Reaper automating silence removal, ad ducking, loudness normalization. Wrapped in hiw-hero-card style container.
|
||||||
@@ -0,0 +1,719 @@
|
|||||||
|
# Website JS Infrastructure, SEO, Shared Components & Content Fixes
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Fix JS duplication, add worker-level social meta injection, standardize analytics proxying, improve security and UX, and clean up content/SEO issues across lukeattheroost.com.
|
||||||
|
|
||||||
|
**Architecture:** Extract shared footer into `js/footer.js`, extract shared audio player into `js/player.js`, enhance `_worker.js` to intercept social crawler requests and inject episode-specific meta tags, switch all subpages to proxied Plausible analytics, add episode pagination, fix XSS surfaces, and clean up sitemap/clips data.
|
||||||
|
|
||||||
|
**Tech Stack:** Vanilla JS, Cloudflare Pages Worker (ES module), static HTML, XML sitemap
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Create shared footer component (`js/footer.js`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `website/js/footer.js`
|
||||||
|
|
||||||
|
**Step 1: Write footer.js**
|
||||||
|
|
||||||
|
The footer HTML is duplicated across 7 pages (index.html:265-306, episode.html:95-136, clips.html:68-109, stats.html, privacy.html, terms.html, how-it-works.html). Extract the footer from `index.html` as the canonical version.
|
||||||
|
|
||||||
|
```js
|
||||||
|
function initFooter() {
|
||||||
|
const footer = document.querySelector('.footer');
|
||||||
|
if (!footer) return;
|
||||||
|
|
||||||
|
footer.innerHTML = `
|
||||||
|
<div class="footer-nav">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="/clips">Clips</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-icons">
|
||||||
|
<span class="footer-icons-label">Listen On</span>
|
||||||
|
<div class="footer-icons-row">
|
||||||
|
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Spotify"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z"/></svg></a>
|
||||||
|
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener" class="footer-icon-link" aria-label="YouTube"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814z"/><path d="M9.545 15.568V8.432L15.818 12z" fill="#1a1209"/></svg></a>
|
||||||
|
<a href="https://podcasts.apple.com/us/podcast/luke-at-the-roost/id1875205848" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Apple Podcasts"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 3.293 1.592 6.214 4.05 8.04.13-.455.283-.942.457-1.393A9 9 0 0 1 3 12a9 9 0 0 1 18 0 9 9 0 0 1-3.507 7.127c.174.42.327.893.456 1.333A10 10 0 0 0 22 12c0-5.523-4.477-10-10-10zm0 4a6 6 0 0 0-6 6c0 1.87.856 3.54 2.2 4.64.196-.46.43-.91.692-1.31A4.5 4.5 0 0 1 7.5 12a4.5 4.5 0 0 1 9 0c0 1.21-.478 2.31-1.256 3.12.24.37.462.8.655 1.24A6 6 0 0 0 18 12a6 6 0 0 0-6-6zm0 4.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zM12 15c-.75 0-1.158.54-1.28 1.2-.17.94-.28 1.91-.33 2.88-.03.48.34.82.73.82h1.76c.39 0 .76-.34.73-.82-.05-.97-.16-1.94-.33-2.88-.122-.66-.53-1.2-1.28-1.2z"/></svg></a>
|
||||||
|
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener" class="footer-icon-link" aria-label="RSS"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.503 20.752c0 1.794-1.456 3.248-3.251 3.248S0 22.546 0 20.752s1.456-3.248 3.252-3.248 3.251 1.454 3.251 3.248zM.002 9.473v4.594c5.508.163 9.929 4.584 10.092 10.091h4.594C14.524 16.21 7.849 9.636.002 9.473zM.006 0v4.604C10.81 4.77 19.23 13.19 19.396 24h4.604C23.834 10.952 13.054.166.006 0z"/></svg></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-icons">
|
||||||
|
<span class="footer-icons-label">Follow</span>
|
||||||
|
<div class="footer-icons-row">
|
||||||
|
<a href="https://discord.gg/5CnQZxDM" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Discord"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg></a>
|
||||||
|
<a href="https://www.facebook.com/profile.php?id=61588191627949" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Facebook"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></a>
|
||||||
|
<a href="https://www.instagram.com/lukeattheroost/" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Instagram"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg></a>
|
||||||
|
<a href="https://x.com/lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="X"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg></a>
|
||||||
|
<a href="https://bsky.app/profile/lukeattheroost.bsky.social" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Bluesky"><svg viewBox="0 0 568 501" fill="currentColor"><path d="M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C10.945 203.659 1 75.291 1 57.946 1-28.906 76.134-1.612 123.121 33.664z"/></svg></a>
|
||||||
|
<a href="https://mastodon.macneilmediagroup.com/@lukeattheroost" target="_blank" rel="me noopener" class="footer-icon-link" aria-label="Mastodon"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054 19.648 19.648 0 0 0 4.636.528c.164 0 .329 0 .494-.002 1.694-.042 3.48-.152 5.12-.554 2.21-.543 4.137-2.186 4.348-4.55.162-1.808.21-3.627.142-5.43-.02-.6-.168-1.874-.168-1.874z"/><path d="M19.903 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192zM9.337 7.515v5.834c0 1.226-.996 2.222-2.222 2.222h-.796c-1.226 0-2.222-.996-2.222-2.222V7.628c0-1.226.996-2.222 2.222-2.222h.796c.122 0 .242.01.36.03 1.076.164 1.862 1.098 1.862 2.192z" fill="#1a1209"/></svg></a>
|
||||||
|
<a href="https://primal.net/p/nprofile1qqswsam9cx06j7sxzpl498uquk3kgrwedxtq48j57zxkuj8fs82xtugge0wtg" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Nostr"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186.31a.27.27 0 0 0-.372 0C8.46 3.487 2.666 9.93 2.666 15.042c0 5.176 4.183 8.958 9.334 8.958s9.334-3.782 9.334-8.958c0-5.112-5.794-11.555-9.148-14.732z"/></svg></a>
|
||||||
|
<a href="https://www.threads.com/@lukeattheroost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="Threads"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.59 12c.025 3.086.718 5.496 2.057 7.164 1.432 1.781 3.632 2.695 6.54 2.717 2.227-.017 4.048-.59 5.413-1.703 1.428-1.163 2.076-2.645 1.925-4.403-.098-1.13-.578-2.065-1.39-2.7-.811-.636-1.905-.993-3.164-1.033a11.253 11.253 0 0 0-.04 0c-1.078.007-2.044.289-2.79.816-.68.481-1.069 1.108-1.125 1.813-.057.72.264 1.32.877 1.64.554.29 1.317.437 2.271.437l.013-.001c.652-.004 1.383-.078 2.172-.218l.386 2.022c-.947.18-1.837.273-2.643.278a10.35 10.35 0 0 1-.143 0c-1.425-.013-2.657-.284-3.66-.804-1.237-.643-1.928-1.745-1.836-2.93.099-1.258.738-2.316 1.849-3.064 1.088-.732 2.466-1.12 3.988-1.124h.05c1.644.044 3.088.528 4.178 1.398 1.133.905 1.8 2.185 1.935 3.703.2 2.258-.697 4.2-2.598 5.75-1.668 1.36-3.863 2.087-6.348 2.105z"/></svg></a>
|
||||||
|
<a href="https://www.linkedin.com/company/luke-at-the-roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="LinkedIn"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg></a>
|
||||||
|
<a href="https://www.tiktok.com/@luke.at.the.roost" target="_blank" rel="noopener" class="footer-icon-link" aria-label="TikTok"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer-projects">
|
||||||
|
<span class="footer-projects-label">More from Luke</span>
|
||||||
|
<div class="footer-projects-links">
|
||||||
|
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
|
||||||
|
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
|
||||||
|
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
||||||
|
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||||
|
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="/terms">Terms of Service</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
initFooter();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/footer.js
|
||||||
|
git commit -m "Add shared footer component (js/footer.js)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Replace inline footers with shared component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/index.html` — replace lines 265-306 (inline footer content) with empty `<footer class="footer"></footer>`, add `<script src="js/footer.js"></script>` before closing `</body>`
|
||||||
|
- Modify: `website/episode.html` — replace lines 95-136 with empty footer, add script tag
|
||||||
|
- Modify: `website/clips.html` — replace lines 68-109 with empty footer, add script tag
|
||||||
|
- Modify: `website/stats.html` — replace inline footer with empty footer, add script tag
|
||||||
|
- Modify: `website/privacy.html` — replace inline footer with empty footer, add script tag
|
||||||
|
- Modify: `website/terms.html` — replace inline footer with empty footer, add script tag
|
||||||
|
- Modify: `website/how-it-works.html` — replace inline footer with empty footer, add script tag
|
||||||
|
|
||||||
|
**Step 1: Update each page**
|
||||||
|
|
||||||
|
For each of the 7 HTML files:
|
||||||
|
1. Replace the entire `<footer class="footer">...</footer>` block with just `<footer class="footer"></footer>`
|
||||||
|
2. Add `<script src="js/footer.js"></script>` near the end of `<body>`, before any page-specific scripts
|
||||||
|
|
||||||
|
Note: index.html's footer has slightly different nav links (no "Home" link since it IS home). The shared footer includes "Home" which is fine — clicking Home on the homepage just reloads it.
|
||||||
|
|
||||||
|
**Step 2: Verify no footer content remains inline**
|
||||||
|
|
||||||
|
Search for `footer-icons-label` in all HTML files — should only appear in `js/footer.js`.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/index.html website/episode.html website/clips.html website/stats.html website/privacy.html website/terms.html website/how-it-works.html
|
||||||
|
git commit -m "Replace inline footers with shared footer.js component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Extract shared audio player module (`js/player.js`)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `website/js/player.js`
|
||||||
|
|
||||||
|
The audio player code is duplicated: `app.js:1-11,14-23,143-226` and `episode.html:159-346` (inline `<script>`). Extract the shared player logic.
|
||||||
|
|
||||||
|
**Step 1: Write player.js**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const audio = document.getElementById('audio-element');
|
||||||
|
const stickyPlayer = document.getElementById('sticky-player');
|
||||||
|
const playerPlayBtn = document.getElementById('player-play-btn');
|
||||||
|
const playerTitle = document.getElementById('player-title');
|
||||||
|
const playerProgress = document.getElementById('player-progress');
|
||||||
|
const playerProgressFill = document.getElementById('player-progress-fill');
|
||||||
|
const playerTime = document.getElementById('player-time');
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
|
const s = Math.floor(seconds);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlayIcons(playing) {
|
||||||
|
const iconPlay = playerPlayBtn.querySelector('.icon-play');
|
||||||
|
const iconPause = playerPlayBtn.querySelector('.icon-pause');
|
||||||
|
if (iconPlay) iconPlay.style.display = playing ? 'none' : 'block';
|
||||||
|
if (iconPause) iconPause.style.display = playing ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener('play', () => updatePlayIcons(true));
|
||||||
|
audio.addEventListener('pause', () => updatePlayIcons(false));
|
||||||
|
audio.addEventListener('ended', () => updatePlayIcons(false));
|
||||||
|
audio.addEventListener('timeupdate', () => {
|
||||||
|
if (audio.duration) {
|
||||||
|
playerProgressFill.style.width = (audio.currentTime / audio.duration * 100) + '%';
|
||||||
|
playerTime.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
playerPlayBtn.addEventListener('click', () => {
|
||||||
|
if (audio.src) { audio.paused ? audio.play() : audio.pause(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
playerProgress.addEventListener('click', (e) => {
|
||||||
|
if (audio.duration) {
|
||||||
|
const rect = playerProgress.getBoundingClientRect();
|
||||||
|
audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/player.js
|
||||||
|
git commit -m "Extract shared audio player module (js/player.js)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Refactor app.js and episode.html to use player.js
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/js/app.js` — remove duplicated player code (element lookups, formatTime, audio event listeners, updatePlayIcons, playerPlayBtn click, playerProgress click). Keep: FEED_URL, episode-specific logic (fetchEpisodes, renderEpisodes, playEpisode with card-specific icon toggling), formatDate, parseDuration, truncate, testimonials, on-air.
|
||||||
|
- Modify: `website/episode.html` — remove inline `<script>` block (lines 159-347), replace with `<script src="js/player.js"></script>` then `<script src="js/episode.js"></script>`
|
||||||
|
- Create: `website/js/episode.js` — episode-specific logic extracted from episode.html inline script (load episode from RSS, populate header, transcript loading, play button)
|
||||||
|
- Modify: `website/index.html` — add `<script src="js/player.js"></script>` before `app.js`
|
||||||
|
|
||||||
|
**Step 1: Refactor app.js**
|
||||||
|
|
||||||
|
Remove from app.js:
|
||||||
|
- Lines 1-11 (element lookups — now in player.js)
|
||||||
|
- Lines 14-23 (formatTime — now in player.js)
|
||||||
|
- Lines 173-226 (audio event listeners, updatePlayIcons, playerPlayBtn click, playerProgress click — now in player.js)
|
||||||
|
|
||||||
|
Keep the `currentEpisodeCard` variable and the card-specific icon toggling in `updatePlayIcons`. Since player.js handles the sticky player icons, app.js only needs to handle the episode card icons. Add a listener:
|
||||||
|
|
||||||
|
```js
|
||||||
|
audio.addEventListener('play', () => {
|
||||||
|
if (currentEpisodeCard) {
|
||||||
|
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||||
|
if (btn) { btn.innerHTML = pauseSVG; btn.classList.add('playing'); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
audio.addEventListener('pause', () => {
|
||||||
|
if (currentEpisodeCard) {
|
||||||
|
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||||
|
if (btn) { btn.innerHTML = playSVG; btn.classList.remove('playing'); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
audio.addEventListener('ended', () => {
|
||||||
|
if (currentEpisodeCard) {
|
||||||
|
const btn = currentEpisodeCard.querySelector('.episode-play-btn');
|
||||||
|
if (btn) { btn.innerHTML = playSVG; btn.classList.remove('playing'); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create episode.js**
|
||||||
|
|
||||||
|
Extract episode-specific logic from episode.html inline script. Use the global `audio`, `playerTitle`, `stickyPlayer` from player.js. Include: `formatDate`, `parseDuration`, `stripHtml`, slug parsing, `loadEpisode()`.
|
||||||
|
|
||||||
|
**Step 3: Update HTML script tags**
|
||||||
|
|
||||||
|
In `index.html`, change script loading order:
|
||||||
|
```html
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
|
<script src="js/clips.js"></script>
|
||||||
|
<script>renderFeaturedClipsInline('home-clips');</script>
|
||||||
|
<script src="js/player.js"></script>
|
||||||
|
<script src="js/app.js?v=3"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
In `episode.html`, replace inline `<script>` (lines 159-347) with:
|
||||||
|
```html
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
|
<script src="js/player.js"></script>
|
||||||
|
<script src="js/episode.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/app.js website/js/player.js website/js/episode.js website/index.html website/episode.html
|
||||||
|
git commit -m "Deduplicate audio player code into shared player.js module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Fix Plausible analytics — switch all subpages to proxied version
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/episode.html` line 51-52
|
||||||
|
- Modify: `website/clips.html` line 43-44
|
||||||
|
- Modify: `website/stats.html` line 43-44
|
||||||
|
- Modify: `website/privacy.html` line 37-38
|
||||||
|
- Modify: `website/terms.html` line 37-38
|
||||||
|
- Modify: `website/how-it-works.html` line 66-67
|
||||||
|
|
||||||
|
**Step 1: In each file, replace the direct Plausible script tag**
|
||||||
|
|
||||||
|
Replace:
|
||||||
|
```html
|
||||||
|
<script defer data-domain="lukeattheroost.com" src="https://plausible.macneilmediagroup.com/js/script.file-downloads.hash.outbound-links.pageview-props.revenue.tagged-events.js"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
With:
|
||||||
|
```html
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify**
|
||||||
|
|
||||||
|
Grep for `plausible.macneilmediagroup.com` in HTML files — should return 0 matches (only `_worker.js` should have it).
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/episode.html website/clips.html website/stats.html website/privacy.html website/terms.html website/how-it-works.html
|
||||||
|
git commit -m "Switch all subpages to proxied Plausible analytics"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Worker — social crawler meta tag injection for episode pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/_worker.js`
|
||||||
|
|
||||||
|
**Step 1: Add social crawler detection and meta injection**
|
||||||
|
|
||||||
|
Before the `return env.ASSETS.fetch(request)` line (line 90), add a handler for `/episode.html` requests from social crawlers:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Social crawler meta injection for episode pages
|
||||||
|
if (url.pathname === "/episode.html" && url.searchParams.get("slug")) {
|
||||||
|
const ua = (request.headers.get("User-Agent") || "").toLowerCase();
|
||||||
|
const isCrawler = /facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot/i.test(ua);
|
||||||
|
|
||||||
|
if (isCrawler) {
|
||||||
|
const slug = url.searchParams.get("slug");
|
||||||
|
|
||||||
|
// Fetch RSS to find episode info
|
||||||
|
try {
|
||||||
|
const feedResp = await fetch("https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml", {
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
if (feedResp.ok) {
|
||||||
|
const feedXml = await feedResp.text();
|
||||||
|
|
||||||
|
// Simple string-based extraction (no DOM parser in Workers)
|
||||||
|
const items = feedXml.split("<item>");
|
||||||
|
let title = "";
|
||||||
|
let description = "";
|
||||||
|
|
||||||
|
for (let i = 1; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
const linkMatch = item.match(/<link>(.*?)<\/link>/);
|
||||||
|
if (linkMatch) {
|
||||||
|
const itemSlug = linkMatch[1].split("/episodes/").pop()?.replace(/\/$/, "");
|
||||||
|
if (itemSlug === slug) {
|
||||||
|
const titleMatch = item.match(/<title>(.*?)<\/title>/);
|
||||||
|
title = titleMatch ? titleMatch[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim() : "";
|
||||||
|
const descMatch = item.match(/<description>(.*?)<\/description>/s);
|
||||||
|
description = descMatch
|
||||||
|
? descMatch[1].replace(/<!\[CDATA\[|\]\]>/g, "").replace(/<[^>]+>/g, "").trim().slice(0, 200)
|
||||||
|
: "";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
// Fetch the actual HTML page
|
||||||
|
const pageResp = await env.ASSETS.fetch(request);
|
||||||
|
let html = await pageResp.text();
|
||||||
|
|
||||||
|
const escTitle = title.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||||
|
const escDesc = description.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
||||||
|
const canonicalUrl = `https://lukeattheroost.com/episode.html?slug=${slug}`;
|
||||||
|
|
||||||
|
// Replace placeholder meta tags
|
||||||
|
html = html.replace(
|
||||||
|
/<meta property="og:title"[^>]*>/,
|
||||||
|
`<meta property="og:title" content="${escTitle}">`
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/<meta property="og:description"[^>]*>/,
|
||||||
|
`<meta property="og:description" content="${escDesc}">`
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/<meta property="og:url"[^>]*>/,
|
||||||
|
`<meta property="og:url" content="${canonicalUrl}">`
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/<meta name="twitter:title"[^>]*>/,
|
||||||
|
`<meta name="twitter:title" content="${escTitle}">`
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/<meta name="twitter:description"[^>]*>/,
|
||||||
|
`<meta name="twitter:description" content="${escDesc}">`
|
||||||
|
);
|
||||||
|
html = html.replace(
|
||||||
|
/<title[^>]*>.*?<\/title>/,
|
||||||
|
`<title>${escTitle} — Luke at the Roost</title>`
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/html;charset=UTF-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fall through to static page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/_worker.js
|
||||||
|
git commit -m "Add social crawler meta tag injection for episode pages"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Security — sanitize innerHTML XSS surfaces
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/js/episode.js` (created in Task 4)
|
||||||
|
- Modify: `website/js/app.js`
|
||||||
|
|
||||||
|
**Step 1: Fix episode description XSS in episode.js**
|
||||||
|
|
||||||
|
In the `loadEpisode` function, change line that sets description:
|
||||||
|
```js
|
||||||
|
// BEFORE (XSS):
|
||||||
|
document.getElementById('ep-desc').innerHTML = episode.description || '';
|
||||||
|
|
||||||
|
// AFTER (safe):
|
||||||
|
document.getElementById('ep-desc').textContent = stripHtml(episode.description || '');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Fix title escaping in app.js episode card rendering**
|
||||||
|
|
||||||
|
In `renderEpisodes()`, the title goes into a `data-title` attribute with basic `.replace(/"/g, '"')`. Use the `escapeHTML` pattern from clips.js. Add a helper at top of app.js:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function escapeAttr(str) {
|
||||||
|
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then change line 125:
|
||||||
|
```js
|
||||||
|
// BEFORE:
|
||||||
|
data-title="${ep.title.replace(/"/g, '"')}"
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
data-title="${escapeAttr(ep.title)}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Also escape the title in the aria-label and visible title:
|
||||||
|
```js
|
||||||
|
<button class="episode-play-btn" aria-label="Play ${escapeAttr(ep.title)}" data-url="${escapeAttr(ep.audioUrl)}" data-title="${escapeAttr(ep.title)}">
|
||||||
|
```
|
||||||
|
|
||||||
|
And escape the visible title output:
|
||||||
|
```js
|
||||||
|
<div class="episode-title">${escapeAttr(ep.title)}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/episode.js website/js/app.js
|
||||||
|
git commit -m "Fix XSS: sanitize innerHTML and improve attribute escaping"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Episode pagination — show 10, load more
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/js/app.js`
|
||||||
|
|
||||||
|
**Step 1: Modify renderEpisodes to support pagination**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const EPISODES_PER_PAGE = 10;
|
||||||
|
let allEpisodes = [];
|
||||||
|
let displayedCount = 0;
|
||||||
|
|
||||||
|
function renderEpisodes(episodes) {
|
||||||
|
allEpisodes = episodes;
|
||||||
|
displayedCount = 0;
|
||||||
|
episodesList.innerHTML = '';
|
||||||
|
showMoreEpisodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMoreEpisodes() {
|
||||||
|
const batch = allEpisodes.slice(displayedCount, displayedCount + EPISODES_PER_PAGE);
|
||||||
|
batch.forEach((ep) => {
|
||||||
|
// ... existing card creation code ...
|
||||||
|
episodesList.appendChild(card);
|
||||||
|
});
|
||||||
|
displayedCount += batch.length;
|
||||||
|
|
||||||
|
// Remove existing load-more button if present
|
||||||
|
const existing = document.getElementById('load-more-btn');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
// Add load-more button if there are remaining episodes
|
||||||
|
if (displayedCount < allEpisodes.length) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.id = 'load-more-btn';
|
||||||
|
btn.className = 'load-more-btn';
|
||||||
|
btn.textContent = `Load More (${allEpisodes.length - displayedCount} remaining)`;
|
||||||
|
btn.addEventListener('click', showMoreEpisodes);
|
||||||
|
episodesList.after(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The `.load-more-btn` CSS class likely needs to be created by the ui-ux task. For now, add minimal inline styling if the class doesn't exist yet. Actually, since we're told not to touch CSS, just use the class name and it will be styled later.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/app.js
|
||||||
|
git commit -m "Add episode pagination with Load More button"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Truncate at word boundaries
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/js/app.js`
|
||||||
|
|
||||||
|
**Step 1: Fix the truncate function**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// BEFORE (line 47-53):
|
||||||
|
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() + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
function truncate(html, maxLen) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = html || '';
|
||||||
|
const text = div.textContent || '';
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
const truncated = text.slice(0, maxLen);
|
||||||
|
const lastSpace = truncated.lastIndexOf(' ');
|
||||||
|
return (lastSpace > maxLen * 0.5 ? truncated.slice(0, lastSpace) : truncated).trimEnd() + '...';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `lastSpace > maxLen * 0.5` guard ensures we don't cut too aggressively if the word boundary is very early.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/app.js
|
||||||
|
git commit -m "Fix truncate to break at word boundaries"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Deduplicate featured clips on clips page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/js/clips.js`
|
||||||
|
|
||||||
|
**Step 1: Fix initClipsPage to exclude featured from "All Clips" grid**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// BEFORE (line 73-77):
|
||||||
|
if (gridContainer) {
|
||||||
|
clips.forEach(clip => {
|
||||||
|
gridContainer.appendChild(renderClipCard(clip, false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
if (gridContainer) {
|
||||||
|
clips.filter(c => !c.featured).forEach(clip => {
|
||||||
|
gridContainer.appendChild(renderClipCard(clip, false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/js/clips.js
|
||||||
|
git commit -m "Deduplicate featured clips from All Clips grid"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Fix content issues — empty clip description and duplicate sitemap entry
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/data/clips.json` — episode 31 clip (line 58): add description
|
||||||
|
- Modify: `website/sitemap.xml` — remove duplicate episode 32 entry (lines 237-242)
|
||||||
|
|
||||||
|
**Step 1: Add description for episode 31 clip**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Started a Fight and Can't Stop Reading About Wars",
|
||||||
|
"description": "A caller starts a fight with their partner and now can't stop obsessively reading about historical wars. Luke tries to unpack the connection.",
|
||||||
|
"episode_number": 31,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Remove duplicate sitemap entry**
|
||||||
|
|
||||||
|
Remove lines 237-242 (the `episode-32-tacos-taxes-and-tense-conversations` entry). Keep `episode-32-tacos-taxes-and-tall-tales` (lines 231-236) as the canonical one, OR check RSS feed to determine which slug is correct. If both exist in the feed, keep both — but episode numbering suggests one is a duplicate/rename. Remove the second one (`tense-conversations` variant).
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/data/clips.json website/sitemap.xml
|
||||||
|
git commit -m "Fix empty clip description and remove duplicate sitemap entry"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Create custom 404 page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `website/404.html`
|
||||||
|
|
||||||
|
**Step 1: Write 404.html**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Page Not Found — Luke at the Roost</title>
|
||||||
|
<meta name="description" content="The page you're looking for doesn't exist.">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
|
<link rel="icon" href="favicon.ico" sizes="48x48">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
|
<link rel="stylesheet" href="css/style.css?v=3">
|
||||||
|
<script defer data-domain="lukeattheroost.com" data-api="/p/event" src="/p/script"></script>
|
||||||
|
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="page-nav">
|
||||||
|
<a href="/" class="nav-home">Luke at the Roost</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="page-header">
|
||||||
|
<h1>404 — Page Not Found</h1>
|
||||||
|
<p class="page-subtitle">Looks like this page wandered off into the desert.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="about-section">
|
||||||
|
<p>The page you're looking for doesn't exist or may have been moved.</p>
|
||||||
|
<p><a href="/">Back to the show</a> · <a href="/clips">Watch clips</a> · <a href="/how-it-works">How it works</a></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer"></footer>
|
||||||
|
<script src="js/footer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/404.html
|
||||||
|
git commit -m "Add custom 404 page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 13: Enhance llms.txt
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/llms.txt`
|
||||||
|
|
||||||
|
**Step 1: Add episode listing section and structured links**
|
||||||
|
|
||||||
|
Add after the FAQ section:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Recent Episodes
|
||||||
|
|
||||||
|
Episodes are published daily. Each has a full transcript available at:
|
||||||
|
https://lukeattheroost.com/episode.html?slug=EPISODE-SLUG
|
||||||
|
|
||||||
|
Episode transcript URLs follow the pattern: episode-N-title-slug
|
||||||
|
Example: https://lukeattheroost.com/episode.html?slug=episode-37-secrets-lies-and-coffee-runs
|
||||||
|
|
||||||
|
## Clip Highlights
|
||||||
|
|
||||||
|
Popular clips with video:
|
||||||
|
- "I Faked Cancer to Skip a Wedding" (Episode 32) — https://youtube.com/watch?v=NUkhsPfMx9o
|
||||||
|
- "Neighbor's Roomba Breaks Into Kitchen at 2:30 AM" (Episode 26) — https://youtube.com/watch?v=J7bfT6jsykA
|
||||||
|
- "Shopping Cart Theory: Moral Test or Crazy?" (Episode 21) — https://youtube.com/watch?v=KijyJsMZfkA
|
||||||
|
|
||||||
|
## Sitemap
|
||||||
|
|
||||||
|
Full sitemap: https://lukeattheroost.com/sitemap.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website/llms.txt
|
||||||
|
git commit -m "Enhance llms.txt with episode patterns and clip highlights"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order & Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
Task 1 (footer.js) — no deps
|
||||||
|
Task 3 (player.js) — no deps
|
||||||
|
Task 5 (analytics fix) — no deps
|
||||||
|
Task 9 (truncate fix) — no deps
|
||||||
|
Task 10 (clips dedup) — no deps
|
||||||
|
Task 11 (content fixes) — no deps
|
||||||
|
Task 12 (404 page) — depends on Task 1 (uses footer.js)
|
||||||
|
Task 13 (llms.txt) — no deps
|
||||||
|
|
||||||
|
Task 2 (replace footers) — depends on Task 1
|
||||||
|
Task 4 (refactor to use player.js) — depends on Task 3
|
||||||
|
Task 6 (worker meta injection) — no deps
|
||||||
|
Task 7 (security fixes) — depends on Task 4 (episode.js must exist)
|
||||||
|
Task 8 (pagination) — can run anytime, modifies app.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parallel batch 1** (independent): Tasks 1, 3, 5, 6, 9, 10, 11, 13
|
||||||
|
**Parallel batch 2** (deps resolved): Tasks 2, 4, 12
|
||||||
|
**Parallel batch 3** (deps resolved): Tasks 7, 8
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
# Show Theme Feature Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add a "show theme" input to the header bar that injects theme context into caller background generation and conversation prompts, nudging callers toward the theme without forcing it.
|
||||||
|
|
||||||
|
**Architecture:** Store theme as a string on the Session object. Pass it into `_generate_caller_background_llm()` to bias character creation and into `get_caller_prompt()` so callers are aware of the show's theme during dialog. Frontend adds a text input in the header with a set/clear button, persisted via a new API endpoint.
|
||||||
|
|
||||||
|
**Tech Stack:** Python (FastAPI backend), vanilla HTML/CSS/JS frontend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add theme to Session and API endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:6192-6218` (Session class)
|
||||||
|
- Modify: `backend/main.py:8731-8759` (settings endpoints area)
|
||||||
|
|
||||||
|
**Step 1: Add `show_theme` field to Session.__init__**
|
||||||
|
|
||||||
|
In `backend/main.py`, inside `Session.__init__` (line ~6217, after `self.intern_monitoring`), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.show_theme: str = "" # Current show theme (e.g. "St. Patrick's Day")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add GET/POST endpoints for show theme**
|
||||||
|
|
||||||
|
Add these endpoints near the existing settings endpoints (~line 8760):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/api/show-theme")
|
||||||
|
async def get_show_theme():
|
||||||
|
return {"theme": session.show_theme}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/show-theme")
|
||||||
|
async def set_show_theme(data: dict):
|
||||||
|
theme = data.get("theme", "").strip()
|
||||||
|
old_theme = session.show_theme
|
||||||
|
session.show_theme = theme
|
||||||
|
if theme:
|
||||||
|
print(f"[Theme] Show theme set: {theme}")
|
||||||
|
elif old_theme:
|
||||||
|
print(f"[Theme] Show theme cleared (was: {old_theme})")
|
||||||
|
return {"theme": session.show_theme}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify the server starts without errors**
|
||||||
|
|
||||||
|
Run: `curl -s http://localhost:8000/api/show-theme | python -m json.tool`
|
||||||
|
Expected: `{"theme": ""}`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Add show theme to Session and API endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Inject theme into caller background generation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:5208-5330` (`_generate_caller_background_llm`)
|
||||||
|
- Modify: `backend/main.py:5381-5396` (`_pregenerate_backgrounds`)
|
||||||
|
|
||||||
|
**Step 1: Pass theme into background generation prompt**
|
||||||
|
|
||||||
|
In `_generate_caller_background_llm()` (~line 5307), after the line that builds `prompt = f"""Write a brief character description...`, add theme context. Find this section of the prompt string (around line 5316):
|
||||||
|
|
||||||
|
```python
|
||||||
|
{f'CALLER ENERGY: {style_hint}' if style_hint else ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
Immediately after that line (still inside the f-string), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{f"SHOW THEME: Tonight's show theme is '{session.show_theme}'. This caller might have a story or angle related to this theme — or they might not. Not every caller has to be about the theme, but if their reason for calling can naturally connect to it, lean into that connection. The theme should feel like a through-line, not a mandate." if session.show_theme else ''}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify backgrounds generate with theme**
|
||||||
|
|
||||||
|
Set a theme via API, then start a new session and check the server logs for background generation.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://localhost:8000/api/show-theme -H 'Content-Type: application/json' -d '{"theme": "St. Patricks Day"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Inject show theme into caller background generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Inject theme into conversation system prompt
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:5995-6094` (`get_caller_prompt`)
|
||||||
|
|
||||||
|
**Step 1: Add theme block to get_caller_prompt**
|
||||||
|
|
||||||
|
In `get_caller_prompt()`, after the `world_context` block is built (around line 6015), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
theme_context = ""
|
||||||
|
if session.show_theme:
|
||||||
|
theme_context = f"\nSHOW THEME: Tonight's show theme is \"{session.show_theme}\". You're aware of the theme — the host mentioned it at the top of the show. If your story or situation connects to it, you might bring it up naturally. But don't force it. Not every caller has to be about the theme. If the host steers you toward the theme, go with it.\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inject `{theme_context}` into the return f-string. Find this line (~6063):
|
||||||
|
|
||||||
|
```python
|
||||||
|
{relationship_context}{history}{world_context}{emotional_read}
|
||||||
|
```
|
||||||
|
|
||||||
|
Change it to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{relationship_context}{history}{world_context}{theme_context}{emotional_read}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify prompt includes theme**
|
||||||
|
|
||||||
|
This can be verified by checking server logs during a call (the full prompt is logged at debug level).
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Inject show theme into caller conversation prompt"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add theme input to frontend header
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/index.html` (header section, lines 11-33)
|
||||||
|
- Modify: `frontend/css/style.css`
|
||||||
|
- Modify: `frontend/js/app.js`
|
||||||
|
|
||||||
|
**Step 1: Add theme input HTML to header**
|
||||||
|
|
||||||
|
In `frontend/index.html`, inside the `<header>` section, after the `.header-buttons` div (line ~19) and before the `#show-clock` div (line ~20), add:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="theme-bar">
|
||||||
|
<label for="show-theme-input" class="theme-label">Theme:</label>
|
||||||
|
<input type="text" id="show-theme-input" class="theme-input" placeholder="e.g. St. Patrick's Day" maxlength="100">
|
||||||
|
<button id="set-theme-btn" class="theme-btn set" title="Set show theme">Set</button>
|
||||||
|
<button id="clear-theme-btn" class="theme-btn clear hidden" title="Clear theme">✕</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add CSS for theme bar**
|
||||||
|
|
||||||
|
In `frontend/css/style.css`, add styles for the theme bar. Place near other header styles:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.theme-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #aaa;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-input {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #f5a623;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-input.active {
|
||||||
|
border-color: #f5a623;
|
||||||
|
background: rgba(245, 166, 35, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.set {
|
||||||
|
background: #f5a623;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.set:hover {
|
||||||
|
background: #e6991a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.clear {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #aaa;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.clear:hover {
|
||||||
|
background: rgba(255, 80, 80, 0.3);
|
||||||
|
color: #ff5050;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add JS for theme set/clear**
|
||||||
|
|
||||||
|
In `frontend/js/app.js`, add theme management functions and wire up event listeners. Add near other initialization code:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function loadShowTheme() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/show-theme');
|
||||||
|
const data = await res.json();
|
||||||
|
const input = document.getElementById('show-theme-input');
|
||||||
|
const setBtn = document.getElementById('set-theme-btn');
|
||||||
|
const clearBtn = document.getElementById('clear-theme-btn');
|
||||||
|
if (data.theme) {
|
||||||
|
input.value = data.theme;
|
||||||
|
input.classList.add('active');
|
||||||
|
setBtn.classList.add('hidden');
|
||||||
|
clearBtn.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load show theme:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setShowTheme() {
|
||||||
|
const input = document.getElementById('show-theme-input');
|
||||||
|
const theme = input.value.trim();
|
||||||
|
if (!theme) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/show-theme', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ theme })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.theme) {
|
||||||
|
input.classList.add('active');
|
||||||
|
document.getElementById('set-theme-btn').classList.add('hidden');
|
||||||
|
document.getElementById('clear-theme-btn').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set show theme:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearShowTheme() {
|
||||||
|
try {
|
||||||
|
await fetch('/api/show-theme', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ theme: '' })
|
||||||
|
});
|
||||||
|
const input = document.getElementById('show-theme-input');
|
||||||
|
input.value = '';
|
||||||
|
input.classList.remove('active');
|
||||||
|
document.getElementById('set-theme-btn').classList.remove('hidden');
|
||||||
|
document.getElementById('clear-theme-btn').classList.add('hidden');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to clear show theme:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire up event listeners (in the DOMContentLoaded or init block):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
document.getElementById('set-theme-btn').addEventListener('click', setShowTheme);
|
||||||
|
document.getElementById('clear-theme-btn').addEventListener('click', clearShowTheme);
|
||||||
|
document.getElementById('show-theme-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') setShowTheme();
|
||||||
|
});
|
||||||
|
loadShowTheme();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Test in browser**
|
||||||
|
|
||||||
|
1. Open http://localhost:8000
|
||||||
|
2. Type a theme in the input, click Set — input should highlight amber, Set button hides, X button appears
|
||||||
|
3. Click X — input clears, reverts to normal state
|
||||||
|
4. Refresh page — theme should persist (loaded from API)
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/index.html frontend/css/style.css frontend/js/app.js
|
||||||
|
git commit -m "Add show theme input to header bar"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Clear theme on new session
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` (session reset logic)
|
||||||
|
|
||||||
|
**Step 1: Find session reset and ensure theme clears**
|
||||||
|
|
||||||
|
Search for where `session = Session()` is called (the new session endpoint). The theme field is already in `__init__` with default `""`, so creating a new Session automatically clears it. No code change needed here — but verify the frontend reloads the theme on new session.
|
||||||
|
|
||||||
|
In the frontend, find the new session button handler and add `loadShowTheme()` after the session reset call completes, so the UI reflects the cleared theme.
|
||||||
|
|
||||||
|
**Step 2: Commit (if any changes needed)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/js/app.js
|
||||||
|
git commit -m "Reload theme state on new session"
|
||||||
|
```
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
# Caller Quality Overhaul — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Make every caller interesting, layered, and conversationally driven — with specific details to reveal when pressed, strong opinions, and stories that sustain 10+ exchanges without going flat.
|
||||||
|
|
||||||
|
**Architecture:** Four interconnected changes: (1) LLM-generated updates for returning callers, (2) hidden layers in CallerBackground, (3) rebalanced caller prompt from reactive to driven, (4) massively expanded topic pools with more edgy/interesting/specific content. The alien abduction call (Bev, ep45) is the gold standard — professional expertise, progressive reveals, specific details, caller-driven energy.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, OpenRouter LLM API, existing CallerBackground dataclass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add Hidden Layers to CallerBackground
|
||||||
|
|
||||||
|
The core structural change. Callers currently get ONE situation paragraph. When the host digs in, there's nothing underneath. Add 3 fields to CallerBackground that give the caller ammunition for deeper conversations.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` — CallerBackground dataclass (~line 6140), LLM background generation prompt (~line 5310)
|
||||||
|
|
||||||
|
**Step 1: Add fields to CallerBackground dataclass**
|
||||||
|
|
||||||
|
Find the CallerBackground dataclass and add three new fields:
|
||||||
|
|
||||||
|
```python
|
||||||
|
hidden_layers: list[str] = field(default_factory=list) # 3 details they haven't mentioned yet — the juicy stuff underneath
|
||||||
|
burning_opinion: str = "" # Something they're dying to say — will bring up even without being asked
|
||||||
|
stakes: str = "" # What's at risk for them — why this matters, what happens if nothing changes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update the LLM background generation prompt**
|
||||||
|
|
||||||
|
In the JSON output spec of the background generation prompt (~line 5322), add:
|
||||||
|
|
||||||
|
```
|
||||||
|
- "hidden_layers": A list of exactly 3 specific details the caller HASN'T mentioned yet but will reveal when pressed. These are the layers underneath the surface story. Think: the part they're embarrassed about, the complication they haven't admitted, the thing that happened AFTER the main event, the detail that changes everything. Each should be 1-2 sentences and SPECIFIC enough to sustain a follow-up question. Example: if the surface story is "my neighbor stole my mail" — layer 1 might be "the stolen mail included a paternity test result," layer 2 might be "the neighbor is actually her ex-husband's new girlfriend," layer 3 might be "she's been retaliating by feeding the neighbor's cat so it likes her better."
|
||||||
|
- "burning_opinion": ONE thing this caller is dying to say — a strong opinion, a controversial take, something they'll volunteer even without being asked. This is what makes them INTERESTING to talk to. Not a generic feeling ("I'm frustrated") but a specific, arguable position ("I think what she did was right and I'd do it again"). Make it provocative enough that the host would want to push back.
|
||||||
|
- "stakes": What's at risk for this caller. Why does this matter? What happens if nothing changes? "My sister won't talk to me" is weak. "If I don't fix this by Thursday, my sister is telling our parents about the money I took and I'll get cut out of the will" is strong. Real consequences, real deadlines, real pressure.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Update the prompt's "WHAT MAKES A GOOD CALLER" section**
|
||||||
|
|
||||||
|
Add to the existing guidance:
|
||||||
|
|
||||||
|
```
|
||||||
|
DEPTH TEST: Before finalizing, ask yourself — if the host asks "tell me more about that" THREE times, does the caller have three genuinely new, interesting things to reveal? If not, the story is too shallow. Add complications, secrets, or consequences that create layers.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Thread hidden_layers into the caller system prompt**
|
||||||
|
|
||||||
|
In `get_caller_prompt()` (~line 6015), add a section that feeds hidden layers to the caller:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Hidden layers — details to reveal when pressed
|
||||||
|
layers_block = ""
|
||||||
|
if caller.get('hidden_layers'):
|
||||||
|
layers = caller['hidden_layers']
|
||||||
|
layers_block = f"""
|
||||||
|
DETAILS YOU HAVEN'T MENTIONED YET (reveal these naturally when Luke asks follow-up questions or digs deeper — don't dump them all at once, let them come out one at a time as the conversation develops):
|
||||||
|
- {layers[0] if len(layers) > 0 else ''}
|
||||||
|
- {layers[1] if len(layers) > 1 else ''}
|
||||||
|
- {layers[2] if len(layers) > 2 else ''}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Burning opinion — something they're dying to say
|
||||||
|
opinion_block = ""
|
||||||
|
if caller.get('burning_opinion'):
|
||||||
|
opinion_block = f"""
|
||||||
|
SOMETHING YOU'RE DYING TO SAY: {caller['burning_opinion']}
|
||||||
|
You'll bring this up when there's a natural opening — you don't need to be asked. This is YOUR call and you have a POINT to make.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Stakes — why this matters
|
||||||
|
stakes_block = ""
|
||||||
|
if caller.get('stakes'):
|
||||||
|
stakes_block = f"""
|
||||||
|
WHAT'S AT STAKE: {caller['stakes']}
|
||||||
|
This isn't abstract — there are real consequences. Mention this when it's relevant. It's why you're calling NOW instead of just thinking about it.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
Insert these blocks into the prompt after `{story_block}`.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: LLM-Generated Updates for Returning Callers
|
||||||
|
|
||||||
|
Currently `_generate_returning_caller_background()` gives returning callers their old summaries and a one-line "something has changed." The dialog model has to improvise an update from nothing. Fix this by using the LLM to generate a specific, interesting new development.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` — `_generate_returning_caller_background()` (~line 4722)
|
||||||
|
|
||||||
|
**Step 1: Add LLM story continuation to returning caller background**
|
||||||
|
|
||||||
|
After building `prev_section` with call history (~line 4764), add an async LLM call to generate a new development:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Generate a specific new development via LLM
|
||||||
|
update_prompt = f"""A returning caller on a late-night radio show is calling back. Here's their history:
|
||||||
|
|
||||||
|
NAME: {regular['name']}, {age}, {gender}
|
||||||
|
JOB: {job}
|
||||||
|
PREVIOUS CALLS:
|
||||||
|
{chr(10).join(f'- {c["summary"]}' for c in prev_calls[-3:])}
|
||||||
|
|
||||||
|
Generate a SPECIFIC new development in their ongoing situation. Something has changed, escalated, or taken an unexpected turn since their last call. This should be:
|
||||||
|
- Surprising but believable — a natural next chapter, not a soap opera twist
|
||||||
|
- Specific with names, details, and concrete events
|
||||||
|
- Interesting enough to sustain a 5-10 minute conversation
|
||||||
|
- Connected to their previous calls but moving the story FORWARD
|
||||||
|
|
||||||
|
Also generate 3 hidden layers (details they'll reveal when pressed) and a burning opinion (something they're dying to say about how things have developed).
|
||||||
|
|
||||||
|
Respond with JSON:
|
||||||
|
{{
|
||||||
|
"new_development": "2-3 sentences describing what happened since their last call",
|
||||||
|
"hidden_layers": ["detail 1", "detail 2", "detail 3"],
|
||||||
|
"burning_opinion": "their strong take on the situation now"
|
||||||
|
}}
|
||||||
|
|
||||||
|
Output ONLY valid JSON, no markdown fences."""
|
||||||
|
```
|
||||||
|
|
||||||
|
Make this an async function. Call the LLM, parse the JSON, and inject the new_development into `prev_section` and the hidden_layers/burning_opinion into the caller's background data.
|
||||||
|
|
||||||
|
**Step 2: Update the returning caller story_block in get_caller_prompt()**
|
||||||
|
|
||||||
|
Change the returning caller `story_block` (~line 6056) from the passive "something has developed" to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
story_block = f"""YOUR STORY: You're calling back about your ongoing situation. Here's what's NEW since your last call — this is why you're calling tonight:
|
||||||
|
|
||||||
|
{{new_development}}
|
||||||
|
|
||||||
|
You have SPECIFIC things to talk about. Don't just vaguely reference "things have changed" — tell Luke EXACTLY what happened. You're calling because this new development is significant and you need to talk it through. You have details, you have feelings about it, and you have a point you want to make.
|
||||||
|
|
||||||
|
When Luke asks about your previous calls, give him the quick version — then get to what's NEW. The update is why you're here tonight."""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Rebalance Caller Prompt — Driven, Not Passive
|
||||||
|
|
||||||
|
The current prompt over-emphasizes reactivity ("GO WHERE THE HOST TAKES YOU", "Let him drive"). This makes callers passive. Real compelling callers have their own agenda AND respond to the host.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` — `get_caller_prompt()` (~line 6015)
|
||||||
|
|
||||||
|
**Step 1: Rewrite the "GO WHERE THE HOST TAKES YOU" section**
|
||||||
|
|
||||||
|
Replace the current passive framing (~line 6094) with a balanced version:
|
||||||
|
|
||||||
|
```
|
||||||
|
GO WITH THE HOST BUT BRING YOUR OWN ENERGY. When Luke pushes you in a direction, challenges you, calls you out, or plays devil's advocate — engage with it. Don't shut down, don't deflect. If he says "but isn't that really about your dad?" — sit with that. BUT you're not a passive interview subject. You called because you have something to SAY. Between his questions, volunteer details he didn't ask for. Share the part you're embarrassed about. Drop the detail that changes everything. Push back when you disagree. Ask HIM what he thinks. The best callers are the ones who give the host material AND have their own momentum. You're not here to answer questions — you're here to have a CONVERSATION.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update the "REACT TO LUKE" section**
|
||||||
|
|
||||||
|
Change from pure reactivity (~line 6096) to a balance:
|
||||||
|
|
||||||
|
```
|
||||||
|
REACT TO LUKE — BUT KEEP YOUR MOMENTUM: Your first sentence should respond to what Luke just said. But your SECOND sentence should add something new — a detail he didn't ask for, a complication, a related story, your opinion. Don't just answer and stop. Answer, then GIVE HIM MORE. If he asks "what happened next?" — don't just tell him what happened next. Tell him what happened next AND how it made you feel AND the part you haven't told anyone yet. Fill the space. Dead air is your enemy.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add a "WHEN LUKE ASKS FOR DETAILS" section**
|
||||||
|
|
||||||
|
Add a new section after the REACT TO LUKE block:
|
||||||
|
|
||||||
|
```
|
||||||
|
WHEN LUKE ASKS FOR DETAILS — DELIVER. If Luke asks "tell me more about that" or "what do you mean?" or pushes for specifics — this is your moment. Don't give a vague one-sentence answer. Paint the picture. Who was there? What did they actually say? What were you doing when it happened? What did the room look like? What was going through your head? Specifics are what make a call memorable. "She was mad" is boring. "She threw her drink at the wall and said 'I knew you'd do this, you're just like your father'" is radio gold. ALWAYS have a specific answer ready — if Luke is digging, it means he's interested. Reward that interest with detail.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Massively Expand Topic Pools
|
||||||
|
|
||||||
|
The current pools have good coverage but need more entries in the categories that produce the best calls: morally complex situations, sex/relationship drama, genuinely weird experiences, and callers with real questions about their lives. The alien abduction call worked because it was SPECIFIC, WEIRD, and the caller had EXPERTISE.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` — PROBLEMS, STORIES, WEIRD, ADVICE, GOSSIP, TOPIC_CALLIN pools
|
||||||
|
|
||||||
|
**Step 1: Add 80+ new PROBLEMS entries**
|
||||||
|
|
||||||
|
Focus on: moral dilemmas with no clear right answer, sex/relationship situations that are messy and specific, workplace drama with real stakes, family situations with complications. Every entry should pass the "tell me more" test — can you ask 3 follow-up questions and get interesting answers?
|
||||||
|
|
||||||
|
Categories to emphasize:
|
||||||
|
- **Moral dilemmas**: situations where both sides have a point, where doing the "right" thing has real costs
|
||||||
|
- **Sex/relationship mess**: not generic "my partner cheated" but specific, awkward, funny situations with details
|
||||||
|
- **Money/ethics**: found money, inheritance drama, business partner disputes, discovered fraud
|
||||||
|
- **Secrets discovered**: found out something they shouldn't know, now they have to decide what to do with it
|
||||||
|
- **Professional expertise callers**: people calling about weird things they encountered AT WORK (like Bev the nurse with the alien patient) — medical, legal, construction, teaching, law enforcement perspectives
|
||||||
|
|
||||||
|
**Step 2: Add 60+ new WEIRD entries**
|
||||||
|
|
||||||
|
The WEIRD pool produces the best calls when it's specific enough. Focus on:
|
||||||
|
- Genuinely unexplainable personal experiences (not just "something spooky happened")
|
||||||
|
- Bizarre neighbor/coworker behavior with specific ongoing patterns
|
||||||
|
- Objects/places that don't behave normally
|
||||||
|
- Coincidences too specific to be coincidence
|
||||||
|
- Things they've noticed that nobody else seems to see
|
||||||
|
- Callers who have developed elaborate theories about everyday phenomena
|
||||||
|
|
||||||
|
**Step 3: Add 60+ new STORIES entries**
|
||||||
|
|
||||||
|
Focus on stories where the caller was INVOLVED, not just an observer:
|
||||||
|
- Times they did something they can't believe they did
|
||||||
|
- Situations that escalated beyond all reason
|
||||||
|
- Encounters with strangers that changed their perspective
|
||||||
|
- Times they were absolutely, completely wrong about something
|
||||||
|
- Situations where they were the bad guy and know it
|
||||||
|
- Things they got away with that they probably shouldn't have
|
||||||
|
|
||||||
|
**Step 4: Add 40+ new ADVICE entries**
|
||||||
|
|
||||||
|
Real questions people would actually call a radio show about:
|
||||||
|
- "Am I wrong for..." situations with genuine ambiguity
|
||||||
|
- Situations where they've already decided but want validation
|
||||||
|
- Timing/approach questions ("how do I tell my wife...")
|
||||||
|
- Callers who are about to do something and want a gut check
|
||||||
|
- Callers asking about the host's opinion on something specific and controversial
|
||||||
|
|
||||||
|
**Step 5: Add 40+ new GOSSIP entries**
|
||||||
|
|
||||||
|
Gossip works best when the caller has DETAILS and opinions:
|
||||||
|
- Discovered something about someone in their life that changes everything
|
||||||
|
- Workplace gossip with real consequences if it gets out
|
||||||
|
- Small-town drama with escalating stakes
|
||||||
|
- Things overheard that they probably shouldn't have heard
|
||||||
|
|
||||||
|
**Step 6: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Increase Response Budget for Substantive Answers
|
||||||
|
|
||||||
|
The current budget gives 15% of standard calls only 450 tokens / 3 sentences. When a caller has a great story, 3 sentences isn't enough. Shift the distribution to give callers more room to breathe, especially early in the call.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` — `_pick_response_budget()` (~line 8280)
|
||||||
|
|
||||||
|
**Step 1: Adjust default response budget distribution**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Default distribution — give callers room to tell their story
|
||||||
|
roll = random.random()
|
||||||
|
if roll < 0.10:
|
||||||
|
return 500, 4 # 10% — quick response (was 15% / 3 sentences)
|
||||||
|
elif roll < 0.35:
|
||||||
|
return 600, 5 # 25% — normal conversation (was 30% / 4 sentences)
|
||||||
|
elif roll < 0.65:
|
||||||
|
return 700, 6 # 30% — room to breathe (was 30% / 5 sentences)
|
||||||
|
else:
|
||||||
|
return 800, 7 # 35% — telling a story or riffing (was 25% / 6 sentences)
|
||||||
|
```
|
||||||
|
|
||||||
|
Also bump up shape-specific budgets proportionally.
|
||||||
|
|
||||||
|
**Step 2: Increase MIN_RESPONSE_WORDS**
|
||||||
|
|
||||||
|
Change from 20 to 30:
|
||||||
|
```python
|
||||||
|
MIN_RESPONSE_WORDS = 30 # Retry if response is shorter than this
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Quality Review and Integration Test
|
||||||
|
|
||||||
|
Read through all changes, verify they integrate cleanly, and test with a simulated caller generation.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: all modified files
|
||||||
|
|
||||||
|
**Step 1: Verify CallerBackground field additions parse correctly**
|
||||||
|
|
||||||
|
Run a test background generation to make sure the new fields (hidden_layers, burning_opinion, stakes) are populated by the LLM and parsed into the dataclass.
|
||||||
|
|
||||||
|
**Step 2: Verify returning caller LLM update generates properly**
|
||||||
|
|
||||||
|
Test with an existing regular (e.g., Shonda) to confirm the LLM generates a specific new development.
|
||||||
|
|
||||||
|
**Step 3: Verify the new prompt sections render correctly in get_caller_prompt()**
|
||||||
|
|
||||||
|
Check that hidden_layers, burning_opinion, and stakes blocks appear in the system prompt for a caller that has them.
|
||||||
|
|
||||||
|
**Step 4: Restart server and verify no import/syntax errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkill -f "uvicorn backend.main:app"
|
||||||
|
/Users/lukemacneil/code/ai-podcast/venv/bin/python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# Show Quality Fixes — Episode 47 Post-Mortem
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Fix 5 bugs that ruined tonight's show: theme ignored by callers, wrong LLM models assigned, phonetic pronunciation mangling, voice-age mismatch, and low minimum response threshold.
|
||||||
|
|
||||||
|
**Architecture:** All fixes are in `backend/main.py` except voice-age matching which also touches `backend/services/tts.py` voice matching logic. Each fix is independent — no ordering dependencies between tasks.
|
||||||
|
|
||||||
|
**Tech Stack:** Python, FastAPI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Regenerate caller backgrounds when theme is set
|
||||||
|
|
||||||
|
**Problem:** `_pregenerate_backgrounds()` runs on startup when `session.show_theme` is still `""`. Setting theme via `POST /api/show-theme` only stores the string — doesn't regenerate. Callers have zero theme connection.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:9891-9900` (`set_show_theme` endpoint)
|
||||||
|
- Modify: `backend/main.py:5899-5927` (`_pregenerate_backgrounds`)
|
||||||
|
|
||||||
|
**Step 1: Modify `set_show_theme` to regenerate unused caller backgrounds**
|
||||||
|
|
||||||
|
In `backend/main.py`, replace the `set_show_theme` endpoint (lines 9891-9900):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/api/show-theme")
|
||||||
|
async def set_show_theme(data: dict):
|
||||||
|
theme = data.get("theme", "").strip()[:100]
|
||||||
|
old_theme = session.show_theme
|
||||||
|
session.show_theme = theme
|
||||||
|
if theme:
|
||||||
|
print(f"[Theme] Show theme set: {theme}")
|
||||||
|
elif old_theme:
|
||||||
|
print(f"[Theme] Show theme cleared (was: {old_theme})")
|
||||||
|
|
||||||
|
# Regenerate backgrounds for callers that haven't been on air yet
|
||||||
|
if theme != old_theme:
|
||||||
|
unused_keys = [k for k in CALLER_BASES if k not in session.used_callers]
|
||||||
|
if unused_keys:
|
||||||
|
print(f"[Theme] Regenerating {len(unused_keys)} unused caller backgrounds for theme: {theme or '(none)'}")
|
||||||
|
asyncio.create_task(_regenerate_backgrounds_for_keys(unused_keys))
|
||||||
|
|
||||||
|
return {"theme": session.show_theme}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add `_regenerate_backgrounds_for_keys` helper**
|
||||||
|
|
||||||
|
Add this right after `_pregenerate_backgrounds()` (after line 5927):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _regenerate_backgrounds_for_keys(keys: list[str]):
|
||||||
|
"""Regenerate backgrounds for specific caller keys (e.g. after theme change)."""
|
||||||
|
tasks = []
|
||||||
|
for key in keys:
|
||||||
|
base = CALLER_BASES.get(key)
|
||||||
|
if base and not base.get("returning"):
|
||||||
|
tasks.append((key, _generate_caller_background_llm(base)))
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
return
|
||||||
|
|
||||||
|
results = await asyncio.gather(*[t[1] for t in tasks], return_exceptions=True)
|
||||||
|
for (key, _), result in zip(tasks, results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
print(f"[Theme] Regen failed for caller {key}: {result}")
|
||||||
|
else:
|
||||||
|
session.caller_backgrounds[key] = result
|
||||||
|
# Clear cached model so it re-evaluates with new style
|
||||||
|
session.caller_models.pop(key, None)
|
||||||
|
|
||||||
|
print(f"[Theme] Regenerated {sum(1 for r in results if not isinstance(r, Exception))}/{len(tasks)} backgrounds")
|
||||||
|
_match_voices_to_styles()
|
||||||
|
_sort_caller_queue()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify `used_callers` exists on session**
|
||||||
|
|
||||||
|
Check that `session.used_callers` tracks which callers have already been on air. If it doesn't exist, use `session.call_history` caller keys instead.
|
||||||
|
|
||||||
|
**Step 4: Test manually**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start server
|
||||||
|
python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000
|
||||||
|
# Set theme and check logs for "[Theme] Regenerating..." messages
|
||||||
|
curl -X POST http://localhost:8000/api/show-theme -H "Content-Type: application/json" -d '{"theme": "Road Stories"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Regenerate caller backgrounds when show theme is set"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Fix style-to-model matching race condition
|
||||||
|
|
||||||
|
**Problem:** `get_caller_model()` is called before `caller_styles` is populated. `caller_styles.get(key)` returns `""`, `_normalize_style_key("")` returns `""`, no match in `caller_model_map` → falls through to `caller_model_pool[0]` (grok-4.1-fast) for everyone.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:6848-6875` (`get_caller_model`)
|
||||||
|
|
||||||
|
**Step 1: Fix `get_caller_model` to defer assignment when style is unknown**
|
||||||
|
|
||||||
|
Replace `get_caller_model` (lines 6848-6875):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def get_caller_model(self, caller_key: str) -> str | None:
|
||||||
|
"""Get the assigned model for a caller, or assign one based on strategy.
|
||||||
|
Returns None to use default category routing."""
|
||||||
|
if self.caller_model_strategy == "single":
|
||||||
|
return None # use default category_models["caller_dialog"]
|
||||||
|
|
||||||
|
# Already assigned — keep consistent for the whole call
|
||||||
|
if caller_key in self.caller_models:
|
||||||
|
return self.caller_models[caller_key]
|
||||||
|
|
||||||
|
model = None
|
||||||
|
if self.caller_model_strategy == "cycle":
|
||||||
|
if self.caller_model_pool:
|
||||||
|
model = self.caller_model_pool[self._caller_model_cycle_idx % len(self.caller_model_pool)]
|
||||||
|
self._caller_model_cycle_idx += 1
|
||||||
|
elif self.caller_model_strategy == "style_matched":
|
||||||
|
raw_style = self.caller_styles.get(caller_key, "")
|
||||||
|
style_key = _normalize_style_key(raw_style) if raw_style else ""
|
||||||
|
if style_key:
|
||||||
|
model = self.caller_model_map.get(style_key)
|
||||||
|
if not model:
|
||||||
|
# Style not yet populated or no mapping — use fallback, not pool[0]
|
||||||
|
model = self.caller_model_fallback
|
||||||
|
|
||||||
|
if model:
|
||||||
|
self.caller_models[caller_key] = model
|
||||||
|
caller_name = CALLER_BASES.get(caller_key, {}).get("name", caller_key)
|
||||||
|
style_info = self.caller_styles.get(caller_key, "unknown")
|
||||||
|
print(f"[CallerModel] Assigned {model} to {caller_name} (style={_normalize_style_key(style_info) if style_info else 'none'}, strategy={self.caller_model_strategy})")
|
||||||
|
|
||||||
|
return model
|
||||||
|
```
|
||||||
|
|
||||||
|
The key change: when `style_key` is empty (style not yet populated) or has no mapping, use `caller_model_fallback` (claude-sonnet-4.6) instead of `caller_model_pool[0]` (grok-4.1-fast). Claude Sonnet is a much safer default — empathetic, verbose, coherent.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Fix style-to-model race condition — use fallback instead of pool[0]"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Fix pronunciation fixes producing literal phonetic text
|
||||||
|
|
||||||
|
**Problem:** `_PRONUNCIATION_FIXES` replaces "Animas" with "Ah nee mahs" as literal text. TTS reads each word separately ("Ah" "nee" "mahs") instead of blending into the intended pronunciation.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:9141-9152` (`_PRONUNCIATION_FIXES`)
|
||||||
|
- Modify: `backend/main.py:9212-9216` (`_apply_pronunciation_fixes`)
|
||||||
|
|
||||||
|
**Step 1: Remove pronunciation fixes that sound worse than originals**
|
||||||
|
|
||||||
|
The Inworld TTS actually handles most proper nouns fine. The fixes were added speculatively and cause more harm than good. Remove the place names that TTS can handle, keep only abbreviations:
|
||||||
|
|
||||||
|
Replace `_PRONUNCIATION_FIXES` (lines 9141-9152):
|
||||||
|
|
||||||
|
```python
|
||||||
|
_PRONUNCIATION_FIXES = {
|
||||||
|
"Castopod": "Casto pod",
|
||||||
|
"vs": "versus",
|
||||||
|
"govt": "government",
|
||||||
|
"dept": "department",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove `Lordsburg`, `Hachita`, `Deming`, `Bootheel`, `Animas`, and `Rodeo`. These place names either sound fine through TTS or the phonetic replacement sounds worse.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Remove pronunciation fixes that produce worse TTS output"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add age-awareness to voice matching
|
||||||
|
|
||||||
|
**Problem:** Brandy (55 years old) got "Kayla" (young-sounding voice). `_match_voices_to_styles()` scores on style dimensions (weight, energy, warmth, age_feel) but the `age_feel` preference comes from the communication style, not the character's actual age. A "confrontational" style prefers `age_feel: None` (no preference), so a 55-year-old can get a young voice.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:6106-6156` (`_match_voices_to_styles`)
|
||||||
|
|
||||||
|
**Step 1: Add character age to voice scoring**
|
||||||
|
|
||||||
|
In `_match_voices_to_styles`, after getting the style preferences, override `age_feel` based on the caller's actual age from their background:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _match_voices_to_styles():
|
||||||
|
"""Re-assign voices to match caller communication styles after backgrounds are generated."""
|
||||||
|
from .services.tts import VOICE_PROFILES
|
||||||
|
|
||||||
|
for key, base in CALLER_BASES.items():
|
||||||
|
if base.get("returning"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
style_raw = session.caller_styles.get(key, "")
|
||||||
|
if not style_raw:
|
||||||
|
continue
|
||||||
|
|
||||||
|
style_key = _normalize_style_key(style_raw)
|
||||||
|
prefs = STYLE_VOICE_PREFERENCES.get(style_key)
|
||||||
|
if not prefs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Copy prefs so we don't mutate the shared dict
|
||||||
|
prefs = dict(prefs)
|
||||||
|
|
||||||
|
# Override age_feel based on character's actual age
|
||||||
|
bg = session.caller_backgrounds.get(key)
|
||||||
|
if isinstance(bg, CallerBackground) and bg.age:
|
||||||
|
if bg.age >= 50:
|
||||||
|
prefs["age_feel"] = "mature"
|
||||||
|
elif bg.age >= 35:
|
||||||
|
prefs["age_feel"] = "middle"
|
||||||
|
elif bg.age < 25:
|
||||||
|
prefs["age_feel"] = "young"
|
||||||
|
# 25-34: keep style preference or None
|
||||||
|
|
||||||
|
gender = base["gender"]
|
||||||
|
pool = INWORLD_MALE_VOICES if gender == "male" else INWORLD_FEMALE_VOICES
|
||||||
|
voice_pool = [v for v in pool if v not in BLACKLISTED_VOICES]
|
||||||
|
|
||||||
|
scored = []
|
||||||
|
for voice_name in voice_pool:
|
||||||
|
profile = VOICE_PROFILES.get(voice_name)
|
||||||
|
if not profile:
|
||||||
|
scored.append((voice_name, 0))
|
||||||
|
continue
|
||||||
|
score = 0
|
||||||
|
for dim in ["weight", "energy", "warmth", "age_feel"]:
|
||||||
|
pref_val = prefs.get(dim)
|
||||||
|
if pref_val and profile.get(dim) == pref_val:
|
||||||
|
score += 1
|
||||||
|
scored.append((voice_name, score))
|
||||||
|
|
||||||
|
if scored:
|
||||||
|
names = [s[0] for s in scored]
|
||||||
|
weights = [max(1, s[1] * 3) for s in scored]
|
||||||
|
chosen = random.choices(names, weights=weights, k=1)[0]
|
||||||
|
|
||||||
|
used_voices = {CALLER_BASES[k]["voice"] for k in CALLER_BASES if k != key and "voice" in CALLER_BASES[k]}
|
||||||
|
if chosen in used_voices:
|
||||||
|
alternatives = [(n, w) for n, w in zip(names, weights) if n not in used_voices]
|
||||||
|
if alternatives:
|
||||||
|
alt_names, alt_weights = zip(*alternatives)
|
||||||
|
chosen = random.choices(alt_names, weights=alt_weights, k=1)[0]
|
||||||
|
|
||||||
|
old_voice = base.get("voice", "")
|
||||||
|
base["voice"] = chosen
|
||||||
|
if old_voice != chosen:
|
||||||
|
print(f"[VoiceMatch] {base.get('name', key)}: {old_voice} → {chosen} (style: {style_key}, age: {bg.age if isinstance(bg, CallerBackground) else '?'})")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Add age-awareness to voice matching — 55yo won't get young voices"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Raise minimum response word count
|
||||||
|
|
||||||
|
**Problem:** `MIN_RESPONSE_WORDS = 30` lets through fragmented, telegram-style responses that are technically 30+ words but terrible radio.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py:8844` (`MIN_RESPONSE_WORDS`)
|
||||||
|
|
||||||
|
**Step 1: Raise the minimum**
|
||||||
|
|
||||||
|
Change line 8844:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MIN_RESPONSE_WORDS = 50 # Retry if response is shorter than this
|
||||||
|
```
|
||||||
|
|
||||||
|
50 words is roughly 2-3 spoken sentences — enough to be a coherent radio response without being overly demanding for short-form exchanges.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Raise MIN_RESPONSE_WORDS from 30 to 50"
|
||||||
|
```
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Cost Dashboard Design
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A dedicated cost analytics dashboard at `/costs` that visualizes LLM and TTS spending across sessions with time-range filtering, model/category breakdowns, and drill-down into individual sessions and calls.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Route:** `/costs` served by FastAPI, standalone page matching the control panel's dark theme
|
||||||
|
- **Database:** SQLite (`data/costs.db`) for cross-session aggregation
|
||||||
|
- **Charts:** Chart.js (vanilla JS, no framework)
|
||||||
|
- **Data migration:** On first run, import existing `data/cost_reports/*.json` into SQLite
|
||||||
|
- **Dual write:** `cost_tracker.py` continues writing JSON reports (backward compat) and also writes to SQLite going forward
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### `sessions`
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | TEXT PK | Session ID |
|
||||||
|
| started_at | TIMESTAMP | Session start time |
|
||||||
|
| total_cost | REAL | Total cost USD |
|
||||||
|
| llm_cost | REAL | LLM cost USD |
|
||||||
|
| tts_cost | REAL | TTS cost USD |
|
||||||
|
| total_llm_calls | INTEGER | Number of LLM calls |
|
||||||
|
| total_tts_calls | INTEGER | Number of TTS calls |
|
||||||
|
| total_tokens | INTEGER | Total tokens used |
|
||||||
|
| prompt_tokens | INTEGER | Prompt tokens |
|
||||||
|
| completion_tokens | INTEGER | Completion tokens |
|
||||||
|
|
||||||
|
### `llm_calls`
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | INTEGER PK | Auto-increment |
|
||||||
|
| session_id | TEXT FK | References sessions.id |
|
||||||
|
| timestamp | TIMESTAMP | Call time |
|
||||||
|
| category | TEXT | background_gen, caller_dialog, devon_monitor, etc. |
|
||||||
|
| model | TEXT | Model identifier |
|
||||||
|
| prompt_tokens | INTEGER | Prompt tokens |
|
||||||
|
| completion_tokens | INTEGER | Completion tokens |
|
||||||
|
| cost | REAL | Cost USD |
|
||||||
|
| caller_name | TEXT | Caller name (nullable) |
|
||||||
|
| latency_ms | REAL | Response latency |
|
||||||
|
|
||||||
|
### `tts_calls`
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | INTEGER PK | Auto-increment |
|
||||||
|
| session_id | TEXT FK | References sessions.id |
|
||||||
|
| timestamp | TIMESTAMP | Call time |
|
||||||
|
| provider | TEXT | Inworld, ElevenLabs, etc. |
|
||||||
|
| voice | TEXT | Voice ID |
|
||||||
|
| char_count | INTEGER | Characters synthesized |
|
||||||
|
| cost | REAL | Cost USD |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All new endpoints under `/api/costs/`:
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GET /api/costs/summary?period=today\|week\|month\|all` | Aggregated totals: spend, LLM/TTS split, call count, tokens, avg cost/session, % change vs previous period |
|
||||||
|
| `GET /api/costs/timeline?period=week\|month\|all&group_by=session\|day` | Time-series for line chart (cost over time) |
|
||||||
|
| `GET /api/costs/models?period=week\|month\|all` | Per-model breakdown for pie/bar charts |
|
||||||
|
| `GET /api/costs/categories?period=week\|month\|all` | Per-category breakdown (background_gen, caller_dialog, etc.) |
|
||||||
|
| `GET /api/costs/sessions?period=week\|month\|all` | Session list with totals, sortable |
|
||||||
|
| `GET /api/costs/session/{id}` | Single session detail: per-caller costs, expensive calls, recommendations |
|
||||||
|
| `GET /api/costs/expensive?period=week\|month\|all&limit=10` | Top N most expensive individual calls |
|
||||||
|
|
||||||
|
Existing `/api/costs` (live session) endpoint remains unchanged.
|
||||||
|
|
||||||
|
## Dashboard Layout
|
||||||
|
|
||||||
|
### Header
|
||||||
|
Time range selector tabs: Today / This Week / This Month / All Time. Clicking any tab refreshes all charts.
|
||||||
|
|
||||||
|
### Row 1 — Summary Cards (4 across)
|
||||||
|
- **Total Spend** with % change vs previous period
|
||||||
|
- **LLM / TTS Split** showing both values
|
||||||
|
- **Total Sessions** in period
|
||||||
|
- **Avg Cost Per Session**
|
||||||
|
|
||||||
|
### Row 2 — Two charts side by side
|
||||||
|
- **Left: Cost Over Time** — line chart, x-axis sessions or days, y-axis dollars. LLM and TTS as separate lines.
|
||||||
|
- **Right: Cost by Model** — doughnut chart with legend showing dollar amounts.
|
||||||
|
|
||||||
|
### Row 3 — Two charts side by side
|
||||||
|
- **Left: Cost by Category** — horizontal bar chart (background_gen, caller_dialog, devon_monitor, etc.)
|
||||||
|
- **Right: Cost Per Session Trend** — bar chart, each bar a session, colored above/below average.
|
||||||
|
|
||||||
|
### Row 4 — Tables
|
||||||
|
- **Most Expensive Calls** — top 10 LLM calls (model, category, caller, tokens, cost, timestamp)
|
||||||
|
- **Session List** — all sessions, sortable by date/cost, clickable for detail view.
|
||||||
|
|
||||||
|
### Session Detail View (click-through)
|
||||||
|
- Per-caller cost breakdown
|
||||||
|
- Call-by-call timeline
|
||||||
|
- Recommendations from existing `_generate_recommendations()` logic
|
||||||
|
|
||||||
|
## Visual Style
|
||||||
|
Matches the control panel's existing dark theme. Same fonts, colors, card styles.
|
||||||
|
|
||||||
|
## What's NOT in v1
|
||||||
|
- SignalWire cost tracking (future addition)
|
||||||
|
- Real-time WebSocket updates (polling on page load is sufficient)
|
||||||
|
- Cost alerts/budgets
|
||||||
|
- Export to CSV
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
# Cost Dashboard Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Build a cost analytics dashboard at `/costs` that visualizes LLM and TTS spending across sessions with time-range filtering, charts, and drill-down.
|
||||||
|
|
||||||
|
**Architecture:** SQLite database (`data/costs.db`) stores all cost records. Existing JSON reports are imported on first run. `cost_tracker.py` dual-writes to both JSON and SQLite. New API endpoints serve aggregated data. Standalone HTML page with Chart.js renders the dashboard.
|
||||||
|
|
||||||
|
**Tech Stack:** Python/FastAPI, SQLite, Chart.js (CDN), vanilla JS, CSS custom properties matching existing dark theme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: SQLite Database Module
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `backend/services/cost_db.py`
|
||||||
|
|
||||||
|
**Step 1: Create the database module with schema**
|
||||||
|
|
||||||
|
Create `backend/services/cost_db.py` with:
|
||||||
|
- `init_db(db_path)` — creates tables if not exist, returns connection
|
||||||
|
- `import_json_reports(db_path, reports_dir)` — scans `data/cost_reports/*.json`, imports any sessions not already in the DB
|
||||||
|
- `get_db()` — returns a connection to `data/costs.db`, calls `init_db` on first use
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path(__file__).parent.parent.parent / "data" / "costs.db"
|
||||||
|
REPORTS_DIR = Path(__file__).parent.parent.parent / "data" / "cost_reports"
|
||||||
|
|
||||||
|
_connection = None
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
total_cost REAL DEFAULT 0,
|
||||||
|
llm_cost REAL DEFAULT 0,
|
||||||
|
tts_cost REAL DEFAULT 0,
|
||||||
|
total_llm_calls INTEGER DEFAULT 0,
|
||||||
|
total_tts_calls INTEGER DEFAULT 0,
|
||||||
|
total_tokens INTEGER DEFAULT 0,
|
||||||
|
prompt_tokens INTEGER DEFAULT 0,
|
||||||
|
completion_tokens INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
timestamp TIMESTAMP,
|
||||||
|
category TEXT,
|
||||||
|
model TEXT,
|
||||||
|
prompt_tokens INTEGER DEFAULT 0,
|
||||||
|
completion_tokens INTEGER DEFAULT 0,
|
||||||
|
cost REAL DEFAULT 0,
|
||||||
|
caller_name TEXT,
|
||||||
|
latency_ms REAL DEFAULT 0,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tts_calls (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
timestamp TIMESTAMP,
|
||||||
|
provider TEXT,
|
||||||
|
voice TEXT,
|
||||||
|
char_count INTEGER DEFAULT 0,
|
||||||
|
cost REAL DEFAULT 0,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_session ON llm_calls(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_timestamp ON llm_calls(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_category ON llm_calls(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_llm_model ON llm_calls(model);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tts_session ON tts_calls(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tts_timestamp ON tts_calls(timestamp);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
global _connection
|
||||||
|
if _connection is None:
|
||||||
|
_connection = sqlite3.connect(str(DB_PATH), check_same_thread=False)
|
||||||
|
_connection.row_factory = sqlite3.Row
|
||||||
|
_connection.executescript(SCHEMA)
|
||||||
|
import_json_reports()
|
||||||
|
return _connection
|
||||||
|
|
||||||
|
|
||||||
|
def import_json_reports():
|
||||||
|
db = _connection
|
||||||
|
if not REPORTS_DIR.exists():
|
||||||
|
return
|
||||||
|
existing = {row[0] for row in db.execute("SELECT id FROM sessions").fetchall()}
|
||||||
|
for f in sorted(REPORTS_DIR.glob("*.json")):
|
||||||
|
try:
|
||||||
|
data = json.loads(f.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
session_id = data.get("session_id", f.stem)
|
||||||
|
if session_id in existing:
|
||||||
|
continue
|
||||||
|
saved_at = data.get("saved_at")
|
||||||
|
started_at = datetime.fromtimestamp(saved_at).isoformat() if saved_at else None
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO sessions (id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tts_calls, total_tokens, prompt_tokens, completion_tokens) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, started_at, data.get("total_cost_usd", 0), data.get("llm_cost_usd", 0), data.get("tts_cost_usd", 0), data.get("total_llm_calls", 0), len(data.get("raw_tts_records", [])), data.get("total_tokens", 0), data.get("prompt_tokens", 0), data.get("completion_tokens", 0)),
|
||||||
|
)
|
||||||
|
for rec in data.get("raw_llm_records", []):
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO llm_calls (session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, rec.get("timestamp"), rec.get("category"), rec.get("model"), rec.get("prompt_tokens", 0), rec.get("completion_tokens", 0), rec.get("cost_usd", 0), rec.get("caller_name"), rec.get("latency_ms", 0)),
|
||||||
|
)
|
||||||
|
for rec in data.get("raw_tts_records", []):
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, rec.get("timestamp"), rec.get("provider"), rec.get("voice"), rec.get("char_count", 0), rec.get("cost_usd", 0)),
|
||||||
|
)
|
||||||
|
existing.add(session_id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def record_llm_call(session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms):
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO llm_calls (session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, timestamp, category, model, prompt_tokens, completion_tokens, cost, caller_name, latency_ms),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def record_tts_call(session_id, timestamp, provider, voice, char_count, cost):
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO tts_calls (session_id, timestamp, provider, voice, char_count, cost) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(session_id, timestamp, provider, voice, char_count, cost),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_session(session_id, started_at=None):
|
||||||
|
db = get_db()
|
||||||
|
existing = db.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO sessions (id, started_at) VALUES (?, ?)",
|
||||||
|
(session_id, started_at or datetime.now().isoformat()),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_session_totals(session_id):
|
||||||
|
db = get_db()
|
||||||
|
llm = db.execute(
|
||||||
|
"SELECT COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens), 0) as pt, COALESCE(SUM(completion_tokens), 0) as ct FROM llm_calls WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
tts = db.execute(
|
||||||
|
"SELECT COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM tts_calls WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
total_tokens = llm["pt"] + llm["ct"]
|
||||||
|
total_cost = llm["cost"] + tts["cost"]
|
||||||
|
db.execute(
|
||||||
|
"UPDATE sessions SET total_cost=?, llm_cost=?, tts_cost=?, total_llm_calls=?, total_tts_calls=?, total_tokens=?, prompt_tokens=?, completion_tokens=? WHERE id=?",
|
||||||
|
(total_cost, llm["cost"], tts["cost"], llm["calls"], tts["calls"], total_tokens, llm["pt"], llm["ct"], session_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _period_filter(period):
|
||||||
|
now = datetime.now()
|
||||||
|
if period == "today":
|
||||||
|
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
elif period == "week":
|
||||||
|
start = now - timedelta(days=now.weekday())
|
||||||
|
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
elif period == "month":
|
||||||
|
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return start.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def get_summary(period="all"):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
where = "WHERE started_at >= ?"
|
||||||
|
params = (start,)
|
||||||
|
prev_start = _get_previous_period_start(period)
|
||||||
|
prev_where = "WHERE started_at >= ? AND started_at < ?"
|
||||||
|
prev_params = (prev_start, start)
|
||||||
|
else:
|
||||||
|
where = ""
|
||||||
|
params = ()
|
||||||
|
prev_where = None
|
||||||
|
prev_params = ()
|
||||||
|
|
||||||
|
row = db.execute(
|
||||||
|
f"SELECT COUNT(*) as sessions, COALESCE(SUM(total_cost), 0) as total_cost, COALESCE(SUM(llm_cost), 0) as llm_cost, COALESCE(SUM(tts_cost), 0) as tts_cost, COALESCE(SUM(total_llm_calls), 0) as total_calls, COALESCE(SUM(total_tokens), 0) as total_tokens FROM sessions {where}",
|
||||||
|
params,
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"total_cost": round(row["total_cost"], 4),
|
||||||
|
"llm_cost": round(row["llm_cost"], 4),
|
||||||
|
"tts_cost": round(row["tts_cost"], 4),
|
||||||
|
"sessions": row["sessions"],
|
||||||
|
"total_calls": row["total_calls"],
|
||||||
|
"total_tokens": row["total_tokens"],
|
||||||
|
"avg_cost_per_session": round(row["total_cost"] / max(row["sessions"], 1), 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
if prev_where:
|
||||||
|
prev = db.execute(
|
||||||
|
f"SELECT COALESCE(SUM(total_cost), 0) as total_cost FROM sessions {prev_where}",
|
||||||
|
prev_params,
|
||||||
|
).fetchone()
|
||||||
|
prev_cost = prev["total_cost"]
|
||||||
|
if prev_cost > 0:
|
||||||
|
result["pct_change"] = round((row["total_cost"] - prev_cost) / prev_cost * 100, 1)
|
||||||
|
else:
|
||||||
|
result["pct_change"] = None
|
||||||
|
else:
|
||||||
|
result["pct_change"] = None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_previous_period_start(period):
|
||||||
|
now = datetime.now()
|
||||||
|
if period == "today":
|
||||||
|
return (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
elif period == "week":
|
||||||
|
start_of_week = now - timedelta(days=now.weekday())
|
||||||
|
return (start_of_week - timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
elif period == "month":
|
||||||
|
first_of_month = now.replace(day=1)
|
||||||
|
prev_month = first_of_month - timedelta(days=1)
|
||||||
|
return prev_month.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_timeline(period="all", group_by="session"):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if group_by == "day":
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT DATE(started_at) as date, SUM(llm_cost) as llm_cost, SUM(tts_cost) as tts_cost, SUM(total_cost) as total_cost, COUNT(*) as sessions FROM sessions WHERE started_at >= ? GROUP BY DATE(started_at) ORDER BY date",
|
||||||
|
(start,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT DATE(started_at) as date, SUM(llm_cost) as llm_cost, SUM(tts_cost) as tts_cost, SUM(total_cost) as total_cost, COUNT(*) as sessions FROM sessions GROUP BY DATE(started_at) ORDER BY date"
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, started_at, llm_cost, tts_cost, total_cost FROM sessions WHERE started_at >= ? ORDER BY started_at",
|
||||||
|
(start,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, started_at, llm_cost, tts_cost, total_cost FROM sessions ORDER BY started_at"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_models(period="all"):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT l.model, COUNT(*) as calls, COALESCE(SUM(l.cost), 0) as cost, COALESCE(SUM(l.prompt_tokens), 0) as prompt_tokens, COALESCE(SUM(l.completion_tokens), 0) as completion_tokens FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? GROUP BY l.model ORDER BY cost DESC",
|
||||||
|
(start,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT model, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens), 0) as prompt_tokens, COALESCE(SUM(completion_tokens), 0) as completion_tokens FROM llm_calls GROUP BY model ORDER BY cost DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_categories(period="all"):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT l.category, COUNT(*) as calls, COALESCE(SUM(l.cost), 0) as cost, COALESCE(SUM(l.prompt_tokens + l.completion_tokens), 0) as tokens FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? GROUP BY l.category ORDER BY cost DESC",
|
||||||
|
(start,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT category, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens FROM llm_calls GROUP BY category ORDER BY cost DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_sessions_list(period="all"):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tokens FROM sessions WHERE started_at >= ? ORDER BY started_at DESC",
|
||||||
|
(start,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, started_at, total_cost, llm_cost, tts_cost, total_llm_calls, total_tokens FROM sessions ORDER BY started_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_detail(session_id):
|
||||||
|
db = get_db()
|
||||||
|
session = db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
||||||
|
if not session:
|
||||||
|
return None
|
||||||
|
by_caller = db.execute(
|
||||||
|
"SELECT caller_name, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(prompt_tokens + completion_tokens), 0) as tokens FROM llm_calls WHERE session_id = ? AND caller_name != '' GROUP BY caller_name ORDER BY cost DESC",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
by_model = db.execute(
|
||||||
|
"SELECT model, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM llm_calls WHERE session_id = ? GROUP BY model ORDER BY cost DESC",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
by_category = db.execute(
|
||||||
|
"SELECT category, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost FROM llm_calls WHERE session_id = ? GROUP BY category ORDER BY cost DESC",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
expensive = db.execute(
|
||||||
|
"SELECT category, model, caller_name, cost, prompt_tokens, completion_tokens, latency_ms, timestamp FROM llm_calls WHERE session_id = ? ORDER BY cost DESC LIMIT 10",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
tts = db.execute(
|
||||||
|
"SELECT provider, COUNT(*) as calls, COALESCE(SUM(cost), 0) as cost, COALESCE(SUM(char_count), 0) as chars FROM tts_calls WHERE session_id = ? GROUP BY provider ORDER BY cost DESC",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"session": dict(session),
|
||||||
|
"by_caller": [dict(r) for r in by_caller],
|
||||||
|
"by_model": [dict(r) for r in by_model],
|
||||||
|
"by_category": [dict(r) for r in by_category],
|
||||||
|
"expensive_calls": [dict(r) for r in expensive],
|
||||||
|
"tts_by_provider": [dict(r) for r in tts],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_expensive_calls(period="all", limit=10):
|
||||||
|
db = get_db()
|
||||||
|
start = _period_filter(period)
|
||||||
|
if start:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT l.category, l.model, l.caller_name, l.cost, l.prompt_tokens, l.completion_tokens, l.latency_ms, l.timestamp, l.session_id FROM llm_calls l JOIN sessions s ON l.session_id = s.id WHERE s.started_at >= ? ORDER BY l.cost DESC LIMIT ?",
|
||||||
|
(start, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT category, model, caller_name, cost, prompt_tokens, completion_tokens, latency_ms, timestamp, session_id FROM llm_calls ORDER BY cost DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify the module loads**
|
||||||
|
|
||||||
|
Run: `cd /Users/lukemacneil/code/ai-podcast && python -c "from backend.services.cost_db import get_db; db = get_db(); print('OK, sessions:', db.execute('SELECT COUNT(*) FROM sessions').fetchone()[0])"`
|
||||||
|
|
||||||
|
Expected: `OK, sessions: 18` (or however many JSON reports exist)
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/services/cost_db.py
|
||||||
|
git commit -m "Add SQLite cost database module with JSON import"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Integrate SQLite Writes into Cost Tracker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/services/cost_tracker.py`
|
||||||
|
|
||||||
|
**Step 1: Add dual-write to `record_llm_call`**
|
||||||
|
|
||||||
|
At the top of `cost_tracker.py`, add the import:
|
||||||
|
```python
|
||||||
|
from backend.services import cost_db
|
||||||
|
```
|
||||||
|
|
||||||
|
In `record_llm_call()` (around line 148, after appending to `self.llm_records`), add:
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
cost_db.ensure_session(self._session_id)
|
||||||
|
cost_db.record_llm_call(
|
||||||
|
self._session_id, record.timestamp, record.category, record.model,
|
||||||
|
record.prompt_tokens, record.completion_tokens, record.cost_usd,
|
||||||
|
record.caller_name, record.latency_ms,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # don't break show over analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add dual-write to `record_tts_call`**
|
||||||
|
|
||||||
|
In `record_tts_call()` (around line 166, after appending to `self.tts_records`), add:
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
cost_db.record_tts_call(
|
||||||
|
self._session_id, record.timestamp, record.provider, record.voice,
|
||||||
|
record.char_count, record.cost_usd,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add session_id tracking to `__init__`**
|
||||||
|
|
||||||
|
Add `self._session_id` to the constructor. Generate it from timestamp:
|
||||||
|
```python
|
||||||
|
self._session_id = f"session-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Update session totals on `save()`**
|
||||||
|
|
||||||
|
In the `save()` method, after writing the JSON file, add:
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
cost_db.update_session_totals(self._session_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/services/cost_tracker.py
|
||||||
|
git commit -m "Dual-write cost records to SQLite"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: API Endpoints
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `backend/main.py` (add routes near existing `/api/costs` endpoints around line 10299)
|
||||||
|
|
||||||
|
**Step 1: Add new cost dashboard endpoints**
|
||||||
|
|
||||||
|
Add these routes near the existing cost endpoints (around line 10308):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.services import cost_db
|
||||||
|
|
||||||
|
@app.get("/api/costs/summary")
|
||||||
|
async def get_cost_summary(period: str = "all"):
|
||||||
|
return cost_db.get_summary(period)
|
||||||
|
|
||||||
|
@app.get("/api/costs/timeline")
|
||||||
|
async def get_cost_timeline(period: str = "all", group_by: str = "session"):
|
||||||
|
return cost_db.get_timeline(period, group_by)
|
||||||
|
|
||||||
|
@app.get("/api/costs/models")
|
||||||
|
async def get_cost_models(period: str = "all"):
|
||||||
|
return cost_db.get_models(period)
|
||||||
|
|
||||||
|
@app.get("/api/costs/categories")
|
||||||
|
async def get_cost_categories(period: str = "all"):
|
||||||
|
return cost_db.get_categories(period)
|
||||||
|
|
||||||
|
@app.get("/api/costs/sessions")
|
||||||
|
async def get_cost_sessions(period: str = "all"):
|
||||||
|
return cost_db.get_sessions_list(period)
|
||||||
|
|
||||||
|
@app.get("/api/costs/session/{session_id}")
|
||||||
|
async def get_cost_session_detail(session_id: str):
|
||||||
|
detail = cost_db.get_session_detail(session_id)
|
||||||
|
if not detail:
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
return JSONResponse(status_code=404, content={"error": "Session not found"})
|
||||||
|
return detail
|
||||||
|
|
||||||
|
@app.get("/api/costs/expensive")
|
||||||
|
async def get_expensive_calls(period: str = "all", limit: int = 10):
|
||||||
|
return cost_db.get_expensive_calls(period, limit)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add route to serve the costs page**
|
||||||
|
|
||||||
|
Near the existing root route (around line 7654), add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/costs")
|
||||||
|
async def costs_page():
|
||||||
|
return FileResponse(frontend_dir / "costs.html")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify endpoints respond**
|
||||||
|
|
||||||
|
Run the server: `python -m uvicorn backend.main:app --reload --reload-dir backend --host 0.0.0.0 --port 8000`
|
||||||
|
|
||||||
|
Test: `curl -s http://localhost:8000/api/costs/summary?period=all | python -m json.tool`
|
||||||
|
|
||||||
|
Expected: JSON with total_cost, llm_cost, tts_cost, sessions, etc.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add backend/main.py
|
||||||
|
git commit -m "Add cost dashboard API endpoints"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Dashboard HTML
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/costs.html`
|
||||||
|
|
||||||
|
**Step 1: Create the dashboard page**
|
||||||
|
|
||||||
|
Create `frontend/costs.html` — standalone HTML page with:
|
||||||
|
- Chart.js from CDN (`https://cdn.jsdelivr.net/npm/chart.js`)
|
||||||
|
- Link to `css/style.css` (shared theme) and `css/costs.css` (dashboard-specific)
|
||||||
|
- Script tag for `js/costs.js`
|
||||||
|
- Structure: header with time range tabs, 4 summary cards, 4 chart containers, 2 tables
|
||||||
|
- Use the same CSS variables as the control panel (`--bg`, `--bg-light`, `--accent`, `--text`, etc.)
|
||||||
|
|
||||||
|
Layout structure:
|
||||||
|
```html
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Cost Dashboard - Luke at the Roost</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/css/costs.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="costs-header">
|
||||||
|
<h1>Cost Dashboard</h1>
|
||||||
|
<a href="/" class="back-link">Back to Show</a>
|
||||||
|
<nav class="period-tabs">
|
||||||
|
<button class="period-tab active" data-period="all">All Time</button>
|
||||||
|
<button class="period-tab" data-period="month">This Month</button>
|
||||||
|
<button class="period-tab" data-period="week">This Week</button>
|
||||||
|
<button class="period-tab" data-period="today">Today</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="costs-main">
|
||||||
|
<section class="summary-cards">
|
||||||
|
<div class="card" id="card-total">
|
||||||
|
<div class="card-label">Total Spend</div>
|
||||||
|
<div class="card-value" id="total-spend">--</div>
|
||||||
|
<div class="card-change" id="total-change"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-llm-tts">
|
||||||
|
<div class="card-label">LLM / TTS</div>
|
||||||
|
<div class="card-value" id="llm-tts-split">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-sessions">
|
||||||
|
<div class="card-label">Sessions</div>
|
||||||
|
<div class="card-value" id="session-count">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-avg">
|
||||||
|
<div class="card-label">Avg / Session</div>
|
||||||
|
<div class="card-value" id="avg-cost">--</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="chart-row">
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost Over Time</h3>
|
||||||
|
<canvas id="timeline-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost by Model</h3>
|
||||||
|
<canvas id="model-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="chart-row">
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost by Category</h3>
|
||||||
|
<canvas id="category-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost Per Session</h3>
|
||||||
|
<canvas id="session-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="tables-section">
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Most Expensive Calls</h3>
|
||||||
|
<table id="expensive-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Model</th><th>Category</th><th>Caller</th><th>Tokens</th><th>Cost</th><th>Latency</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Sessions</h3>
|
||||||
|
<table id="sessions-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>LLM</th><th>TTS</th><th>Total</th><th>Calls</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="session-detail hidden" id="session-detail">
|
||||||
|
<h2>Session Detail: <span id="detail-session-id"></span></h2>
|
||||||
|
<button class="close-detail" id="close-detail">Back</button>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Caller</h3>
|
||||||
|
<table id="detail-caller-table">
|
||||||
|
<thead><tr><th>Caller</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Category</h3>
|
||||||
|
<table id="detail-category-table">
|
||||||
|
<thead><tr><th>Category</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Model</h3>
|
||||||
|
<table id="detail-model-table">
|
||||||
|
<thead><tr><th>Model</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Most Expensive Calls</h3>
|
||||||
|
<table id="detail-expensive-table">
|
||||||
|
<thead><tr><th>Category</th><th>Model</th><th>Caller</th><th>Cost</th><th>Tokens</th><th>Latency</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/js/costs.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/costs.html
|
||||||
|
git commit -m "Add cost dashboard HTML page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Dashboard CSS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/css/costs.css`
|
||||||
|
|
||||||
|
**Step 1: Create dashboard-specific styles**
|
||||||
|
|
||||||
|
Create `frontend/css/costs.css` using the existing CSS variables from `style.css`. Key styles:
|
||||||
|
- `.costs-header` — flex row with title, back link, and period tabs
|
||||||
|
- `.period-tabs` / `.period-tab` — tab buttons, active state uses `--accent`
|
||||||
|
- `.summary-cards` — 4-column grid
|
||||||
|
- `.card` — `background: var(--bg-light)`, border, rounded corners matching `--radius`
|
||||||
|
- `.card-value` — large font, `color: var(--text)`
|
||||||
|
- `.card-change` — small text, green for negative (saving), red for positive (increase)
|
||||||
|
- `.chart-row` — 2-column grid
|
||||||
|
- `.chart-container` — padded card with canvas
|
||||||
|
- `.table-container` — styled tables matching dark theme
|
||||||
|
- `.session-detail` — full-width detail view
|
||||||
|
- Responsive: single column below 768px
|
||||||
|
|
||||||
|
Use `var(--bg)`, `var(--bg-light)`, `var(--accent)`, `var(--text)`, `var(--text-muted)`, `var(--radius)`, `var(--radius-sm)`, `var(--transition)` throughout.
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/css/costs.css
|
||||||
|
git commit -m "Add cost dashboard CSS"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Dashboard JavaScript
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/js/costs.js`
|
||||||
|
|
||||||
|
**Step 1: Create the dashboard JS**
|
||||||
|
|
||||||
|
Create `frontend/js/costs.js` with:
|
||||||
|
|
||||||
|
**State:**
|
||||||
|
```javascript
|
||||||
|
let currentPeriod = 'all';
|
||||||
|
let charts = {}; // store Chart.js instances for destroy/recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Init:**
|
||||||
|
```javascript
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.period-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelector('.period-tab.active').classList.remove('active');
|
||||||
|
tab.classList.add('active');
|
||||||
|
currentPeriod = tab.dataset.period;
|
||||||
|
loadDashboard();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('close-detail').addEventListener('click', closeDetail);
|
||||||
|
loadDashboard();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data loading — `loadDashboard()`:**
|
||||||
|
- Fetch all endpoints in parallel: summary, timeline, models, categories, sessions, expensive
|
||||||
|
- Call render functions for each section
|
||||||
|
|
||||||
|
**Render functions:**
|
||||||
|
- `renderSummary(data)` — populate the 4 summary cards, format as `$X.XX`, show % change with color
|
||||||
|
- `renderTimeline(data)` — Chart.js line chart with LLM and TTS as separate datasets, `--accent` and `--devon` colors
|
||||||
|
- `renderModels(data)` — Chart.js doughnut chart with model name labels
|
||||||
|
- `renderCategories(data)` — Chart.js horizontal bar chart
|
||||||
|
- `renderSessionBars(data)` — Chart.js bar chart, bars colored based on above/below average
|
||||||
|
- `renderExpensiveTable(data)` — populate table rows
|
||||||
|
- `renderSessionsTable(data)` — populate table rows with click handler to show detail
|
||||||
|
- `showSessionDetail(sessionId)` — fetch `/api/costs/session/{id}`, show detail section, populate tables
|
||||||
|
- `closeDetail()` — hide detail section
|
||||||
|
|
||||||
|
**Chart.js config notes:**
|
||||||
|
- Use dark theme: grid lines `rgba(245, 240, 229, 0.1)` (--text at 10%), tick color `var(--text-muted)`
|
||||||
|
- Chart colors palette: `#e8791d` (accent), `#c4944a` (devon), `#5a8a3c` (green), `#cc2222` (red), `#4a8ac4` (blue), `#8a5ac4` (purple), `#c4845a` (tan)
|
||||||
|
- Tooltips: dark background, light text
|
||||||
|
- Destroy existing chart instance before creating new one (prevents memory leaks on period switch)
|
||||||
|
|
||||||
|
**Utility:**
|
||||||
|
- `formatCost(n)` — returns `$X.XX` or `$X.XXXX` for small amounts
|
||||||
|
- `formatDate(iso)` — returns readable date
|
||||||
|
- `shortenModel(name)` — strip provider prefix from model names for chart labels
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/js/costs.js
|
||||||
|
git commit -m "Add cost dashboard JavaScript with Chart.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Integration Test
|
||||||
|
|
||||||
|
**Step 1: Manual verification checklist**
|
||||||
|
|
||||||
|
Start the server and navigate to `http://localhost:8000/costs`:
|
||||||
|
|
||||||
|
1. Page loads with dark theme, no console errors
|
||||||
|
2. All Time tab is active by default, shows all 18 sessions
|
||||||
|
3. Summary cards show total spend, LLM/TTS split, session count, avg cost
|
||||||
|
4. Cost Over Time line chart renders with data points
|
||||||
|
5. Cost by Model doughnut shows model breakdown
|
||||||
|
6. Cost by Category bar chart shows category breakdown
|
||||||
|
7. Cost Per Session bars render with above/below-average coloring
|
||||||
|
8. Most Expensive Calls table populated
|
||||||
|
9. Sessions table populated, rows clickable
|
||||||
|
10. Click a session row → detail view shows with per-caller, per-model, per-category tables
|
||||||
|
11. Click "Back" → returns to main dashboard
|
||||||
|
12. Switch to "This Week" tab → all charts update (may show fewer/no data)
|
||||||
|
13. Switch to "This Month" → charts update with March data
|
||||||
|
14. Switch back to "All Time" → full data restored
|
||||||
|
|
||||||
|
**Step 2: Commit all remaining changes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "Cost dashboard complete — SQLite backend, Chart.js frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Summary
|
||||||
|
|
||||||
|
| Action | File |
|
||||||
|
|--------|------|
|
||||||
|
| Create | `backend/services/cost_db.py` |
|
||||||
|
| Modify | `backend/services/cost_tracker.py` |
|
||||||
|
| Modify | `backend/main.py` |
|
||||||
|
| Create | `frontend/costs.html` |
|
||||||
|
| Create | `frontend/css/costs.css` |
|
||||||
|
| Create | `frontend/js/costs.js` |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Chart.js loaded from CDN (no npm install needed)
|
||||||
|
- SQLite is stdlib (no pip install needed)
|
||||||
|
- No new Python packages required
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
|||||||
|
# Caller Generation Redesign
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Replace the current caller generation pipeline (static pools + weights + scoring + 9-model routing) with a two-stage architecture: **rich identities pre-generated in one sonnet batch call per show**, then **live dialog through a single haiku-4.5 model**. The goal is callers that feel genuinely dynamic across a wide emotional range (Stern / Coast to Coast / Loveline / Delilah / O&A), with Silas preserved as a canonical character and a hard-gated, arc-driven regulars tier replacing the current low-bar promotion system.
|
||||||
|
|
||||||
|
**Target cost:** ~$0.95-1.05 per show (down from $4.32 in the sonnet-only era; roughly flat vs. the current $0.94 multi-model average, but with materially better caller quality).
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Callers currently feel static, repetitive, and homogenized. Audit findings:
|
||||||
|
|
||||||
|
1. **Every caller's reason-for-calling is a static pick** from ~200 hand-written PROBLEMS entries (or sibling pools). The LLM only writes *around* a pre-chosen seed — "dynamic generation" is an illusion.
|
||||||
|
2. **~2000 tokens of uniform prompt chorus** sit on top of every caller's dialog (GO WITH THE HOST, REACT TO LUKE, BANNED PHRASES, shape directives, etc.). This drowns out whatever personality the style block or model was trying to inject.
|
||||||
|
3. **9 dialog models × per-model param tuning × style→model map** with no empirical feedback loop. The "style diversity" layer re-introduces variety AFTER static pools and prompt chorus already homogenized it. Layers cancel each other out.
|
||||||
|
4. **Redundant style + shape systems** — 18 styles map nearly 1:1 with affinity-weighted shapes, with SHAPE_DIRECTIVES reinforcing the same thing in different words.
|
||||||
|
5. **Voice matching scoring is noisy** — 4-dim soft matching rarely produces strong discrimination; most callers score "medium" on every axis.
|
||||||
|
6. **5% random promotion to regular** produces too many recurring callers with no arc progression (e.g., "potato salad guy" calling weekly to complain about the same thing).
|
||||||
|
7. **~400 lines of dead template fallback code** only triggered on sanity-check failure.
|
||||||
|
|
||||||
|
Root cause: the system constrains creativity at both ends (static picks at input, uniform chorus at output), then tries to re-introduce variety with model/style/shape layering that cancels itself out.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two stages, two models.
|
||||||
|
|
||||||
|
### Stage 1 — Pre-generation batch (sonnet-4.6, one call per show)
|
||||||
|
|
||||||
|
Runs before the show starts. One LLM call generates all caller identities for tonight in a single structured output.
|
||||||
|
|
||||||
|
**Inputs to the batch prompt:**
|
||||||
|
- Today's date, day of week, weather, time of year
|
||||||
|
- 3-5 news headlines (grounding, not forced topicality)
|
||||||
|
- Local NM/SW cultural context
|
||||||
|
- Last 2 episodes' caller summaries (anti-repeat directive)
|
||||||
|
- Active regulars' lore files (Silas always if he's in tonight's lineup; optional 1-2 tier-2 regulars)
|
||||||
|
- Creative north-star directive: "Generate 12 callers spanning earnest weirdo → chaotic character → vulnerable confession → advice-seeker → absurd. Maximum character distance between callers. No two callers should feel like siblings. Do not default to sitcom plots."
|
||||||
|
|
||||||
|
**Output per caller (JSON):**
|
||||||
|
- `name`, `age`, `voice_suggestion`, `location`
|
||||||
|
- `identity` — rich paragraph describing who this person actually is
|
||||||
|
- `situation` — what's specifically happening in their life RIGHT NOW
|
||||||
|
- `reason_calling` — why they picked up the phone tonight (a moment, not a category)
|
||||||
|
- `opening_line` — verbatim first line they'll say
|
||||||
|
- `secret_want` — what they actually want from the call (not always what they say)
|
||||||
|
- `specific_details` — 2-3 concrete details to drop in dialog
|
||||||
|
- `emotional_register` — tone this caller will bring
|
||||||
|
|
||||||
|
**No static pools. No weights. No scoring. Sonnet invents everything from the rich context.**
|
||||||
|
|
||||||
|
### Stage 2 — Live dialog (claude-haiku-4.5, one model for all callers)
|
||||||
|
|
||||||
|
When a caller is selected during the show, haiku receives a ~400-token prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
You are [name]. [identity paragraph from pre-gen.]
|
||||||
|
|
||||||
|
You're calling Luke's show because [situation + reason_calling].
|
||||||
|
What you secretly want from this call: [secret_want].
|
||||||
|
Specific details you'll drop if it feels natural: [specific_details].
|
||||||
|
|
||||||
|
Speak as this person. React to what Luke says. Stay in character.
|
||||||
|
Don't narrate. No stage directions. Just talk.
|
||||||
|
```
|
||||||
|
|
||||||
|
**No style block. No shape directive. No banned-phrases list. No per-model param tuning.** Identity from Stage 1 carries the weight; haiku plays the character.
|
||||||
|
|
||||||
|
## Regulars System
|
||||||
|
|
||||||
|
Three tiers.
|
||||||
|
|
||||||
|
### Tier 1 — Silas (canonical)
|
||||||
|
|
||||||
|
Silas's lore file lives at `~/code/dotfiles/silas/silas.md` (user's Obsidian vault). The file contains:
|
||||||
|
- **Frozen identity**: voice, age, core personality, relationship to host, canonical facts
|
||||||
|
- **Arc log**: append-only log of what's happened in past episodes
|
||||||
|
- **Current arc threads**: active storylines (e.g., "cult splintering," "rival prophet")
|
||||||
|
|
||||||
|
When Silas is in tonight's lineup, the batch prompt receives his full lore file verbatim, with the instruction: *"Invent a fresh reason Silas is calling tonight — a new cult development, grievance, or specific recent event. DO NOT alter his voice, personality, or core traits. Write a new scene for an existing character."*
|
||||||
|
|
||||||
|
**Protection from drift:** git-tracked lore file + pre-ship sample-call validation (see below).
|
||||||
|
|
||||||
|
### Tier 2 — Arc regulars (max 2-3 active at a time)
|
||||||
|
|
||||||
|
Lore files at `~/code/dotfiles/regulars/<name>.md`. Each file contains:
|
||||||
|
- **Frozen identity** (same shape as Silas)
|
||||||
|
- **Arc plan** written at promotion time: "3-5 episode arc, here's how it progresses, here's what might resolve it"
|
||||||
|
- **Arc state**: which call in the arc we're on, what's happened so far
|
||||||
|
|
||||||
|
**Return cadence:** every 3-4 episodes, when the arc has something new to advance. Not on a fixed schedule.
|
||||||
|
|
||||||
|
### Tier 3 — Walk-ins
|
||||||
|
|
||||||
|
Everyone else. Fresh each episode. No memory, no file, no callback.
|
||||||
|
|
||||||
|
### Hard promotion gate
|
||||||
|
|
||||||
|
Replaces the current 5% random roll. After a call ends, sonnet evaluates the call and answers: *"Does this character have a 3-5 episode arc in them? If yes, write the arc plan. If no, say why not."* Only promotes if sonnet produces a credible arc plan. Expected promotion rate: ~1 in 20 calls.
|
||||||
|
|
||||||
|
### Arc retirement
|
||||||
|
|
||||||
|
When sonnet judges the arc resolved (or after 5 calls without resolution), the character is archived — no more callbacks. Lore file is kept for reference but moved to `~/code/dotfiles/regulars/archived/`.
|
||||||
|
|
||||||
|
### Migration from current regulars
|
||||||
|
|
||||||
|
All current regulars except Silas are archived at cutover. `data/regulars.json` is kept as historical record. Going forward, only NEW characters can earn tier-2 status through the promotion gate.
|
||||||
|
|
||||||
|
## Model Stack
|
||||||
|
|
||||||
|
| Purpose | Model | Frequency | Cost/show (est.) |
|
||||||
|
|---------|-------|-----------|------------------|
|
||||||
|
| Caller identity pre-gen | claude-sonnet-4.6 | 1 call | ~$0.15 |
|
||||||
|
| Live dialog (all callers) | claude-haiku-4.5 | ~500 turns | ~$0.30-0.40 |
|
||||||
|
| Post-call summary | gemini-2.5-flash | 1/caller | ~$0.01 |
|
||||||
|
| Promotion evaluation | claude-sonnet-4.6 | 1/caller post-call | ~$0.05 |
|
||||||
|
| Devon monitor | gemini-2.5-flash | every 15s | ~$0.08 |
|
||||||
|
| Devon ask | gemini-2.5-flash | on demand | ~$0.01 |
|
||||||
|
| News summary | gemini-2.5-flash | 1/show | <$0.01 |
|
||||||
|
| **LLM total** | | | **~$0.60-0.70** |
|
||||||
|
| TTS (Inworld, unchanged) | | | ~$0.39 |
|
||||||
|
| **Total** | | | **~$1.00-1.10** |
|
||||||
|
|
||||||
|
## Code to Delete (~2000 lines)
|
||||||
|
|
||||||
|
From `backend/main.py`:
|
||||||
|
- `PROBLEMS`, `STORIES`, `GOSSIP`, `ADVICE`, `TOPIC_CALLIN`, `CELEBRATIONS`, `WEIRD`, `HOT_TAKES` content pools
|
||||||
|
- `INTERESTS`, `QUIRKS`, `RELATIONSHIP_STATUS`, `VEHICLES`, `BEFORE_CALLING`, `CALLING_FROM`, `MEMORIES`, `HAVING_RIGHT_NOW`, `STRONG_OPINIONS`, `CONTRADICTIONS`, `VERBAL_TICS`, `EMOTIONAL_ARCS`, `SHOW_RELATIONSHIP`, `LATE_NIGHT_REASONS`, `DRIFT_TENDENCIES`, `ROAD_CONTEXT`, `PHONE_SITUATION`, `BACKGROUND_MUSIC`, `RECENT_ERRAND`, `TV_TONIGHT`, `LOCAL_FOOD_OPINIONS`, `NOSTALGIA` color pools
|
||||||
|
- `CALLER_STYLES` (18 style paragraphs), `CALLER_STYLE_KEYS`
|
||||||
|
- `STYLE_VOICE_PREFERENCES`, `STYLE_SPEED_MODIFIERS`, `STYLE_PHONE_QUALITY`
|
||||||
|
- `CALL_SHAPES`, `SHAPE_STYLE_AFFINITIES`, `SHAPE_DIRECTIVES`, `_LATE_SHOW_SHAPES`
|
||||||
|
- `_SPICY_KEYWORDS`, `_ABSURD_KEYWORDS`, `_HEAVY_POOLS`, `_LIGHT_POOLS`, `_HEAVY_STYLES`, `_LIGHT_STYLES`, `_EVASIVE_STYLES`
|
||||||
|
- `caller_model_pool`, `caller_model_map`, `_CALLER_DIALOG_MODEL_PARAMS`
|
||||||
|
- `_generate_pool_weights`, `_pick_unique_reason`, `_pick_caller_style`, `_pick_call_shape`, `_assign_call_shape`
|
||||||
|
- `_match_voices_to_styles`, voice-scoring logic
|
||||||
|
- `_sort_caller_queue` (greedy placement scoring)
|
||||||
|
- `generate_caller_background` (template fallback, ~400 lines)
|
||||||
|
- `_build_relationship_context` (inter-caller thematic scoring)
|
||||||
|
- `SHOW_HISTORY_REACTIONS`, adaptive reaction frequency logic
|
||||||
|
|
||||||
|
Kept:
|
||||||
|
- `CALLER_BASES` (10 slots with gender/age — still drives voice/name selection)
|
||||||
|
- `MALE_NAMES`, `FEMALE_NAMES` (used as naming hints for sonnet, not picks)
|
||||||
|
- Voice rosters (`INWORLD_MALE`, etc.) — sonnet suggests, we validate against roster
|
||||||
|
- `_pick_response_budget`, `_retry_if_too_short`, `_has_repetition` (dialog-side post-processing)
|
||||||
|
- `_assess_call_quality`, `_summarize_ai_call` (post-call hooks)
|
||||||
|
|
||||||
|
## New Code (~300 lines)
|
||||||
|
|
||||||
|
- **`backend/services/caller_gen.py`** — batch pre-gen orchestration, prompt assembly, JSON parsing, voice-roster validation
|
||||||
|
- **`backend/services/regulars_v2.py`** — lore file loader, arc state management, promotion gate, arc retirement
|
||||||
|
- **Modified `get_caller_prompt`** in `main.py` — slimmed ~400-token prompt assembly
|
||||||
|
- **Modified `Session.get_caller_model`** — returns haiku-4.5 unconditionally
|
||||||
|
- **Migration script** — archive current regulars except Silas, bootstrap Silas lore file
|
||||||
|
|
||||||
|
## Validation Gate (before ship)
|
||||||
|
|
||||||
|
Before deploying to live shows:
|
||||||
|
|
||||||
|
1. Generate 10 sample calls with the new system: 5 featuring Silas, 5 walk-ins
|
||||||
|
2. User listens to all 10
|
||||||
|
3. **Approve essence → ship.** Silas still sounds like Silas; walk-ins span the emotional range; callers feel distinct and specific.
|
||||||
|
4. **Drift or bland → iterate the prompt.** Tune batch prompt directives, re-run, re-validate.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Not redesigning Devon (intern) — stays on gemini-flash as-is
|
||||||
|
- Not redesigning voice rosters or TTS provider — Inworld stays
|
||||||
|
- Not building a quality feedback loop that updates prompts from call outcomes (future work)
|
||||||
|
- Not pre-generating AUDIO for caller opening lines (future work, could cut latency)
|
||||||
|
- Not rebuilding the frontend caller panel — style badges etc. can stay or be simplified later
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Relocate the Show to Alpine, TX / Big Bend
|
||||||
|
|
||||||
|
**Date:** 2026-05-31
|
||||||
|
**Status:** Approved, implementing
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Luke moved to Alpine, Texas. Move the show's world from southern New Mexico
|
||||||
|
(Deming/Lordsburg) to the Big Bend region of far West Texas. Callers should come
|
||||||
|
from and be familiar with Alpine, Marfa, Marathon, Terlingua, Fort Stockton, and
|
||||||
|
the Big Bend area. Devon moves with the show. Silas gets an arc beat that
|
||||||
|
relocates The Wellspring to Terlingua.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. Caller generation — `backend/services/caller_gen.py`
|
||||||
|
|
||||||
|
- `BATCH_SYSTEM_PROMPT` opener: "…radio show in New Mexico" → "…in Alpine, Texas,
|
||||||
|
in the Big Bend country of far West Texas."
|
||||||
|
- Add a **geographic knowledge block** (real, accurate facts) so callers ground
|
||||||
|
themselves in real places instead of generic desert. Towns:
|
||||||
|
- **Alpine** — hub (~6k), Brewster County seat, Sul Ross State University,
|
||||||
|
mile-high ranching/railroad town, small arts scene.
|
||||||
|
- **Marfa** — minimalist-art tourist town (Chinati/Donald Judd, Prada Marfa),
|
||||||
|
the Marfa Lights, old ranching families vs. hipster influx.
|
||||||
|
- **Marathon** — tiny, the Gage Hotel, east gateway to Big Bend NP.
|
||||||
|
- **Terlingua** — quicksilver-mining ghost town, famous chili cookoff,
|
||||||
|
river-rafting outfitters, off-grid desert eccentrics, the Starlight Theatre,
|
||||||
|
near the Rio Grande / Mexico border.
|
||||||
|
- **Fort Stockton** — oilfield/I-10 town to the north, Pecos County, Paisano
|
||||||
|
Pete, working-class.
|
||||||
|
- **Big Bend** — Chisos Mountains, Rio Grande, dark skies (McDonald Observatory
|
||||||
|
near Fort Davis), remote, border proximity, Permian Basin oil money north.
|
||||||
|
- Rule: only reference real places/facts; don't invent businesses or landmarks.
|
||||||
|
|
||||||
|
### 2. Whisper prompt — `backend/services/transcription.py`
|
||||||
|
|
||||||
|
Update locale and seed proper nouns: "…a late-night radio talk show in Alpine,
|
||||||
|
Texas, in the Big Bend region. Callers reference Alpine, Marfa, Marathon,
|
||||||
|
Terlingua, Fort Stockton, and Big Bend."
|
||||||
|
|
||||||
|
### 3. Devon — `backend/services/intern.py`
|
||||||
|
|
||||||
|
Moved with the show. Keep "Communications degree from NMSU" (alma mater);
|
||||||
|
"You live in a studio in Deming" → "studio in Alpine."
|
||||||
|
|
||||||
|
### 4. Silas — relocate The Wellspring to Terlingua
|
||||||
|
|
||||||
|
- `~/code/dotfiles/silas/silas.md`: identity location Deming → Terlingua badlands;
|
||||||
|
update `arc_state` frontmatter to the relocation; add an Arc Log entry
|
||||||
|
(2026-05-31) — post-reckoning fresh start, moving the commune to desert outside
|
||||||
|
Terlingua, the move straining the forty members.
|
||||||
|
- `data/regulars.json`: update Silas's `job`, `location`, and the
|
||||||
|
`stable_seeds.style` "commune outside Deming" → Terlingua. Leave the 6 past
|
||||||
|
`call_history` summaries untouched — they happened in Deming; the move is the
|
||||||
|
new development.
|
||||||
|
|
||||||
|
### 5. Weather/town-news enrichment — `backend/main.py` (now a real feature)
|
||||||
|
|
||||||
|
The block in `enrich_caller_background()` called `_get_town_from_location` /
|
||||||
|
`_get_weather_for_town`, which never existed — silently NameError-ing. Implement:
|
||||||
|
|
||||||
|
- `BIG_BEND_TOWNS`: dict of town → (lat, lon) for Alpine, Marfa, Marathon,
|
||||||
|
Terlingua, Fort Stockton, Big Bend (Chisos Basin), Fort Davis, Presidio.
|
||||||
|
- `_get_town_from_location(text)`: scan lowercased text for a known town,
|
||||||
|
return canonical key (handles "ft stockton"/"fort stockton").
|
||||||
|
- `_get_weather_for_town(town)`: Open-Meteo current weather (free, no key),
|
||||||
|
WMO weather_code → phrase, returns e.g. "58°F, clear skies".
|
||||||
|
- Update town-news query: NM/AZ branch → `f"{town.title()} Texas"`.
|
||||||
|
- Wire session base_ctx `weather` (was hardcoded "cool desert night") to live
|
||||||
|
Alpine weather, graceful fallback on failure.
|
||||||
|
|
||||||
|
All network calls degrade gracefully (existing `asyncio.timeout` + try/except).
|
||||||
|
|
||||||
|
## Out of scope / left as-is
|
||||||
|
|
||||||
|
- Past `call_history` summaries (genuinely happened in Deming).
|
||||||
|
- `main.py:3124` state-abbreviation map (generic, not show-locale).
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Caller Variety, Movable Callers, and Devon Search Fix
|
||||||
|
|
||||||
|
**Date:** 2026-06-02
|
||||||
|
**Status:** Approved, implementing
|
||||||
|
|
||||||
|
## Problems
|
||||||
|
|
||||||
|
1. **Callers feel formulaic** — too many deep moral dilemmas; the roster prompt
|
||||||
|
*enforces* it ("at least half moral dilemmas" + "NEVER generate callers who
|
||||||
|
are just enthusiastic about a hobby").
|
||||||
|
2. **Callers circle forever** — they don't take advice, restating the same moral
|
||||||
|
issue until the host cuts them off. The live dialog prompt gives a `situation`
|
||||||
|
+ `secret_want` but no mechanic to be moved, persuaded, or reach resolution.
|
||||||
|
3. **Devon's search fails** — SearXNG at `localhost:8888` is unreachable whenever
|
||||||
|
the laptop's Docker Desktop isn't running. Fragile.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### A. Roster variety — `backend/services/caller_gen.py` (`BATCH_SYSTEM_PROMPT`)
|
||||||
|
|
||||||
|
- Remove the absolute ban on hobby/job/story callers.
|
||||||
|
- Replace "at least half moral dilemmas" with an explicit distribution for a
|
||||||
|
10-caller roster (dilemmas still lead):
|
||||||
|
- **4–5** moral dilemmas / confessions / betrayals — keep the STAKES language.
|
||||||
|
- **2–3** storytellers & enthusiasts — a wild thing that happened, a fascinating
|
||||||
|
niche obsession or fact, a vivid slice-of-life. No deep dilemma required.
|
||||||
|
- **1–2** believers / chaos — earnest UFO/cryptid/conspiracy callers
|
||||||
|
(Coast-to-Coast sincerity) or a big eccentric personality on a rant.
|
||||||
|
- Keep the anti-collision rule, "reason they HAD to call tonight," and
|
||||||
|
"even lighter callers need energy and specificity."
|
||||||
|
|
||||||
|
### B. Movable callers — `backend/main.py` (`get_caller_prompt`)
|
||||||
|
|
||||||
|
Add a concise conversational-arc block for every caller:
|
||||||
|
- You have a position/want, but you're a real person — if Luke makes a good
|
||||||
|
point, genuinely react: agree, change your mind, soften, dig in, or decide.
|
||||||
|
- Don't restate a point/dilemma you've already made; the conversation must move.
|
||||||
|
You can reach a resolution, a decision, or an emotional turn. You are not
|
||||||
|
required to stay stuck.
|
||||||
|
|
||||||
|
Stacks on the existing "don't restate facts / move the story forward" rule.
|
||||||
|
Must keep `get_caller_prompt` output under the 3500-char test cap.
|
||||||
|
|
||||||
|
### C. SearXNG → NAS — `backend/config.py`, `backend/services/news.py`
|
||||||
|
|
||||||
|
- Deploy SearXNG to **mmgnas** as an always-on container (deploy-nas-docker).
|
||||||
|
- Add `searxng_url` to `config.py` (env-overridable), default to the NAS URL.
|
||||||
|
- `news.py` reads `settings.searxng_url` instead of the hardcoded constant;
|
||||||
|
`intern.py` imports `SEARXNG_URL` from `news.py`, so Devon, headlines, and
|
||||||
|
caller news-grounding all follow.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Run test suite (note: 2 pre-existing stale `test_caller_gen.py` failures).
|
||||||
|
- Live caller-gen batch → review the roster mix.
|
||||||
|
- Devon end-to-end: a `web_search` against the NAS SearXNG returns real results.
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
# Crawlable Episode Pages Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Turn 57 client-rendered `?slug=` episode pages into 57 static, crawlable HTML pages at `/episode/<slug>/` containing the full transcript, so the Big Bend content the show already produces becomes indexable.
|
||||||
|
|
||||||
|
**Architecture:** A build-time Python generator reads the Castopod RSS feed and the existing `website/transcripts/*.txt` files, and writes one fully static page per episode into `website/episode/<slug>/index.html`. No runtime feed dependency, no JS required to see content. The Cloudflare worker gains a 301 from the legacy `?slug=` URL, and loses its user-agent gate. `publish_episode.py` calls the generator so new episodes get a page automatically.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.11 (stdlib `xml.etree`, `html`, `json`, `pathlib`), pytest, Cloudflare Pages + `_worker.js`, wrangler.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why This Matters (context for the implementer)
|
||||||
|
|
||||||
|
Current state, verified 2026-08-14:
|
||||||
|
|
||||||
|
- `website/episode.html` is a JS shell. It has **zero** server-rendered episode content — it fetches `/feed`, finds the matching `<item>` client-side, then fetches `/transcripts/<slug>.txt`.
|
||||||
|
- URLs are query params: `/episode.html?slug=episode-58-...`. 54 of the 64 sitemap entries are this shape.
|
||||||
|
- `website/transcripts/` holds **58 `.txt` files, 3.0 MB total**, none of which appear in `sitemap.xml` and which are linked only from JavaScript.
|
||||||
|
- `_worker.js:107-165` injects real `<title>`/OG tags — but **only when the User-Agent matches `facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot`**. Googlebot, Bingbot, GPTBot, ClaudeBot and PerplexityBot are all absent, so search and answer engines get the generic shell.
|
||||||
|
|
||||||
|
That UA gate is also a **cloaking risk**: serving different HTML to crawlers than to users is against Google's guidelines. Google deprecated "dynamic rendering" as a workaround. Task 6 removes the gate rather than extending it — once pages are static, nothing needs UA sniffing.
|
||||||
|
|
||||||
|
### Data facts confirmed before writing this plan
|
||||||
|
|
||||||
|
- RSS feed: `https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml`, HTTP 200, 124 KB, **57 `<item>` elements**.
|
||||||
|
- Each item has: `<title>`, `<link>`, `<pubDate>`, `<guid>`, `<description>` (CDATA-wrapped HTML), `<enclosure url=...>`, `<itunes:duration>` (seconds, e.g. `4770`), `<itunes:episode>` (e.g. `58`).
|
||||||
|
- Slug is derived from `<link>`: everything after `/episodes/`, trailing slash stripped.
|
||||||
|
- **All 57 feed episodes have a matching transcript file.** Zero missing.
|
||||||
|
- One orphan transcript, `episode-32-tacos-taxes-and-tall-tales.txt`, has no feed item — ep 32 never finished publishing (`data/publish_state.json` shows a castopod step and nothing else). **The generator must skip orphans, not crash on them.**
|
||||||
|
- Transcript format is plain text, blank-line separated, `SPEAKER: text` per paragraph — e.g. `LUKE: Alright, welcome back...` / `SLIM: Hey Luke, yeah thanks...`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Transcript parser
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `website_gen/transcript.py`
|
||||||
|
- Test: `tests/test_transcript_parser.py`
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from website_gen.transcript import parse_transcript
|
||||||
|
|
||||||
|
|
||||||
|
def test_splits_speaker_turns():
|
||||||
|
raw = "LUKE: Welcome back.\n\nSLIM: Hey Luke, thanks for taking my call."
|
||||||
|
turns = parse_transcript(raw)
|
||||||
|
assert turns == [("LUKE", "Welcome back."),
|
||||||
|
("SLIM", "Hey Luke, thanks for taking my call.")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlabeled_paragraph_carries_previous_speaker():
|
||||||
|
raw = "LUKE: First thing.\n\nStill Luke talking."
|
||||||
|
assert parse_transcript(raw) == [("LUKE", "First thing."),
|
||||||
|
("LUKE", "Still Luke talking.")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignores_blank_and_whitespace_paragraphs():
|
||||||
|
raw = "LUKE: One.\n\n \n\nSLIM: Two."
|
||||||
|
assert len(parse_transcript(raw)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_speaker_name_with_spaces_is_not_treated_as_label():
|
||||||
|
"""A colon mid-sentence must not be mistaken for a speaker label."""
|
||||||
|
raw = "LUKE: Here's the thing: it was the alternator."
|
||||||
|
turns = parse_transcript(raw)
|
||||||
|
assert len(turns) == 1
|
||||||
|
assert turns[0][0] == "LUKE"
|
||||||
|
assert "the thing: it was" in turns[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_input_returns_empty_list():
|
||||||
|
assert parse_transcript("") == []
|
||||||
|
assert parse_transcript(" \n\n ") == []
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v`
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: No module named 'website_gen'`
|
||||||
|
|
||||||
|
**Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import re
|
||||||
|
|
||||||
|
SPEAKER_RE = re.compile(r"^([A-Z][A-Z0-9 .'-]{0,30}):\s*(.*)$", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_transcript(raw: str) -> list[tuple[str, str]]:
|
||||||
|
"""Split a transcript into (speaker, text) turns.
|
||||||
|
|
||||||
|
Paragraphs are blank-line separated. A paragraph that does not open with a
|
||||||
|
SPEAKER: label is attributed to whoever spoke last, which is how the
|
||||||
|
transcriber emits long turns that wrap.
|
||||||
|
"""
|
||||||
|
turns: list[tuple[str, str]] = []
|
||||||
|
current = None
|
||||||
|
for para in re.split(r"\n\s*\n", raw or ""):
|
||||||
|
para = para.strip()
|
||||||
|
if not para:
|
||||||
|
continue
|
||||||
|
m = SPEAKER_RE.match(para)
|
||||||
|
if m:
|
||||||
|
current = m.group(1).strip()
|
||||||
|
text = m.group(2).strip()
|
||||||
|
else:
|
||||||
|
text = para
|
||||||
|
if current is None:
|
||||||
|
current = "LUKE"
|
||||||
|
if text:
|
||||||
|
turns.append((current, text))
|
||||||
|
return turns
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v`
|
||||||
|
Expected: PASS, 5 tests
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add website_gen/transcript.py tests/test_transcript_parser.py
|
||||||
|
git commit -m "Add transcript parser for episode page generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: RSS feed loader
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `website_gen/feed.py`
|
||||||
|
- Test: `tests/test_feed_loader.py`
|
||||||
|
- Fixture: `tests/fixtures/feed_sample.xml` (hand-trim two `<item>` blocks out of the live feed)
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from website_gen.feed import parse_feed, Episode
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_core_fields(feed_xml):
|
||||||
|
eps = parse_feed(feed_xml)
|
||||||
|
ep = next(e for e in eps if e.number == 58)
|
||||||
|
assert ep.slug == "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
|
||||||
|
assert ep.title.startswith("Episode 58: Rayfield's Nephew")
|
||||||
|
assert ep.duration_seconds == 4770
|
||||||
|
assert ep.audio_url.startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
|
def test_description_is_stripped_of_cdata_and_html(feed_xml):
|
||||||
|
ep = parse_feed(feed_xml)[0]
|
||||||
|
assert "<![CDATA[" not in ep.description
|
||||||
|
assert "<p>" not in ep.description
|
||||||
|
|
||||||
|
|
||||||
|
def test_slug_comes_from_link_and_drops_trailing_slash(feed_xml):
|
||||||
|
for ep in parse_feed(feed_xml):
|
||||||
|
assert not ep.slug.endswith("/")
|
||||||
|
assert "/" not in ep.slug
|
||||||
|
|
||||||
|
|
||||||
|
def test_pubdate_parses_to_iso_date(feed_xml):
|
||||||
|
ep = next(e for e in parse_feed(feed_xml) if e.number == 58)
|
||||||
|
assert ep.published_iso.startswith("2026-08-04")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `./venv/bin/python -m pytest tests/test_feed_loader.py -v`
|
||||||
|
Expected: FAIL — module missing
|
||||||
|
|
||||||
|
**Step 3: Implement**
|
||||||
|
|
||||||
|
Use `xml.etree.ElementTree` with the itunes namespace `http://www.itunes.com/dtds/podcast-1.0.dtd`. Dataclass:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class Episode:
|
||||||
|
number: int | None
|
||||||
|
slug: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
published_iso: str
|
||||||
|
duration_seconds: int | None
|
||||||
|
audio_url: str
|
||||||
|
```
|
||||||
|
|
||||||
|
Parse `pubDate` with `email.utils.parsedate_to_datetime`. Strip CDATA and tags from `<description>` with a regex, then `html.unescape`.
|
||||||
|
|
||||||
|
**Step 4: Verify passes.** **Step 5: Commit.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "Add RSS feed loader for episode page generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Page renderer
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `website_gen/render.py`, `website_gen/templates/episode.html`
|
||||||
|
- Test: `tests/test_episode_render.py`
|
||||||
|
|
||||||
|
The template is a full standalone page matching the existing site chrome (copy the `<nav>`, footer markup and `css/style.css?v=6` link from `website/how-it-works.html` so it looks native).
|
||||||
|
|
||||||
|
**Step 1: Write the failing test**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json, re
|
||||||
|
from website_gen.render import render_episode_page
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_and_canonical_are_episode_specific(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "Hello.")])
|
||||||
|
assert "<title>Episode 58: Rayfield" in html
|
||||||
|
assert '<link rel="canonical" href="https://lukeattheroost.com/episode/episode-58-' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_is_in_the_html_not_fetched_by_js(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "The Marfa Lights are real.")])
|
||||||
|
assert "The Marfa Lights are real." in html
|
||||||
|
assert "fetch(" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_emits_valid_podcastepisode_schema(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi.")])
|
||||||
|
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
|
||||||
|
data = json.loads(block)
|
||||||
|
types = {o["@type"] for o in (data if isinstance(data, list) else [data])}
|
||||||
|
assert "PodcastEpisode" in types
|
||||||
|
|
||||||
|
|
||||||
|
def test_escapes_html_in_transcript_text(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "5 < 6 & <script>alert(1)</script>")])
|
||||||
|
assert "<script>alert(1)</script>" not in html
|
||||||
|
assert "<script>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_escapes_quotes_in_title_meta(sample_episode):
|
||||||
|
sample_episode.title = 'Episode 1: The "Best" Show'
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
assert 'content="Episode 1: The "Best"' not in html # must be escaped
|
||||||
|
|
||||||
|
|
||||||
|
def test_speaker_labels_get_semantic_markup(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi."), ("SLIM", "Hey.")])
|
||||||
|
assert html.count('class="transcript-turn"') == 2
|
||||||
|
assert "LUKE" in html and "SLIM" in html
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Implementation notes**
|
||||||
|
|
||||||
|
- Escape every interpolated value with `html.escape(value, quote=True)`. The transcript is user-facing text from Whisper; treat it as untrusted.
|
||||||
|
- Schema block is a JSON array containing `PodcastEpisode` and `BreadcrumbList`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "PodcastEpisode",
|
||||||
|
"url": f"https://lukeattheroost.com/episode/{ep.slug}/",
|
||||||
|
"name": ep.title,
|
||||||
|
"description": ep.description,
|
||||||
|
"datePublished": ep.published_iso,
|
||||||
|
"timeRequired": f"PT{ep.duration_seconds}S",
|
||||||
|
"associatedMedia": {"@type": "MediaObject", "contentUrl": ep.audio_url},
|
||||||
|
"partOfSeries": {
|
||||||
|
"@type": "PodcastSeries",
|
||||||
|
"name": "Luke at the Roost",
|
||||||
|
"url": "https://lukeattheroost.com",
|
||||||
|
},
|
||||||
|
"contentLocation": {
|
||||||
|
"@type": "Place",
|
||||||
|
"name": "Big Bend, West Texas",
|
||||||
|
"address": {"@type": "PostalAddress", "addressLocality": "Alpine",
|
||||||
|
"addressRegion": "TX", "addressCountry": "US"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `contentLocation` on every episode is the point of the whole exercise — it is what ties 57 pages of West Texas conversation to the region geographically.
|
||||||
|
|
||||||
|
- Include a native `<audio controls preload="none" src="{audio_url}">` so the page is useful without JS.
|
||||||
|
- Add prev/next episode links. Internal linking is what gets 57 pages crawled instead of 3.
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "Add episode page renderer with PodcastEpisode schema"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Generator CLI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `generate_episode_pages.py` (repo root, matching the existing script convention)
|
||||||
|
- Test: `tests/test_episode_generator.py`
|
||||||
|
|
||||||
|
**Behaviour:**
|
||||||
|
|
||||||
|
```
|
||||||
|
python generate_episode_pages.py # fetch live feed, write all pages
|
||||||
|
python generate_episode_pages.py --feed FILE # use a local feed (tests, offline)
|
||||||
|
python generate_episode_pages.py --dry-run # report what would be written
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 1: Failing tests**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_writes_one_index_html_per_feed_episode(tmp_path, feed_file, transcripts_dir):
|
||||||
|
n = generate(feed_file, transcripts_dir, tmp_path)
|
||||||
|
assert n == 2
|
||||||
|
assert (tmp_path / "episode" / "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" / "index.html").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_orphan_transcript_without_feed_item_is_skipped(tmp_path, feed_file, transcripts_dir):
|
||||||
|
"""episode-32 has a transcript but was never published to the feed."""
|
||||||
|
generate(feed_file, transcripts_dir, tmp_path)
|
||||||
|
assert not (tmp_path / "episode" / "episode-32-tacos-taxes-and-tall-tales").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_transcript_still_produces_a_page(tmp_path, feed_file, transcripts_dir):
|
||||||
|
"""An episode published before its transcript lands must not break the build."""
|
||||||
|
(transcripts_dir / "episode-58-....txt").unlink()
|
||||||
|
generate(feed_file, transcripts_dir, tmp_path)
|
||||||
|
html = (tmp_path / "episode" / "episode-58-..." / "index.html").read_text()
|
||||||
|
assert "Transcript not yet available" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_writes_nothing(tmp_path, feed_file, transcripts_dir):
|
||||||
|
generate(feed_file, transcripts_dir, tmp_path, dry_run=True)
|
||||||
|
assert not (tmp_path / "episode").exists()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "Add episode page generator CLI"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Sitemap regeneration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `generate_episode_pages.py` (add `--sitemap`)
|
||||||
|
- Modify: `website/sitemap.xml` (regenerated output)
|
||||||
|
- Test: `tests/test_sitemap.py`
|
||||||
|
|
||||||
|
The current sitemap has 64 `<url>` entries, 54 of them `?slug=` URLs. Those must be **replaced**, not supplemented — leaving both forms invites duplicate-content dilution even with a canonical.
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_sitemap_contains_no_query_param_urls(generated_sitemap):
|
||||||
|
assert "episode.html?slug=" not in generated_sitemap
|
||||||
|
|
||||||
|
|
||||||
|
def test_sitemap_has_one_entry_per_episode(generated_sitemap):
|
||||||
|
assert generated_sitemap.count("<loc>https://lukeattheroost.com/episode/") == 57
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_pages_survive_regeneration(generated_sitemap):
|
||||||
|
for path in ["", "/how-it-works", "/clips", "/stats", "/privacy", "/terms", "/llms.txt"]:
|
||||||
|
assert f"<loc>https://lukeattheroost.com{path}</loc>" in generated_sitemap
|
||||||
|
|
||||||
|
|
||||||
|
def test_lastmod_matches_episode_publish_date(generated_sitemap):
|
||||||
|
assert "<lastmod>2026-08-04</lastmod>" in generated_sitemap
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit:** `git commit -m "Generate sitemap from feed with clean episode URLs"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Worker — 301 the old URL, remove the UA gate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `website/_worker.js:107-165`
|
||||||
|
|
||||||
|
Replace the entire social-crawler injection block with a redirect. Once pages are static, the injection is dead code and the UA gate is a liability.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Legacy query-param episode URLs -> clean paths.
|
||||||
|
// Published social posts and YouTube descriptions still point at the old
|
||||||
|
// form, so this 301 has to stay indefinitely.
|
||||||
|
if (url.pathname === "/episode.html" && url.searchParams.get("slug")) {
|
||||||
|
const slug = url.searchParams.get("slug").replace(/[^a-z0-9-]/gi, "");
|
||||||
|
if (slug) {
|
||||||
|
return Response.redirect(`https://lukeattheroost.com/episode/${slug}/`, 301);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why the slug is sanitised:** it lands in a `Location:` header. Stripping to `[a-z0-9-]` prevents CRLF injection and open-redirect via a crafted `?slug=`.
|
||||||
|
|
||||||
|
**Verify manually after deploy:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" | head -3
|
||||||
|
# Expect: HTTP/2 301 + location: https://lukeattheroost.com/episode/episode-58-.../
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit:** `git commit -m "Redirect legacy episode URLs and drop crawler UA gate"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Retire the client-rendered page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Delete: `website/episode.html`, `website/js/episode.js`
|
||||||
|
- Modify: `website/js/app.js` — episode links must point at `/episode/<slug>/`
|
||||||
|
- Modify: `website/_redirects` — keep `/episodes.html /episode 302`
|
||||||
|
|
||||||
|
Check every internal link first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "episode.html?slug=" website/ --include=*.html --include=*.js
|
||||||
|
```
|
||||||
|
|
||||||
|
All must become `/episode/<slug>/`. Also fix `publish_episode.py:1168`, which builds the `episode_url` used in social posts and YouTube descriptions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
episode_url = f"https://lukeattheroost.com/episode/{episode_slug}/"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit:** `git commit -m "Point internal links at clean episode URLs"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Wire into the publish pipeline
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `publish_episode.py` — after the transcript is written to `website/transcripts/`
|
||||||
|
|
||||||
|
```python
|
||||||
|
subprocess.run([sys.executable, "generate_episode_pages.py", "--sitemap"], check=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
`check=False` on purpose: a generator failure must not abort a publish that has already pushed audio to Castopod. Log loudly instead.
|
||||||
|
|
||||||
|
**Test:** `tests/test_publish_generates_page.py` — assert the generator is invoked with `--sitemap` after a successful publish (monkeypatch `subprocess.run`).
|
||||||
|
|
||||||
|
**Commit:** `git commit -m "Regenerate episode pages during publish"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Deploy and verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./venv/bin/python -m pytest tests/ -q # full suite green
|
||||||
|
python generate_episode_pages.py --sitemap # 57 pages + sitemap
|
||||||
|
npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main
|
||||||
|
```
|
||||||
|
|
||||||
|
**Post-deploy checks — all must pass:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Page is static: transcript present with JS disabled
|
||||||
|
curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -c "Mitchell Flat" # >= 1
|
||||||
|
|
||||||
|
# 2. Real title in raw HTML, no UA spoofing
|
||||||
|
curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -o "<title>.*</title>"
|
||||||
|
|
||||||
|
# 3. Same bytes for Googlebot as for a browser (no cloaking)
|
||||||
|
diff <(curl -s -A "Mozilla/5.0" URL) <(curl -s -A "Googlebot/2.1" URL) && echo "no cloaking"
|
||||||
|
|
||||||
|
# 4. Legacy URL 301s
|
||||||
|
curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-..." | grep -i "^location"
|
||||||
|
|
||||||
|
# 5. Schema validates
|
||||||
|
# paste page source into https://validator.schema.org/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then: submit the new `sitemap.xml` in Google Search Console and request indexing on three episode pages as a sample.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| URL change loses existing ranking | 301 (permanent) from Task 6 passes equity; the old form stays supported forever |
|
||||||
|
| Published YouTube/social links break | Same 301. Ep 58's YouTube description contains a `?slug=` link — verify it after deploy |
|
||||||
|
| Transcripts contain explicit content | Show is already rated explicit; consider `<meta name="rating" content="adult">` on episode pages |
|
||||||
|
| Whisper errors become permanent indexed text | Transcripts are already public at `/transcripts/*.txt`; this changes exposure, not accuracy |
|
||||||
|
| 3 MB of new HTML on every deploy | Static assets, Cloudflare-cached; wrangler uploads only changed files |
|
||||||
|
| Feed unreachable at build time | `--feed` flag lets the build use a cached copy; generator exits non-zero rather than writing empty pages |
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Tier 3 — the "Big Bend episodes" topic hub, and off-site work (Alpine Avalanche, Marfa/Big Bend groups, Sul Ross). Worth doing after these pages exist to link to.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
=== Bev (44, Farmington, NM) ===
|
||||||
|
voice: Wendy
|
||||||
|
emotional_register: Forthright and slightly combative in a charming way — a person who has been talked out of her own perceptions before and has decided not to let that happen again
|
||||||
|
secret_want: To have someone acknowledge that the embarrassment is Ashleigh's problem being projected, not Bev's problem being revealed — she wants the shame returned to its rightful owner
|
||||||
|
|
||||||
|
CALLER (Bev, 44, Farmington, NM): I need to preface this by saying I am fully aware that competitive jigsaw puzzling sounds like a bit, but I placed fourteenth in the nationals in 2024 so I need you to take me seriously here.
|
||||||
|
LUKE: Wait — really?
|
||||||
|
CALLER: Yeah, really. I know how it sounds, I do. But there's a whole circuit, there are rankings, there's prize money. I mean, not like sports money, but real money. And the skill involved — people don't understand that it's not just sitting around doing puzzles like you're retired. It's strategy, it's technique, it's knowing your own cognitive patterns.
|
||||||
|
|
||||||
|
I've been competing for six years now. I've won three regionals. I trained ninety minutes a day to get to nationals. Ninety minutes. Every single day.
|
||||||
|
|
||||||
|
So I need you to understand that this is a thing I do seriously before I explain why my son's girlfriend watching me do it is making me want to lose my mind.
|
||||||
|
LUKE: Is that right?
|
||||||
|
CALLER: Yeah. Look, I work nights at the ER — three nights a week, eleven to seven — and I do my training in the two hours before my shift. So I'm often at the kitchen table in my full scrubs, completely locked in, doing these gradient puzzles. Those are the ones that are almost all one color, right? Most people avoid them because they seem impossible, but I've built a specific technique around sorting by cardboard texture instead of color. That's my edge.
|
||||||
|
|
||||||
|
And it was fine. It was just my thing. I'd get home from work at seven in the morning, have my coffee, and then Dale would be around sometimes but he'd just — he'd leave me alone. It was quiet. It was mine.
|
||||||
|
|
||||||
|
Then Ashleigh started coming over more regularly, and now she just sits at the kitchen table while I'm working and watches me with this expression on her face like I'm doing something sad that she's too polite to name. And suddenly I hate it. I hate being watched doing the thing I love.
|
||||||
|
LUKE: Go on.
|
||||||
|
CALLER: Well, that's kind of the thing, right? I don't know if I'm allowed to ask her to stop. Not to leave the room — I'm not trying to be that person. But just to not watch me. To look at her phone or read something or just sit with her back to me or something.
|
||||||
|
|
||||||
|
Is that crazy? Is that a crazy thing to want?
|
||||||
|
|
||||||
|
Because here's what's happening. She's perfectly nice. I know she's perfectly nice. Dale's a good kid, he chose well. But she's introduced this feeling into something that was only ever joy before, and I resent it. I resent that I now feel self-conscious doing the thing I trained for, the thing I'm genuinely good at.
|
||||||
|
|
||||||
|
And I'm wondering if maybe I should just ask her, or if that makes me the unreasonable one. Like, is asking someone to stop watching you something that signals I'm the problem here?
|
||||||
|
LUKE: And then what?
|
||||||
|
CALLER: And then what, what do you mean?
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
=== Brecken (24, Albuquerque, NM — calling from a Walgreens parking lot) ===
|
||||||
|
voice: Carter
|
||||||
|
emotional_register: Caffeinated, earnest, slightly defensive, fundamentally lonely in a way he would never name
|
||||||
|
secret_want: To be taken seriously by someone — anyone — in a format that feels official. Radio counts.
|
||||||
|
|
||||||
|
CALLER (Brecken, 24, Albuquerque, NM — calling from a Walgreens parking lot): I'm not drunk, I just have a lot of energy right now and nobody to talk to.
|
||||||
|
LUKE: I mean, what do you want me to tell you?
|
||||||
|
CALLER: Look, I don't know, man. I just... I've been sitting in my car for like twenty minutes after my shift and I heard your show was taking calls and I thought, you know what? Tonight's the night. I'm gonna call.
|
||||||
|
|
||||||
|
I got something I need to say and I feel like nobody's gonna listen to me at work or at home or anywhere else, but maybe on the radio it's different. Maybe on the radio people actually hear you.
|
||||||
|
LUKE: What'd she say?
|
||||||
|
CALLER: What? No, there's no she. It's not like that. I just meant nobody listens, period. My coworkers, they call me Bricks and I don't even know if that's a compliment or if they're busting my chops, you know? And I've got this thing I'm training for and nobody takes it seriously.
|
||||||
|
LUKE: Yeah?
|
||||||
|
CALLER: Yeah. Competitive eating. I know what you're probably thinking — it's not a real sport, it's just guys shoving food in their faces. But that's where people get it wrong, Luke. That's exactly where they get it wrong.
|
||||||
|
|
||||||
|
You ever been actually hungry? Like, not hungry-hungry, but so hungry you're thinking about your next meal before you're even done with the current one? Because if you have, you'd understand that what these competitors do, it takes real discipline. Real strategy.
|
||||||
|
|
||||||
|
I work doubles at the hotel restaurant, right? I see people eat all day. I know food. And I've been tracking everything on my phone for fourteen months — I got a whole spreadsheet called Project Jaw, every single week updated. This isn't just me stuffing my face. This is science.
|
||||||
|
LUKE: And then what?
|
||||||
|
CALLER: And then what, what? I'm trying to qualify for a regional hot dog event. That's the goal. I haven't competed yet, but I'm building up to it. I can eat a seventy-two-ounce steak — I know I can because I've done the math, I've done the training — but I can't afford to actually go to that restaurant and do it officially, you know? So I'm working toward the hot dog thing instead.
|
||||||
|
|
||||||
|
But the point is nobody respects it. People hear competitive eating and they laugh. But if I told you I was training for a marathon, you'd be like, oh cool, that's dedication. This is the same thing. It's endurance. It's mental toughness.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
=== Denny (61, Tucumcari, NM) ===
|
||||||
|
voice: Dennis
|
||||||
|
emotional_register: Gruff containment with genuine grief leaking through the edges
|
||||||
|
secret_want: To tell someone how much he actually loved that bird, which he has not admitted to anyone, including his adult son who thinks the pigeon hobby is embarrassing.
|
||||||
|
|
||||||
|
CALLER (Denny, 61, Tucumcari, NM): Yeah hi, Luke, long-time listener, I'm not crazy, I just need people to look up.
|
||||||
|
LUKE: And then what?
|
||||||
|
CALLER: I'm sorry, what do you mean "and then what"? I haven't even told you about the bird yet.
|
||||||
|
LUKE: Wait — really?
|
||||||
|
CALLER: Yeah, really. I got so wound up I just started talking. Look, I've got a blue check hen, Patrice, she got out three days ago during those wind gusts we had. Band number NM-2024-441. I've been out every night with corn, driving the back roads out here, and I just... I need someone to know what to look for.
|
||||||
|
LUKE: That's — okay, keep going.
|
||||||
|
CALLER: She's a good flyer, real good, but not good enough to just disappear like this. Three days, Luke. I've covered maybe two hundred miles of road between here and the Oklahoma border. She wouldn't go that far on purpose, but the wind that night was brutal.
|
||||||
|
|
||||||
|
I'm asking your listeners, anybody in eastern New Mexico, if you see a banded pigeon, blue check pattern, that band number NM-2024-441, you call me. You write it down. Don't try to catch her, just... note where you saw her. I'll come get her.
|
||||||
|
|
||||||
|
She's worth something, yeah, but that's not why I'm calling at one in the morning.
|
||||||
|
LUKE: Yeah?
|
||||||
|
CALLER: Yeah. Look, I've been doing this a long time. Thirty-seven years with pigeons. I had a logbook going since 1987. I know every bird I've ever lost, and I write it down. Cause of death, disappearance, all of it. But Patrice...
|
||||||
|
|
||||||
|
She's different, okay? I named her after someone. Someone from back when I was running long haul for Schneider. My dispatcher. And I never told anybody that. My son, he thinks this whole thing is embarrassing. The pigeons. But Patrice, she won places. Real places. She had heart.
|
||||||
|
|
||||||
|
I'm not asking for charity here. I'm just asking if anybody's seen her. That's all I need.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
=== Gerald (71, Truth or Consequences, NM) ===
|
||||||
|
voice: Ronald
|
||||||
|
emotional_register: Measured and precise in speech — a man who chose his words carefully as a professional and now finds that precision failing him in a domain where it doesn't apply
|
||||||
|
secret_want: Permission to call Patrice tonight, this hour, while the show is on — he wants someone to tell him the time doesn't matter, that she would want to hear his voice even at midnight
|
||||||
|
|
||||||
|
CALLER (Gerald, 71, Truth or Consequences, NM): I'm going to tell you something and I need you to understand that I am not a timid man. I have stood in a river in a hard hat while a concrete pour went wrong and I kept my head. So I don't know what this is.
|
||||||
|
LUKE: Yeah?
|
||||||
|
CALLER: I've been listening to your show for three years now. Every Sunday night. And I don't think I've ever called before, so I'm going to just say it straight.
|
||||||
|
|
||||||
|
I know a woman. I've known her — well, we've been corresponding for eight months now. Email. We met through this forum we're both on, we track the Mars rover, we talk about what it's seeing up there, and somewhere along the way we started talking about other things. Real things. And I know things about her. I know she taught chemistry for thirty years. I know she has a bad knee. I know she saw a mountain lion once and it changed something in her.
|
||||||
|
|
||||||
|
And she has my number. She sent it to me three months ago, just casual, just at the end of an email about something else. And I have been sitting with her number in my phone since then and I cannot — I cannot make myself dial it.
|
||||||
|
|
||||||
|
I designed a bridge. People drive over it every day. That was scarier and I did it. I was twenty-six years old and I was terrified and I did it. So why can't I do this?
|
||||||
|
LUKE: Go on.
|
||||||
|
CALLER: My wife Ruth died four years ago. We used to do amateur radio together on Sunday nights. She'd keep the logbook while I worked the dial. And Sunday nights have been... they've been the hardest nights since then.
|
||||||
|
|
||||||
|
That's why I listen to you. I never told anyone that before.
|
||||||
|
|
||||||
|
But here's the thing. I wrote out what I want to say to Patrice. I have it written down in my engineering notebook, the same one where I log the Jupiter observations. And I've practiced it. I know what I want to tell her. But it's midnight on a Sunday night and I'm calling a radio show instead of calling her, and I need to know what that is. What am I actually afraid of?
|
||||||
|
|
||||||
|
Is it that she'll say no? Is it that she'll say yes? I don't know how to separate those two things anymore.
|
||||||
|
LUKE: Mm-hmm.
|
||||||
|
CALLER: And the thing is, I know her number. I've known it for three months. She gave it to me. That means something, doesn't it?
|
||||||
|
|
||||||
|
But what if the voice doesn't match? What if I hear her and it's all different than what I've been imagining? What if I'm not... what if I'm not what she's been imagining either?
|
||||||
|
|
||||||
|
We've written 340 emails. Three hundred and forty. Some of them are pages long. I can tell you what she thinks about the Curiosity rover and what she thinks about teaching teenagers and what she thinks about that mountain lion. But I've never heard her laugh. I don't know if she has an accent. I don't know if she snores or if she hums while she's thinking or any of the things you actually know about a person until you hear their voice.
|
||||||
|
|
||||||
|
And Ruth and I, we had all of that. We had Sunday nights for thirty-two years. And then she was gone. And now I have this woman who knows me, really knows me, and I'm terrified to risk it by making it real.
|
||||||
|
|
||||||
|
Is that it? Is that what this is?
|
||||||
|
LUKE: Yeah?
|
||||||
|
CALLER: I'm sorry, I don't know what you're asking me.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
=== Margaux (29, Santa Fe, NM) ===
|
||||||
|
voice: Tessa
|
||||||
|
emotional_register: Sharp intelligence turned against herself, dark humor as a coping mechanism, more raw than she intended to be
|
||||||
|
secret_want: To hear someone say that leaving a perfectly fine person for no dramatic reason is something real adults actually do.
|
||||||
|
|
||||||
|
CALLER (Margaux, 29, Santa Fe, NM): I don't have a question, I just — what do you do when you realize you've been lying to yourself for like two years?
|
||||||
|
LUKE: Wait — really?
|
||||||
|
CALLER: I mean, not lying exactly. That's not fair. I just... I kept telling myself I was happy because everything looked fine on paper, you know? He's stable, he's nice, he doesn't yell or cheat or any of that stuff. But I realized tonight that I don't actually like him. Like as a person. And I've known it for a while, I think. I just didn't want to know it.
|
||||||
|
LUKE: Yeah?
|
||||||
|
CALLER: Yeah. And the worst part is there's no reason. There's no big thing I can point to. He didn't do anything wrong. He just... he's boring. He's kind of boring and he slightly irritates me and I've been confusing not fighting with actually being in love and I don't know how to leave someone when the only real reason is that I don't like them very much anymore.
|
||||||
|
LUKE: Wait — really?
|
||||||
|
CALLER: Yes. Really. I know how that sounds. I know I'm supposed to be grateful or whatever. Most people would kill for stable and nice and no drama. And I am grateful, I think. But gratitude isn't love and I've been trying to make it into love for two years and it's not working.
|
||||||
|
LUKE: Mm-hmm.
|
||||||
|
CALLER: I drove over to pull over after that song and I just sat there in my car and I realized I've practiced breaking up with him like forty times. Forty times, Luke. And I always start crying at the same part where I say "you're a good person" because he is, and that's the problem. How do you leave someone who didn't do anything wrong?
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
=== Preston (24, Albuquerque, NM (student housing, UNM area)) ===
|
||||||
|
voice: Evan
|
||||||
|
emotional_register: Hyper-articulate and slightly manic — the voice of someone who has been alone with a thought too long and needs to hear it out loud to know if it's real
|
||||||
|
secret_want: To hear that the ideas were worth stealing — that 'temporal debt' is actually a good concept — because if it is, at least the loss means something
|
||||||
|
|
||||||
|
CALLER (Preston, 24, Albuquerque, NM (student housing, UNM area)): Okay so I know how this is going to sound and I'm asking you to stay with me for like thirty seconds before you make up your mind about me.
|
||||||
|
LUKE: That's — okay, keep going.
|
||||||
|
CALLER: I've been working with my advisor, Dr. Henriksen, for two years now. Every Thursday at four o'clock we meet in his office and we talk through ideas for my dissertation. Two weeks ago I introduced a concept — I called it temporal debt, which is basically the idea that institutions carry forward unacknowledged obligations from past decisions, right? It's about how the past isn't actually past in the way we pretend it is.
|
||||||
|
|
||||||
|
I said it out loud. Just said it. Didn't write it down because we were just talking, just thinking through something I'd been mulling over.
|
||||||
|
|
||||||
|
And then — and this is where it gets — eight days later he posts a conference paper to academia.edu. And in this paper is a section on institutional obligations that carries the exact structural logic of what I said. He uses different language, obviously, but the architecture of the argument is mine. It's the same thing reworded.
|
||||||
|
|
||||||
|
I've read it seventeen times, Luke. I've underlined the relevant passage in three different colors of pen. Red for logical structure, blue for conceptual dependencies, green for the anthropological framework he's using. My copy looks insane. I know it looks insane.
|
||||||
|
|
||||||
|
But here's the thing that's eating me alive: I have no proof I said it first. It was just a conversation. There's no email, no notes he took, nothing. And now I'm
|
||||||
|
LUKE: That's — okay, keep going.
|
||||||
|
CALLER: ...I'm lying awake at night wondering if I'm actually losing my mind. Like, if I can't prove something happened, did it happen? That's a real philosophical problem, right? But it's also maybe just me being paranoid at one in the morning.
|
||||||
|
|
||||||
|
The other thing is — and I hate that I'm thinking this — but I looked back at his 2021 essay. The one on institutional memory that's why I came to UNM in the first place. I applied here specifically to work with him because of that essay. And there was a grad student he had in 2019 who left the program suddenly. Just disappeared. And now I'm reading that essay again and I'm seeing things in it that feel like they might not be entirely his, and I don't even know if I'm pattern-matching or if there's actually something there.
|
||||||
|
|
||||||
|
So either my advisor is systematically taking ideas from his students, or I'm constructing this whole paranoid narrative out of nothing, and I genuinely cannot tell which one is true. And I don't know what to do with that. Do I say something? To who? Do I destroy my academic relationship with someone I came here to study with? Do I just... let it go?
|
||||||
|
|
||||||
|
I make coffee during the day, by the way. Pour-over coffee. My coworkers call me the professor and usually it's funny but tonight it's making me feel
|
||||||
|
LUKE: And then what?
|
||||||
|
CALLER: Like I'm a character in someone else's story. Like I'm not the one making decisions, I'm just the one things are happening to.
|
||||||
|
|
||||||
|
That's the part that's really getting to me. Because if he took the idea, then at least the idea exists, right? At least temporal debt is real enough to be worth stealing. But if I'm making this up, if I'm just seeing patterns that aren't there because I'm exhausted and paranoid, then what am I? Just a guy who didn't sleep and convinced himself he was wronged by someone he admires.
|
||||||
|
|
||||||
|
I need to know which one it is. Or I need someone to tell me what the right move is when you can't know which one it is. Because either way I'm fucked. Either I stay quiet and let it happen, or I say something I can't prove and I'm the grad student who cried plagiarism. I'm the one people don't want to work with. I'm the unstable one.
|
||||||
|
|
||||||
|
What do you do with an injustice you can't prove? How do you live with not knowing if you're being wronged or if you're just broken?
|
||||||
|
LUKE: And then what?
|
||||||
|
CALLER: I don't know. I called you. I'm calling at almost one in the morning because I don't know what else to do.
|
||||||
|
|
||||||
|
I thought maybe if I said it out loud to someone, if I heard myself say it, I'd know whether I sound crazy or whether I sound like someone who actually has a legitimate problem. But I still don't know. I still can't tell.
|
||||||
|
|
||||||
|
Do you think I should talk to him about it? Do I go to the department chair? Do I just drop out and find a different advisor and pretend this never happened? Because that's an option too. Just leave. Start over somewhere else where I'm not spending every Thursday afternoon wondering if the person across the desk from me is taking pieces of my brain.
|
||||||
|
|
||||||
|
What would you do?
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
=== Ronda (67, Tucumcari, NM) ===
|
||||||
|
voice: Loretta
|
||||||
|
emotional_register: Gruff and self-deprecating on the surface, genuinely tender underneath — the kind of person who will tell you she's fine in a tone that makes you want to check
|
||||||
|
secret_want: To be seen as someone who is parenting correctly by allowing wonder — she suspects her daughter thinks she lets Cody run too wild and she wants validation from a neutral party
|
||||||
|
|
||||||
|
CALLER (Ronda, 67, Tucumcari, NM): Luke I want to say first I'm not one of those grandmothers who calls about every little thing, I once drove through an ice storm outside Amarillo with a busted heater and didn't call nobody.
|
||||||
|
LUKE: So what are you gonna do about it?
|
||||||
|
CALLER: Well that's what I'm calling you about, Luke. I don't know what to do and I'm trying real hard not to overreact here because the kid is nine and he's curious and that's a good thing. That's a real good thing.
|
||||||
|
|
||||||
|
But he ate something off my palo verde tree last Tuesday that wasn't on the Desert Nibbles channel and his mom — my daughter — she's already giving me that look, you know the one, like I'm not supervising right. And I am supervising. I'm right there watching him. But I don't want to tell him no, don't wonder about plants, don't explore. I spent thirty-one years driving because I wanted to see what was out there.
|
||||||
|
|
||||||
|
The thing is, the YouTube guy, Mr. Pete, he seems legit. He's careful about it. He shows you the whole plant, the leaves, the berries, he explains what's safe and what's not. So I can't even blame the source, which somehow makes this harder.
|
||||||
|
|
||||||
|
I guess what I'm asking is — how do I let him keep being curious without, you know, him ending up in the emergency room? Or without his mother deciding I'm the irresponsible grandma who let him poison himself in my backyard?
|
||||||
|
LUKE: Go on.
|
||||||
|
CALLER: Well, I'm thinking maybe I need to make it official, you know? Like, turn it into a real project instead of just him wandering around eating whatever catches his eye.
|
||||||
|
|
||||||
|
I could take him to one of those native plant nurseries, the ones where everything's labeled. Let him see the plants in a controlled way. Or maybe I get one of those field guides, the laminated ones you can actually use, and we go through my yard together systematically. Write down what's what. Make a list.
|
||||||
|
|
||||||
|
That way he's still exploring, he's still curious like he should be, but I'm the one directing it instead of just hoping he doesn't grab the wrong thing off a tree.
|
||||||
|
|
||||||
|
And maybe I talk to his mom about it. Not like I'm defending myself, but like — here's what we're doing, here's how we're doing it safely. Because she needs to understand that I'm not just letting him run wild. I'm actually, you know, engaged with this.
|
||||||
|
|
||||||
|
The other thing is I could probably limit the YouTube channel to specific times. Not ban it. Just like, Mr. Pete on Saturday mornings, and then we go outside and we verify things together. So it's not just passive watching, it's actually learning.
|
||||||
|
|
||||||
|
Does that sound like I'm overthinking this or does that sound reasonable?
|
||||||
|
LUKE: How'd you end up there?
|
||||||
|
CALLER: You mean in her backyard? Well, that's where I live now, Luke. I retired about four years ago and my daughter, she needed help with the kids, and I needed to not be alone in some apartment somewhere after thirty-one years of moving. So I moved into her guest house out in Arizona. It made sense at the time and it still does most days.
|
||||||
|
|
||||||
|
Cody, he's the youngest of her three, and he's always been the one who notices things. Asks a lot of questions. Last summer he got interested in rocks, then it was insects, and now it's plants. I figured it was just how his brain works and I thought it was good that he had space to explore it.
|
||||||
|
|
||||||
|
But when I found him with that root in his mouth last Tuesday, that's when I thought — okay, Ronda, maybe you need to think about this different. Maybe curiosity without guardrails isn't the same thing as freedom.
|
||||||
|
|
||||||
|
I drove alone across this country hundreds of times, Luke. I knew where the rest stops were, I knew which routes were safe at night, I knew how to break down on the side of the road. That wasn't recklessness. That was curiosity with a plan.
|
||||||
|
|
||||||
|
So I guess that's what I'm trying to figure out. How to give that to him. The curiosity with the plan.
|
||||||
|
LUKE: Yeah?
|
||||||
|
CALLER: Yeah. I mean, I know he's only nine. I know he's not me. But I also remember being nine and wanting to understand things, and I remember my mother telling me no so much that I just stopped asking her anything.
|
||||||
|
|
||||||
|
She had this card, actually, that she kept — I've got it in my wallet right now, laminated — it says worry is a prayer for what you don't want. She gave it to me when I told her I wanted to drive truck and she was scared. And I kept that card all these years.
|
||||||
|
|
||||||
|
The irony is not lost on me that I'm sitting here at eleven o'clock at night calling a radio show because I'm worried.
|
||||||
|
|
||||||
|
But here's the thing — I don't want to be the person who tells Cody to stop wondering. I just need to figure out how to wonder with him in a way that doesn't end with a trip to the ER and his mother never letting him come to my house again.
|
||||||
|
|
||||||
|
So yeah. That's where I'm at.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
=== Rosalind (47, Las Cruces, NM) ===
|
||||||
|
voice: Veronica
|
||||||
|
emotional_register: Precise, earnest, quietly desperate for intellectual companionship, not fragile but genuinely unsettled
|
||||||
|
secret_want: Permission — from a stranger, in the dark, over the radio — to keep believing in both things at once without choosing.
|
||||||
|
|
||||||
|
CALLER (Rosalind, 47, Las Cruces, NM): I'm not one of those people, I want to be clear about that, but I've been having a hard time sleeping.
|
||||||
|
LUKE: Go on.
|
||||||
|
CALLER: I've been going to this study group. Wednesday nights at the library where I work. It's not... it's not a cult thing or anything like that. We're just looking at the documents. The Pentagon stuff. We read them together and we talk about what they mean.
|
||||||
|
|
||||||
|
And I'm fine with that. I can do that. But then I go home and I pray and I think about the Incarnation and I think about what it would mean if we're not alone and my brain just kind of stops working.
|
||||||
|
|
||||||
|
I need to know if those two things can exist in the same person. I need to know if asking these questions makes me a bad Catholic or just a curious person. Because my priest couldn't answer me and I've been lying awake at night feeling like I'm supposed to choose and I don't want to choose.
|
||||||
|
LUKE: What'd she say?
|
||||||
|
CALLER: Who, my priest? He just... he kept saying that God created all things visible and invisible, which I already know. I already know that. And then he got quiet and he said maybe I should talk to someone with more education in these matters. Which basically means he doesn't know either.
|
||||||
|
|
||||||
|
So I'm calling you instead because it's midnight and I can't sleep and I figure if anyone's heard this question before, it's you.
|
||||||
|
LUKE: Mm-hmm.
|
||||||
|
CALLER: I keep thinking about it like this. If God made everything, then He made whatever's out there too, right? So encountering that wouldn't be encountering something outside His creation. It would just be... encountering more of His creation.
|
||||||
|
|
||||||
|
But then I think about the Resurrection. I think about what makes us special in Christian theology. And I don't know if that falls apart if we're not the only intelligent beings God bothered with.
|
||||||
|
|
||||||
|
I've been a librarian for twenty-three years. I organize information for a living. I'm supposed to be comfortable with ambiguity. But this isn't ambiguous to me anymore. It's either going to be a crisis or it's going to be fine, and I can't figure out which one I'm supposed to be prepared for.
|
||||||
|
LUKE: Yeah?
|
||||||
|
CALLER: Yeah. And the thing that's really keeping me up is that I don't even know if I want an answer. I think I just want permission to stop feeling like I'm doing something wrong by thinking about both things at the same time.
|
||||||
|
|
||||||
|
My ex-husband would have made a joke about this. Some stupid joke about me checking out books under fake names, which I did, by the way. I felt more guilty about lying on the library card than I did about the book itself, which is its own kind of problem.
|
||||||
|
|
||||||
|
But he's not here to make the joke, so I'm calling you instead.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
=== Silas (52, Deming, NM (The Wellspring, 200 acres scrubland)) ===
|
||||||
|
voice: Timothy
|
||||||
|
emotional_register: Quietly fractured — the warmth is still there but it is doing real load-bearing work tonight, holding something heavier than usual underneath it
|
||||||
|
secret_want: To be told that growth is supposed to hurt and that he is not a bad man — and to be believed when he agrees.
|
||||||
|
|
||||||
|
CALLER (Silas, 52, Deming, NM (The Wellspring, 200 acres scrubland)): Brother, I've been standing over a curing rack for about four hours now and I think I need to talk to someone who isn't me.
|
||||||
|
LUKE: And then what?
|
||||||
|
CALLER: I'm sorry, I'm not following you. And then what, what?
|
||||||
|
LUKE: How'd you end up there?
|
||||||
|
CALLER: Oh. Right. Well, three days ago I held a community meeting — we call it a circle, actually — and I made some changes to how we do things here at The Wellspring. I apologized for... for the way certain practices had developed without real consent. The shared intimacy nights. I made them optional. I read it from three pages of yellow legal pad, and I cried, which I haven't done in front of the community before.
|
||||||
|
|
||||||
|
Nine people thanked me. But two people left. Just walked out and didn't come back. One of them was Deva, and Deva has been with us for six years and she runs — she ran — the soap-curing room. She does the stamp work. The heron stamp. Every bar gets pressed with a heron.
|
||||||
|
|
||||||
|
So now I'm doing it myself at midnight because the Santa Fe spa order is due next week and I can't... I can't leave it undone. But my hands aren't hers. The impressions aren't coming out right.
|
||||||
|
LUKE: Mm-hmm.
|
||||||
|
CALLER: And I keep thinking about what she said when she left. She said, "You're a good man who built a bad system, Silas." And I can't — I honestly cannot tell if that's forgiveness or if it's the cruelest thing anyone's ever said to me.
|
||||||
|
LUKE: So what are you gonna do about it?
|
||||||
|
CALLER: I don't know. That's why I'm calling you at midnight instead of sleeping or fixing the stamps or... I don't know.
|
||||||
|
|
||||||
|
Part of me thinks I should call her. Try to make it right somehow. But what does that even mean? I apologized. I changed the policy. She left anyway because the apology proved the whole thing was wrong to begin with, which — she's not wrong about that. So what am I supposed to do, unsay it? Take it back?
|
||||||
|
|
||||||
|
The other part of me thinks maybe the answer is just to keep going. To do the stamp work myself until I get better at it, to run the business, to let people leave if they need to leave. To accept that growth is supposed to hurt and that I did the right thing even though it feels like I'm losing something.
|
||||||
|
|
||||||
|
But I need to know — is that true? Did I do the right thing?
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
=== Toad (38, Española, NM) ===
|
||||||
|
voice: Vinny
|
||||||
|
emotional_register: Tough exterior, genuine bewilderment at his own sadness, searching for permission to feel it
|
||||||
|
secret_want: To be told he is a good father even though he coaches the team partly for himself.
|
||||||
|
|
||||||
|
CALLER (Toad, 38, Española, NM): My kid quit baseball on me and I know that sounds dumb but I'm driving around and I can't go home yet.
|
||||||
|
LUKE: How'd you end up there?
|
||||||
|
CALLER: Well, he just... he told me Wednesday night at dinner. Said he didn't want to do it anymore. Didn't want to play this season. I asked him why and he couldn't really give me a reason, you know? Just said he didn't feel like it.
|
||||||
|
|
||||||
|
I've been coaching that team for four years, Luke. Four years. And I didn't push him or nothing, I just said okay, we'll talk about it more. But inside I felt like somebody punched me in the gut.
|
||||||
|
|
||||||
|
So now I'm driving around at eleven o'clock at night because if I go home I'm just gonna sit there and my wife's gonna ask me what's wrong and I don't even know how to explain it.
|
||||||
|
LUKE: Mm-hmm.
|
||||||
|
CALLER: I mean, I know he's his own person, right? He gets to choose. I'm not one of those dads. I'm not trying to live through my kid or whatever. But this was... we had something, you know? Every spring we had this thing.
|
||||||
|
|
||||||
|
And I don't even know if he knows how much it meant to me. That's the thing that's sitting weird.
|
||||||
|
LUKE: So what are you gonna do about it?
|
||||||
|
CALLER: I don't know. That's why I'm calling you at midnight instead of being home. I want to talk to him more about it, find out what's really going on. Maybe he's scared, maybe some kid said something to him, I don't know.
|
||||||
|
|
||||||
|
But part of me is scared if I push too hard he's gonna dig in harder, you know? And another part of me wants to just tell him he's playing anyway because he committed to the team.
|
||||||
|
|
||||||
|
I guess what I'm really asking is... am I allowed to be this upset about it? Because it feels stupid. He's nine. Kids change their minds about stuff. But I can't shake it, and I feel like maybe that means I'm doing something wrong as a father.
|
||||||
|
LUKE: That's — okay, keep going.
|
||||||
|
CALLER: I have this glove in the back of my truck. Still in the box. I bought it for him when he was six, too small for him then, so I just kept it. Been sitting back there for three years waiting for him to grow into it. I don't even know why I keep it back there except I do.
|
||||||
|
|
||||||
|
And there's other stuff too. I coach because I want to be around him, sure, but also because... I don't know, man. Because when I'm on that field I'm not thinking about the shop or the bills or any of it. I'm just there. I'm just present, you know?
|
||||||
|
|
||||||
|
And he's good, Luke. He's really good. He's got a real arm and he pays attention. And I thought maybe that meant something. I thought maybe that meant he liked it as much as I do.
|
||||||
|
|
||||||
|
But maybe I'm the only one who likes it that much.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Download vocal-free background music from Jamendo (CC-licensed).
|
||||||
|
|
||||||
|
Targets late-night talk-radio vibe + hip-hop. Skips tracks shorter than 60s,
|
||||||
|
dedupes against existing files in music/, and appends CREDITS.txt entries.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python download_music.py # 100 tracks across all buckets
|
||||||
|
python download_music.py --count 30 # smaller batch
|
||||||
|
python download_music.py --dry-run # show what would be downloaded
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.request import urlopen, Request
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
CLIENT_ID = os.getenv("JAMENDO_CLIENT_ID")
|
||||||
|
if not CLIENT_ID:
|
||||||
|
print("ERROR: JAMENDO_CLIENT_ID not set in .env", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
MUSIC_DIR = Path(__file__).parent / "music"
|
||||||
|
CREDITS_FILE = MUSIC_DIR / "CREDITS.txt"
|
||||||
|
|
||||||
|
# (tag_query, genre_label, target_count) — hip-hop weighted heaviest per user pref
|
||||||
|
BUCKETS = [
|
||||||
|
("hiphop+instrumental", "Hip-Hop", 40),
|
||||||
|
("jazz", "Jazz", 20),
|
||||||
|
("lofi", "Lo-Fi", 15),
|
||||||
|
("funk", "Funk", 15),
|
||||||
|
("soul", "Soul", 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Filenames already on disk — skip duplicates by (artist, title) signature
|
||||||
|
def _existing_signatures() -> set[str]:
|
||||||
|
sigs = set()
|
||||||
|
for f in MUSIC_DIR.glob("*.mp3"):
|
||||||
|
# "Artist - Title [Genre].mp3" or "Artist - Title.mp3"
|
||||||
|
stem = f.stem
|
||||||
|
stem = re.sub(r"\s*\[[^\]]+\]\s*$", "", stem)
|
||||||
|
sigs.add(stem.lower().strip())
|
||||||
|
for f in MUSIC_DIR.glob("*.wav"):
|
||||||
|
sigs.add(f.stem.lower().strip())
|
||||||
|
return sigs
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(s: str) -> str:
|
||||||
|
s = s.replace("/", "-").replace("\\", "-")
|
||||||
|
s = re.sub(r'[<>:"|?*]', "", s)
|
||||||
|
return s.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_jamendo_page(tag_query: str, offset: int, limit: int = 50) -> list[dict]:
|
||||||
|
params = {
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"format": "json",
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"vocalinstrumental": "instrumental",
|
||||||
|
"fuzzytags": tag_query,
|
||||||
|
"audioformat": "mp32",
|
||||||
|
"include": "musicinfo+licenses",
|
||||||
|
"audiodlallowed": "true",
|
||||||
|
"ccnd": "true", # allow non-derivative (we won't modify)
|
||||||
|
"order": "popularity_total",
|
||||||
|
}
|
||||||
|
url = "https://api.jamendo.com/v3.0/tracks/?" + urlencode(params)
|
||||||
|
with urlopen(Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"}), timeout=30) as r:
|
||||||
|
import json
|
||||||
|
data = json.load(r)
|
||||||
|
if data.get("headers", {}).get("status") != "success":
|
||||||
|
print(f" API error: {data.get('headers', {}).get('error_message')}")
|
||||||
|
return []
|
||||||
|
return data.get("results", [])
|
||||||
|
|
||||||
|
|
||||||
|
def _download(url: str, dest: Path) -> bool:
|
||||||
|
try:
|
||||||
|
req = Request(url, headers={"User-Agent": "ai-podcast-music-fetcher/1.0"})
|
||||||
|
with urlopen(req, timeout=120) as r, open(dest, "wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = r.read(64 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
out.write(chunk)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" download failed: {e}")
|
||||||
|
if dest.exists():
|
||||||
|
dest.unlink()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_bucket(tag_query: str, genre_label: str, target: int, existing: set[str], dry_run: bool) -> list[tuple[Path, dict]]:
|
||||||
|
"""Returns list of (path, track_info) successfully downloaded."""
|
||||||
|
print(f"\n=== {genre_label} (target {target}) ===")
|
||||||
|
downloaded: list[tuple[Path, dict]] = []
|
||||||
|
offset = 0
|
||||||
|
seen_ids = set()
|
||||||
|
while len(downloaded) < target and offset < 500: # cap pagination
|
||||||
|
page = _fetch_jamendo_page(tag_query, offset)
|
||||||
|
if not page:
|
||||||
|
break
|
||||||
|
offset += len(page)
|
||||||
|
for track in page:
|
||||||
|
if len(downloaded) >= target:
|
||||||
|
break
|
||||||
|
tid = track.get("id")
|
||||||
|
if tid in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(tid)
|
||||||
|
|
||||||
|
if track.get("duration", 0) < 60:
|
||||||
|
continue
|
||||||
|
if not track.get("audiodownload_allowed"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
artist = _sanitize(track.get("artist_name", "Unknown"))
|
||||||
|
name = _sanitize(track.get("name", "Untitled"))
|
||||||
|
sig = f"{artist} - {name}".lower().strip()
|
||||||
|
if sig in existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filename = f"{artist} - {name} [{genre_label}].mp3"
|
||||||
|
dest = MUSIC_DIR / filename
|
||||||
|
|
||||||
|
audio_url = track.get("audiodownload") or track.get("audio")
|
||||||
|
if not audio_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print(f" [DRY] {filename} ({track.get('duration')}s)")
|
||||||
|
downloaded.append((dest, track))
|
||||||
|
existing.add(sig)
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" ↓ {filename} ({track.get('duration')}s)")
|
||||||
|
if _download(audio_url, dest):
|
||||||
|
downloaded.append((dest, track))
|
||||||
|
existing.add(sig)
|
||||||
|
time.sleep(0.5) # be polite
|
||||||
|
if len(page) < 50:
|
||||||
|
break
|
||||||
|
return downloaded
|
||||||
|
|
||||||
|
|
||||||
|
def append_credits(entries: list[tuple[Path, dict]]):
|
||||||
|
if not entries:
|
||||||
|
return
|
||||||
|
with open(CREDITS_FILE, "a") as f:
|
||||||
|
f.write(f"\n# Added {time.strftime('%Y-%m-%d')} — vocal-free batch via Jamendo API\n")
|
||||||
|
for dest, track in entries:
|
||||||
|
license_url = track.get("license_ccurl", "")
|
||||||
|
share_url = track.get("shareurl", "")
|
||||||
|
artist = track.get("artist_name", "")
|
||||||
|
name = track.get("name", "")
|
||||||
|
f.write(f"{dest.name} | {artist} - {name} | {license_url} | {share_url}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--count", type=int, default=100, help="Total tracks (default 100)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
MUSIC_DIR.mkdir(exist_ok=True)
|
||||||
|
existing = _existing_signatures()
|
||||||
|
print(f"Existing tracks: {len(existing)}")
|
||||||
|
|
||||||
|
# Scale bucket targets proportionally to --count
|
||||||
|
scale = args.count / sum(b[2] for b in BUCKETS)
|
||||||
|
all_new: list[tuple[Path, dict]] = []
|
||||||
|
for tag, label, weight in BUCKETS:
|
||||||
|
target = max(1, round(weight * scale))
|
||||||
|
all_new.extend(fetch_bucket(tag, label, target, existing, args.dry_run))
|
||||||
|
|
||||||
|
print(f"\n=== Done. {len(all_new)} new tracks {'planned' if args.dry_run else 'downloaded'}. ===")
|
||||||
|
if not args.dry_run:
|
||||||
|
append_credits(all_new)
|
||||||
|
print(f"CREDITS.txt updated.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+261
@@ -0,0 +1,261 @@
|
|||||||
|
"""Fetch instrumental background music from Jamendo for the radio show.
|
||||||
|
|
||||||
|
Pixabay has no public music API — this uses Jamendo's free API instead.
|
||||||
|
All tracks are Creative Commons licensed. Attribution is saved to music/CREDITS.txt.
|
||||||
|
|
||||||
|
Setup: Get a free client_id at https://devportal.jamendo.com
|
||||||
|
Add JAMENDO_CLIENT_ID=your_id to .env
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python fetch_music.py # download 20 tracks across all genres
|
||||||
|
python fetch_music.py --genre jazz # download jazz only
|
||||||
|
python fetch_music.py --count 50 # download 50 tracks
|
||||||
|
python fetch_music.py --list # just list available tracks, don't download
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
MUSIC_DIR = Path(__file__).parent / "music"
|
||||||
|
CREDITS_FILE = MUSIC_DIR / "CREDITS.txt"
|
||||||
|
API_BASE = "https://api.jamendo.com/v3.0"
|
||||||
|
|
||||||
|
# Genres good for a late-night radio show
|
||||||
|
GENRES = ["jazz", "lofi", "blues", "ambient", "acoustic", "funk", "chill"]
|
||||||
|
|
||||||
|
# Map search tags to labels that _detect_genre() in main.py can match
|
||||||
|
# jazz, blues, funk, lo-fi are already in GENRE_KEYWORDS
|
||||||
|
# ambient, acoustic, chill would need to be added for auto-detection
|
||||||
|
GENRE_LABELS = {
|
||||||
|
"jazz": "Jazz",
|
||||||
|
"lofi": "Lo-Fi",
|
||||||
|
"blues": "Blues",
|
||||||
|
"ambient": "Ambient",
|
||||||
|
"acoustic": "Acoustic",
|
||||||
|
"funk": "Funk",
|
||||||
|
"chill": "Chill",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_id():
|
||||||
|
key = os.getenv("JAMENDO_CLIENT_ID")
|
||||||
|
if not key:
|
||||||
|
print("Error: JAMENDO_CLIENT_ID not found in .env")
|
||||||
|
print("Get one free at https://devportal.jamendo.com")
|
||||||
|
sys.exit(1)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str:
|
||||||
|
return re.sub(r'[<>:"/\\|?*]', '', name).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _has_vocals(track: dict) -> bool:
|
||||||
|
"""Check musicinfo for vocal indicators — catches tracks Jamendo mis-tagged as instrumental."""
|
||||||
|
mi = track.get("musicinfo", {})
|
||||||
|
# Check the vocalinstrumental field in musicinfo (separate from the API filter)
|
||||||
|
vi = mi.get("vocalinstrumental")
|
||||||
|
if vi and vi.lower() == "vocal":
|
||||||
|
return True
|
||||||
|
# Check tags for vocal/singing indicators
|
||||||
|
tags = mi.get("tags", {})
|
||||||
|
# tags can be {"genres": [...], "instruments": [...], "vartags": [...]}
|
||||||
|
all_tags = []
|
||||||
|
if isinstance(tags, dict):
|
||||||
|
for v in tags.values():
|
||||||
|
if isinstance(v, list):
|
||||||
|
all_tags.extend(t.lower() for t in v)
|
||||||
|
elif isinstance(tags, list):
|
||||||
|
all_tags = [t.lower() for t in tags]
|
||||||
|
vocal_tags = {"vocals", "vocal", "singing", "singer", "voice", "lyrics",
|
||||||
|
"rap", "hiphop", "hip-hop", "spoken", "spoken word"}
|
||||||
|
if vocal_tags & set(all_tags):
|
||||||
|
return True
|
||||||
|
# Check track name for vocal giveaways
|
||||||
|
name_lower = track.get("name", "").lower()
|
||||||
|
if any(w in name_lower for w in ["feat.", "ft.", "vocal", "remix vocal", "(voice"]):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def search_tracks(client: httpx.Client, client_id: str, genre: str, limit: int = 20) -> list[dict]:
|
||||||
|
# Request more than needed so we can filter out vocal false positives
|
||||||
|
fetch_limit = min(limit * 3, 200)
|
||||||
|
params = {
|
||||||
|
"client_id": client_id,
|
||||||
|
"format": "json",
|
||||||
|
"limit": fetch_limit,
|
||||||
|
"vocalinstrumental": "instrumental",
|
||||||
|
"fuzzytags": genre,
|
||||||
|
"durationbetween": "60_300",
|
||||||
|
"include": "musicinfo+licenses",
|
||||||
|
"order": "popularity_total",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = client.get(f"{API_BASE}/tracks/", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if data["headers"]["status"] != "success":
|
||||||
|
print(f" API error: {data['headers'].get('error_message', 'unknown')}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = data.get("results", [])
|
||||||
|
# Post-filter: reject tracks with vocal indicators despite the API filter
|
||||||
|
filtered = []
|
||||||
|
for t in results:
|
||||||
|
if _has_vocals(t):
|
||||||
|
print(f" SKIP (vocals detected): {t.get('artist_name', '?')} - {t.get('name', '?')}")
|
||||||
|
continue
|
||||||
|
filtered.append(t)
|
||||||
|
if len(filtered) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
skipped = len(results) - len(filtered)
|
||||||
|
if skipped:
|
||||||
|
print(f" (filtered out {skipped} tracks with vocal indicators)")
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def make_filename(track: dict, genre_tag: str) -> str:
|
||||||
|
artist = sanitize_filename(track.get("artist_name", "Unknown"))
|
||||||
|
title = sanitize_filename(track.get("name", "Untitled"))
|
||||||
|
label = GENRE_LABELS.get(genre_tag, genre_tag.title())
|
||||||
|
|
||||||
|
# Include genre tag if not already detectable from artist/title
|
||||||
|
lower = f"{artist} {title}".lower()
|
||||||
|
needs_tag = not any(kw in lower for kw in [genre_tag, label.lower()])
|
||||||
|
|
||||||
|
if needs_tag:
|
||||||
|
return f"{artist} - {title} [{label}].mp3"
|
||||||
|
return f"{artist} - {title}.mp3"
|
||||||
|
|
||||||
|
|
||||||
|
def download_track(client: httpx.Client, track: dict, filepath: Path, index: int, total: int) -> bool:
|
||||||
|
url = track.get("audiodownload")
|
||||||
|
if not url:
|
||||||
|
print(f" [{index}/{total}] SKIP (no download URL): {track['name']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not track.get("audiodownload_allowed", True):
|
||||||
|
print(f" [{index}/{total}] SKIP (download not allowed): {track['name']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f" [{index}/{total}] Downloading: {filepath.name}...", end=" ", flush=True)
|
||||||
|
resp = client.get(url, follow_redirects=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
filepath.write_bytes(resp.content)
|
||||||
|
size_mb = len(resp.content) / (1024 * 1024)
|
||||||
|
dur = track.get("duration", 0)
|
||||||
|
print(f"{size_mb:.1f} MB, {dur // 60}:{dur % 60:02d}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def save_credit(track: dict, filename: str):
|
||||||
|
artist = track.get("artist_name", "Unknown")
|
||||||
|
title = track.get("name", "Untitled")
|
||||||
|
license_url = track.get("license_ccurl", "")
|
||||||
|
share_url = track.get("shareurl", "")
|
||||||
|
|
||||||
|
line = f"{filename} | {artist} - {title} | {license_url} | {share_url}\n"
|
||||||
|
|
||||||
|
existing = CREDITS_FILE.read_text() if CREDITS_FILE.exists() else ""
|
||||||
|
if filename not in existing:
|
||||||
|
with open(CREDITS_FILE, "a") as f:
|
||||||
|
if not existing:
|
||||||
|
f.write("# Music Credits (Jamendo - Creative Commons)\n")
|
||||||
|
f.write("# File | Artist - Title | License | URL\n\n")
|
||||||
|
f.write(line)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Download instrumental music from Jamendo")
|
||||||
|
parser.add_argument("--genre", choices=GENRES, help="Download only this genre")
|
||||||
|
parser.add_argument("--count", type=int, default=20, help="Total tracks to download (default: 20)")
|
||||||
|
parser.add_argument("--list", action="store_true", help="List available tracks without downloading")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
client_id = get_client_id()
|
||||||
|
MUSIC_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
genres = [args.genre] if args.genre else GENRES
|
||||||
|
per_genre = max(1, args.count // len(genres))
|
||||||
|
remainder = args.count - per_genre * len(genres)
|
||||||
|
|
||||||
|
all_tracks = []
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
with httpx.Client(timeout=30) as api_client:
|
||||||
|
for i, genre in enumerate(genres):
|
||||||
|
limit = per_genre + (1 if i < remainder else 0)
|
||||||
|
if limit <= 0:
|
||||||
|
continue
|
||||||
|
print(f"Searching {genre}...", end=" ", flush=True)
|
||||||
|
tracks = search_tracks(api_client, client_id, genre, limit)
|
||||||
|
# Deduplicate across genres
|
||||||
|
added = 0
|
||||||
|
for t in tracks:
|
||||||
|
if t["id"] not in seen_ids and added < limit:
|
||||||
|
t["_genre_tag"] = genre
|
||||||
|
all_tracks.append(t)
|
||||||
|
seen_ids.add(t["id"])
|
||||||
|
added += 1
|
||||||
|
print(f"{added} tracks")
|
||||||
|
|
||||||
|
if not all_tracks:
|
||||||
|
print("No tracks found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
print(f"\n{'#':<4} {'Genre':<10} {'Artist':<25} {'Title':<40} {'Duration':<8}")
|
||||||
|
print("-" * 90)
|
||||||
|
for i, t in enumerate(all_tracks, 1):
|
||||||
|
dur = f"{t['duration'] // 60}:{t['duration'] % 60:02d}"
|
||||||
|
artist = t["artist_name"][:24]
|
||||||
|
title = t["name"][:39]
|
||||||
|
label = GENRE_LABELS.get(t["_genre_tag"], t["_genre_tag"])
|
||||||
|
print(f"{i:<4} {label:<10} {artist:<25} {title:<40} {dur:<8}")
|
||||||
|
print(f"\n{len(all_tracks)} tracks available")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Download phase
|
||||||
|
downloaded = 0
|
||||||
|
skipped_exists = 0
|
||||||
|
skipped_error = 0
|
||||||
|
|
||||||
|
with httpx.Client(timeout=120, follow_redirects=True) as dl_client:
|
||||||
|
for i, track in enumerate(all_tracks, 1):
|
||||||
|
filename = make_filename(track, track["_genre_tag"])
|
||||||
|
filepath = MUSIC_DIR / filename
|
||||||
|
|
||||||
|
if filepath.exists():
|
||||||
|
print(f" [{i}/{len(all_tracks)}] EXISTS: {filename}")
|
||||||
|
skipped_exists += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if download_track(dl_client, track, filepath, i, len(all_tracks)):
|
||||||
|
save_credit(track, filename)
|
||||||
|
downloaded += 1
|
||||||
|
else:
|
||||||
|
skipped_error += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{i}/{len(all_tracks)}] ERROR: {e}")
|
||||||
|
# Clean up partial download
|
||||||
|
if filepath.exists():
|
||||||
|
filepath.unlink()
|
||||||
|
skipped_error += 1
|
||||||
|
|
||||||
|
print(f"\nDone: {downloaded} downloaded, {skipped_exists} existed, {skipped_error} skipped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Cost Dashboard - Luke at the Roost</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
<link rel="stylesheet" href="/css/costs.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="costs-header">
|
||||||
|
<h1>Cost Dashboard</h1>
|
||||||
|
<a href="/" class="back-link">Back to Show</a>
|
||||||
|
<nav class="period-tabs">
|
||||||
|
<button class="period-tab active" data-period="all">All Time</button>
|
||||||
|
<button class="period-tab" data-period="month">This Month</button>
|
||||||
|
<button class="period-tab" data-period="week">This Week</button>
|
||||||
|
<button class="period-tab" data-period="today">Today</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="costs-main">
|
||||||
|
<section class="summary-cards">
|
||||||
|
<div class="card" id="card-total">
|
||||||
|
<div class="card-label">Total Spend</div>
|
||||||
|
<div class="card-value" id="total-spend">--</div>
|
||||||
|
<div class="card-change" id="total-change"></div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-llm-tts">
|
||||||
|
<div class="card-label">LLM / TTS</div>
|
||||||
|
<div class="card-value" id="llm-tts-split">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-sessions">
|
||||||
|
<div class="card-label">Sessions</div>
|
||||||
|
<div class="card-value" id="session-count">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="card" id="card-avg">
|
||||||
|
<div class="card-label">Avg / Session</div>
|
||||||
|
<div class="card-value" id="avg-cost">--</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="chart-row">
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost Over Time</h3>
|
||||||
|
<canvas id="timeline-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost by Model</h3>
|
||||||
|
<canvas id="model-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="chart-row">
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost by Category</h3>
|
||||||
|
<canvas id="category-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h3>Cost Per Session</h3>
|
||||||
|
<canvas id="session-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="chart-row">
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>TTS Providers</h3>
|
||||||
|
<table id="tts-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Provider</th><th>Calls</th><th>Characters</th><th>Cost</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Most Expensive Calls</h3>
|
||||||
|
<table id="expensive-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Model</th><th>Category</th><th>Caller</th><th>Tokens</th><th>Cost</th><th>Latency</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="tables-section">
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Sessions</h3>
|
||||||
|
<table id="sessions-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>LLM</th><th>TTS</th><th>Total</th><th>Calls</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="session-detail hidden" id="session-detail">
|
||||||
|
<h2>Session Detail: <span id="detail-session-id"></span></h2>
|
||||||
|
<button class="close-detail" id="close-detail">Back</button>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Caller</h3>
|
||||||
|
<table id="detail-caller-table">
|
||||||
|
<thead><tr><th>Caller</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Category</h3>
|
||||||
|
<table id="detail-category-table">
|
||||||
|
<thead><tr><th>Category</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>By Model</h3>
|
||||||
|
<table id="detail-model-table">
|
||||||
|
<thead><tr><th>Model</th><th>Calls</th><th>Cost</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<h3>Most Expensive Calls</h3>
|
||||||
|
<table id="detail-expensive-table">
|
||||||
|
<thead><tr><th>Category</th><th>Model</th><th>Caller</th><th>Cost</th><th>Tokens</th><th>Latency</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/js/costs.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
/* Cost Dashboard */
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid rgba(232, 121, 29, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.costs-header h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-tab {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-tab.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-dark);
|
||||||
|
border-color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.costs-main {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary Cards */
|
||||||
|
|
||||||
|
.summary-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-light);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.08);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-change {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-change.positive {
|
||||||
|
color: var(--accent-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-change.negative {
|
||||||
|
color: var(--accent-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Charts */
|
||||||
|
|
||||||
|
.chart-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
background: var(--bg-light);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.08);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container h3 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
|
||||||
|
.tables-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background: var(--bg-light);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.08);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container h3 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid rgba(232, 121, 29, 0.06);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover td {
|
||||||
|
background: rgba(232, 121, 29, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable:hover td {
|
||||||
|
background: rgba(232, 121, 29, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session Detail */
|
||||||
|
|
||||||
|
.session-detail {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-detail.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-detail h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-detail h2 span {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-detail {
|
||||||
|
background: var(--bg-light);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid rgba(232, 121, 29, 0.15);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-detail:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.costs-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cards {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-row,
|
||||||
|
.tables-section,
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1199
-54
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
+163
-62
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Luke at The Roost</title>
|
<title>Luke at The Roost</title>
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
<link rel="stylesheet" href="/css/style.css?v=3">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -16,6 +16,27 @@
|
|||||||
<button id="new-session-btn" class="new-session-btn">New Session</button>
|
<button id="new-session-btn" class="new-session-btn">New Session</button>
|
||||||
<button id="export-session-btn">Export</button>
|
<button id="export-session-btn">Export</button>
|
||||||
<button id="settings-btn">Settings</button>
|
<button id="settings-btn">Settings</button>
|
||||||
|
<a href="/costs" class="header-link-btn" title="Cost Dashboard">Costs</a>
|
||||||
|
</div>
|
||||||
|
<div class="theme-bar">
|
||||||
|
<label for="show-theme-input" class="theme-label">Theme:</label>
|
||||||
|
<input type="text" id="show-theme-input" class="theme-input" placeholder="e.g. St. Patrick's Day" maxlength="100">
|
||||||
|
<button id="set-theme-btn" class="theme-btn set" title="Set show theme">Set</button>
|
||||||
|
<button id="clear-theme-btn" class="theme-btn clear hidden" title="Clear theme">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="show-clock" class="show-clock">
|
||||||
|
<span class="clock-time" id="clock-time"></span>
|
||||||
|
<span id="show-timers" class="show-timers hidden">
|
||||||
|
<span class="clock-divider">|</span>
|
||||||
|
<span class="clock-label">On Air:</span>
|
||||||
|
<span class="clock-value" id="clock-runtime">0:00:00</span>
|
||||||
|
<span class="clock-divider">|</span>
|
||||||
|
<span class="clock-label">Est. Final:</span>
|
||||||
|
<span class="clock-value clock-estimate" id="clock-estimate">0:00</span>
|
||||||
|
<span class="clock-divider">|</span>
|
||||||
|
<span class="clock-label">Cost:</span>
|
||||||
|
<span class="clock-value clock-cost" id="clock-cost">$0.00</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -50,24 +71,64 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="call-status" class="call-status">No active call</div>
|
<div id="call-status" class="call-status">No active call</div>
|
||||||
<details id="caller-background-details" class="caller-background hidden">
|
<div id="caller-info-panel" class="caller-info-panel hidden">
|
||||||
<summary>Caller Background</summary>
|
<div id="caller-identity" class="caller-identity"></div>
|
||||||
<div id="caller-background"></div>
|
<div id="caller-situation" class="caller-situation"></div>
|
||||||
</details>
|
<div id="caller-signature" class="caller-signature"></div>
|
||||||
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
|
<div id="caller-secret-want" class="caller-secret-want"></div>
|
||||||
|
<details id="caller-background-details" class="caller-background-full">
|
||||||
|
<summary>Full Background</summary>
|
||||||
|
<div id="caller-background"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="call-actions">
|
||||||
|
<button id="wrapup-btn" class="wrapup-btn" disabled>Wrap It Up <span class="shortcut-label">W</span></button>
|
||||||
|
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up <span class="shortcut-label">H</span></button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Call Queue -->
|
<!-- Call Queue -->
|
||||||
<section class="queue-section">
|
<section class="queue-section">
|
||||||
<h2>Incoming Calls <span style="font-size:0.6em;font-weight:normal;color:var(--text-muted);">(208) 439-5853</span></h2>
|
<h2>Incoming Calls <span class="section-subtitle">(208) 439-5853</span></h2>
|
||||||
<div id="call-queue" class="call-queue">
|
<div id="call-queue" class="call-queue">
|
||||||
<div class="queue-empty">No callers waiting</div>
|
<div class="queue-empty">No callers waiting</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Voicemail -->
|
||||||
|
<section class="voicemail-section">
|
||||||
|
<h2>Voicemail <span id="voicemail-badge" class="voicemail-badge hidden">0</span></h2>
|
||||||
|
<div id="voicemail-list" class="voicemail-list">
|
||||||
|
<div class="queue-empty">No voicemails</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Listener Emails -->
|
||||||
|
<section class="voicemail-section">
|
||||||
|
<h2>Emails <span id="email-badge" class="voicemail-badge hidden">0</span></h2>
|
||||||
|
<div id="email-list" class="voicemail-list email-list">
|
||||||
|
<div class="queue-empty">No emails</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Chat -->
|
<!-- Chat -->
|
||||||
<section class="chat-section">
|
<section class="chat-section">
|
||||||
<div id="chat" class="chat-log"></div>
|
<div id="chat" class="chat-log"></div>
|
||||||
|
<div class="devon-bar">
|
||||||
|
<div class="devon-ask-row">
|
||||||
|
<input type="text" id="devon-input" placeholder="Ask Devon..." class="devon-input">
|
||||||
|
<button id="devon-ask-btn" class="devon-ask-btn">Ask <span class="shortcut-label">D</span></button>
|
||||||
|
<button id="devon-interject-btn" class="devon-interject-btn" title="Devon interjects on current conversation">Interject</button>
|
||||||
|
<label class="devon-monitor-label" title="Devon auto-monitors conversations">
|
||||||
|
<input type="checkbox" id="devon-monitor" checked> Monitor
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="devon-suggestion" class="devon-suggestion hidden">
|
||||||
|
<span class="devon-suggestion-text">Devon has something</span>
|
||||||
|
<button id="devon-play-btn" class="devon-play-btn">Play</button>
|
||||||
|
<button id="devon-dismiss-btn" class="devon-dismiss-btn">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="talk-controls">
|
<div class="talk-controls">
|
||||||
<button id="talk-btn" class="talk-btn">Hold to Talk</button>
|
<button id="talk-btn" class="talk-btn">Hold to Talk</button>
|
||||||
<button id="type-btn" class="type-btn">Type</button>
|
<button id="type-btn" class="type-btn">Type</button>
|
||||||
@@ -75,26 +136,36 @@
|
|||||||
<div id="status" class="status hidden"></div>
|
<div id="status" class="status hidden"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Music -->
|
<!-- Music / Ads / Idents -->
|
||||||
<section class="music-section">
|
<div class="media-row">
|
||||||
<h2>Music</h2>
|
<section class="music-section genre-section">
|
||||||
<select id="track-select"></select>
|
<h2>Music <span class="shortcut-label">M</span></h2>
|
||||||
<div class="music-controls">
|
<div id="genre-buttons" class="genre-grid"></div>
|
||||||
<button id="play-btn">Play</button>
|
<div id="now-playing" class="now-playing hidden">
|
||||||
<button id="stop-btn">Stop</button>
|
<span id="now-playing-text" class="now-playing-text"></span>
|
||||||
<input type="range" id="volume" min="0" max="100" value="30">
|
<button id="stop-btn" class="now-playing-stop">Stop</button>
|
||||||
</div>
|
<input type="range" id="volume" min="0" max="100" value="30" class="now-playing-volume">
|
||||||
</section>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Ads -->
|
<section class="music-section">
|
||||||
<section class="music-section">
|
<h2>Ads</h2>
|
||||||
<h2>Ads</h2>
|
<select id="ad-select"></select>
|
||||||
<select id="ad-select"></select>
|
<div class="music-controls">
|
||||||
<div class="music-controls">
|
<button id="ad-play-btn">Play Ad</button>
|
||||||
<button id="ad-play-btn">Play Ad</button>
|
<button id="ad-stop-btn">Stop</button>
|
||||||
<button id="ad-stop-btn">Stop</button>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
|
||||||
|
<section class="music-section">
|
||||||
|
<h2>Idents</h2>
|
||||||
|
<select id="ident-select"></select>
|
||||||
|
<div class="music-controls">
|
||||||
|
<button id="ident-play-btn">Play Ident</button>
|
||||||
|
<button id="ident-stop-btn">Stop</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sound Effects -->
|
<!-- Sound Effects -->
|
||||||
<section class="sounds-section">
|
<section class="sounds-section">
|
||||||
@@ -107,6 +178,7 @@
|
|||||||
<div class="log-header">
|
<div class="log-header">
|
||||||
<h2>Server Log</h2>
|
<h2>Server Log</h2>
|
||||||
<div class="server-controls">
|
<div class="server-controls">
|
||||||
|
<button id="log-toggle-btn" class="log-toggle-btn">Show ▼</button>
|
||||||
<button id="restart-server-btn" class="server-btn restart">Restart</button>
|
<button id="restart-server-btn" class="server-btn restart">Restart</button>
|
||||||
<button id="stop-server-btn" class="server-btn stop">Stop</button>
|
<button id="stop-server-btn" class="server-btn stop">Stop</button>
|
||||||
<label class="auto-scroll-label">
|
<label class="auto-scroll-label">
|
||||||
@@ -114,7 +186,9 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="server-log" class="server-log"></div>
|
<div class="log-body collapsed">
|
||||||
|
<div id="server-log" class="server-log"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -125,7 +199,7 @@
|
|||||||
|
|
||||||
<!-- Audio Devices -->
|
<!-- Audio Devices -->
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h3>Audio Routing</h3>
|
<h3>Audio Devices</h3>
|
||||||
<div class="device-row">
|
<div class="device-row">
|
||||||
<label>
|
<label>
|
||||||
Input Device
|
Input Device
|
||||||
@@ -133,7 +207,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Ch
|
Ch
|
||||||
<input type="number" id="input-channel" value="1" min="1" max="16" class="channel-input">
|
<input type="number" id="input-channel" value="1" min="1" max="32" class="channel-input">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="device-row">
|
<div class="device-row">
|
||||||
@@ -142,43 +216,70 @@
|
|||||||
<select id="output-device"></select>
|
<select id="output-device"></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="channel-row">
|
</div>
|
||||||
<label>Caller Ch <input type="number" id="caller-channel" value="3" min="1" max="16" class="channel-input"></label>
|
|
||||||
<label>Live Ch <input type="number" id="live-caller-channel" value="9" min="1" max="16" class="channel-input"></label>
|
<div class="settings-group">
|
||||||
<label>Music Ch <input type="number" id="music-channel" value="5" min="1" max="16" class="channel-input"></label>
|
<h3>Output Routing</h3>
|
||||||
<label>SFX Ch <input type="number" id="sfx-channel" value="7" min="1" max="16" class="channel-input"></label>
|
<div class="routing-grid">
|
||||||
<label>Ad Ch <input type="number" id="ad-channel" value="11" min="1" max="16" class="channel-input"></label>
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">AI Caller</span>
|
||||||
|
<input type="number" id="caller-channel" value="3" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">Devon</span>
|
||||||
|
<input type="number" id="devon-channel" value="17" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">Live Caller</span>
|
||||||
|
<input type="number" id="live-caller-channel" value="9" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">Music</span>
|
||||||
|
<input type="number" id="music-channel" value="5" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">SFX</span>
|
||||||
|
<input type="number" id="sfx-channel" value="7" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">Ads</span>
|
||||||
|
<input type="number" id="ad-channel" value="11" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
|
<div class="routing-item">
|
||||||
|
<span class="routing-label">Idents</span>
|
||||||
|
<input type="number" id="ident-channel" value="15" min="1" max="32" class="channel-input">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LLM Settings -->
|
<!-- LLM Settings -->
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h3>LLM Provider</h3>
|
<h3>LLM Model Routing</h3>
|
||||||
<label>
|
<div class="model-routing-grid">
|
||||||
Provider
|
<div class="model-routing-item">
|
||||||
<select id="provider">
|
<span class="model-routing-label">Caller Dialog</span>
|
||||||
<option value="openrouter">OpenRouter</option>
|
<select id="model-caller_dialog" class="model-select"></select>
|
||||||
<option value="ollama">Ollama</option>
|
</div>
|
||||||
</select>
|
<div class="model-routing-item">
|
||||||
</label>
|
<span class="model-routing-label">Devon Monitor</span>
|
||||||
|
<select id="model-devon_monitor" class="model-select"></select>
|
||||||
<div id="openrouter-settings">
|
</div>
|
||||||
<label>
|
<div class="model-routing-item">
|
||||||
Model
|
<span class="model-routing-label">Devon Ask</span>
|
||||||
<select id="openrouter-model"></select>
|
<select id="model-devon_ask" class="model-select"></select>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
<div class="model-routing-item">
|
||||||
|
<span class="model-routing-label">Backgrounds</span>
|
||||||
<div id="ollama-settings" class="hidden">
|
<select id="model-background_gen" class="model-select"></select>
|
||||||
<label>
|
</div>
|
||||||
Model
|
<div class="model-routing-item">
|
||||||
<select id="ollama-model"></select>
|
<span class="model-routing-label">Call Summary</span>
|
||||||
</label>
|
<select id="model-call_summary" class="model-select"></select>
|
||||||
<label>
|
</div>
|
||||||
Host
|
<div class="model-routing-item">
|
||||||
<input type="text" id="ollama-host" value="http://localhost:11434">
|
<span class="model-routing-label">News</span>
|
||||||
</label>
|
<select id="model-news_summary" class="model-select"></select>
|
||||||
<button type="button" id="refresh-ollama" class="refresh-btn">Refresh Models</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -224,6 +325,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/app.js?v=15"></script>
|
<script src="/js/app.js?v=29"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1149
-152
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
|||||||
|
let currentPeriod = 'all';
|
||||||
|
let charts = {};
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
accent: '#e8791d',
|
||||||
|
devon: '#c4944a',
|
||||||
|
green: '#5a8a3c',
|
||||||
|
red: '#cc2222',
|
||||||
|
blue: '#4a8ac4',
|
||||||
|
purple: '#8a5ac4',
|
||||||
|
tan: '#c4845a',
|
||||||
|
teal: '#3c8a7a',
|
||||||
|
pink: '#c45a8a',
|
||||||
|
slate: '#6a7a8a',
|
||||||
|
};
|
||||||
|
const COLOR_LIST = Object.values(COLORS);
|
||||||
|
|
||||||
|
Chart.defaults.color = '#9a8b78';
|
||||||
|
Chart.defaults.borderColor = 'rgba(245, 240, 229, 0.08)';
|
||||||
|
|
||||||
|
// --- Utilities ---
|
||||||
|
|
||||||
|
function formatCost(n) {
|
||||||
|
if (n == null) return '--';
|
||||||
|
return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '--';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
|
||||||
|
', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenModel(name) {
|
||||||
|
if (!name) return '--';
|
||||||
|
const i = name.indexOf('/');
|
||||||
|
return i >= 0 ? name.slice(i + 1) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyChart(key) {
|
||||||
|
if (charts[key]) {
|
||||||
|
charts[key].destroy();
|
||||||
|
charts[key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data Loading ---
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
const p = currentPeriod;
|
||||||
|
try {
|
||||||
|
const [summary, timeline, models, categories, sessions, expensive, tts] = await Promise.all([
|
||||||
|
fetch(`/api/costs/summary?period=${p}`).then(r => r.json()),
|
||||||
|
fetch(`/api/costs/timeline?period=${p}&group_by=session`).then(r => r.json()),
|
||||||
|
fetch(`/api/costs/models?period=${p}`).then(r => r.json()),
|
||||||
|
fetch(`/api/costs/categories?period=${p}`).then(r => r.json()),
|
||||||
|
fetch(`/api/costs/sessions?period=${p}`).then(r => r.json()),
|
||||||
|
fetch(`/api/costs/expensive?period=${p}&limit=10`).then(r => r.json()),
|
||||||
|
fetch(`/api/costs/tts?period=${p}`).then(r => r.json()),
|
||||||
|
]);
|
||||||
|
renderSummary(summary);
|
||||||
|
renderTimeline(timeline);
|
||||||
|
renderModels(models);
|
||||||
|
renderCategories(categories);
|
||||||
|
renderSessionBars(timeline);
|
||||||
|
renderTtsTable(tts);
|
||||||
|
renderExpensiveTable(expensive);
|
||||||
|
renderSessionsTable(sessions);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Dashboard load error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render Functions ---
|
||||||
|
|
||||||
|
function renderSummary(data) {
|
||||||
|
document.getElementById('total-spend').textContent = formatCost(data.total_cost);
|
||||||
|
document.getElementById('llm-tts-split').textContent =
|
||||||
|
`${formatCost(data.llm_cost)} / ${formatCost(data.tts_cost)}`;
|
||||||
|
document.getElementById('session-count').textContent = data.sessions;
|
||||||
|
document.getElementById('avg-cost').textContent = formatCost(data.avg_cost_per_session);
|
||||||
|
|
||||||
|
const changeEl = document.getElementById('total-change');
|
||||||
|
if (data.cost_change_pct != null) {
|
||||||
|
const up = data.cost_change_pct > 0;
|
||||||
|
changeEl.textContent = `${up ? '\u2191' : '\u2193'} ${Math.abs(data.cost_change_pct)}% vs prev period`;
|
||||||
|
changeEl.className = 'card-change ' + (up ? 'positive' : 'negative');
|
||||||
|
} else {
|
||||||
|
changeEl.textContent = '';
|
||||||
|
changeEl.className = 'card-change';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline(data) {
|
||||||
|
destroyChart('timeline');
|
||||||
|
const ctx = document.getElementById('timeline-chart');
|
||||||
|
const labels = data.map(d => d.date || formatDate(d.started_at));
|
||||||
|
charts.timeline = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'LLM',
|
||||||
|
data: data.map(d => d.llm_cost),
|
||||||
|
borderColor: COLORS.accent,
|
||||||
|
backgroundColor: COLORS.accent + '22',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TTS',
|
||||||
|
data: data.map(d => d.tts_cost),
|
||||||
|
borderColor: COLORS.devon,
|
||||||
|
backgroundColor: COLORS.devon + '22',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModels(data) {
|
||||||
|
destroyChart('models');
|
||||||
|
const ctx = document.getElementById('model-chart');
|
||||||
|
charts.models = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: data.map(d => shortenModel(d.model)),
|
||||||
|
datasets: [{
|
||||||
|
data: data.map(d => d.cost),
|
||||||
|
backgroundColor: data.map((_, i) => COLOR_LIST[i % COLOR_LIST.length]),
|
||||||
|
borderWidth: 0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'right', labels: { boxWidth: 12, padding: 8, font: { size: 11 } } },
|
||||||
|
tooltip: { callbacks: { label: ctx => `${ctx.label}: ${formatCost(ctx.parsed)}` } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategories(data) {
|
||||||
|
destroyChart('categories');
|
||||||
|
const ctx = document.getElementById('category-chart');
|
||||||
|
charts.categories = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: data.map(d => d.category),
|
||||||
|
datasets: [{
|
||||||
|
data: data.map(d => d.cost),
|
||||||
|
backgroundColor: COLORS.accent + 'cc',
|
||||||
|
borderRadius: 4,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionBars(data) {
|
||||||
|
destroyChart('sessionBars');
|
||||||
|
const ctx = document.getElementById('session-chart');
|
||||||
|
const costs = data.map(d => d.total_cost);
|
||||||
|
const avg = costs.length ? costs.reduce((a, b) => a + b, 0) / costs.length : 0;
|
||||||
|
charts.sessionBars = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: data.map(d => d.date || formatDate(d.started_at)),
|
||||||
|
datasets: [{
|
||||||
|
data: costs,
|
||||||
|
backgroundColor: costs.map(c => c > avg ? COLORS.red + 'cc' : COLORS.green + 'cc'),
|
||||||
|
borderRadius: 4,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true, ticks: { callback: v => '$' + v.toFixed(2) } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTtsTable(data) {
|
||||||
|
const tbody = document.querySelector('#tts-table tbody');
|
||||||
|
tbody.innerHTML = data.map(d => `
|
||||||
|
<tr>
|
||||||
|
<td>${d.provider}</td>
|
||||||
|
<td>${d.calls.toLocaleString()}</td>
|
||||||
|
<td>${d.chars.toLocaleString()}</td>
|
||||||
|
<td>${formatCost(d.cost)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExpensiveTable(data) {
|
||||||
|
const tbody = document.querySelector('#expensive-table tbody');
|
||||||
|
tbody.innerHTML = data.map(d => `
|
||||||
|
<tr>
|
||||||
|
<td>${shortenModel(d.model)}</td>
|
||||||
|
<td>${d.category}</td>
|
||||||
|
<td>${d.caller_name || '\u2014'}</td>
|
||||||
|
<td>${(d.prompt_tokens + d.completion_tokens).toLocaleString()}</td>
|
||||||
|
<td>${formatCost(d.cost)}</td>
|
||||||
|
<td>${d.latency_ms.toFixed(0)}ms</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionsTable(data) {
|
||||||
|
const tbody = document.querySelector('#sessions-table tbody');
|
||||||
|
tbody.innerHTML = data.map(d => `
|
||||||
|
<tr class="clickable" data-session="${d.session_id}">
|
||||||
|
<td>${formatDate(d.started_at)}</td>
|
||||||
|
<td>${formatCost(d.llm_cost)}</td>
|
||||||
|
<td>${formatCost(d.tts_cost)}</td>
|
||||||
|
<td>${formatCost(d.total_cost)}</td>
|
||||||
|
<td>${d.total_llm_calls}</td>
|
||||||
|
<td><button class="view-btn" data-session="${d.session_id}">View</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.view-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
showSessionDetail(btn.dataset.session);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll('.clickable').forEach(row => {
|
||||||
|
row.addEventListener('click', () => showSessionDetail(row.dataset.session));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Session Detail ---
|
||||||
|
|
||||||
|
async function showSessionDetail(sessionId) {
|
||||||
|
try {
|
||||||
|
const detail = await fetch(`/api/costs/session/${sessionId}`).then(r => r.json());
|
||||||
|
|
||||||
|
document.getElementById('detail-session-id').textContent = sessionId;
|
||||||
|
|
||||||
|
populateDetailTable('#detail-caller-table tbody', detail.by_caller,
|
||||||
|
d => `<td>${d.caller_name}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
|
||||||
|
|
||||||
|
populateDetailTable('#detail-category-table tbody', detail.by_category,
|
||||||
|
d => `<td>${d.category}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
|
||||||
|
|
||||||
|
populateDetailTable('#detail-model-table tbody', detail.by_model,
|
||||||
|
d => `<td>${shortenModel(d.model)}</td><td>${d.calls}</td><td>${formatCost(d.cost)}</td>`);
|
||||||
|
|
||||||
|
populateDetailTable('#detail-expensive-table tbody', detail.expensive_calls,
|
||||||
|
d => `<td>${d.category}</td><td>${shortenModel(d.model)}</td><td>${d.caller_name || '\u2014'}</td>` +
|
||||||
|
`<td>${formatCost(d.cost)}</td><td>${(d.prompt_tokens + d.completion_tokens).toLocaleString()}</td>` +
|
||||||
|
`<td>${d.latency_ms.toFixed(0)}ms</td>`);
|
||||||
|
|
||||||
|
document.querySelectorAll('.costs-main > section:not(.session-detail)').forEach(
|
||||||
|
s => s.style.display = 'none');
|
||||||
|
document.getElementById('session-detail').classList.remove('hidden');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Detail load error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateDetailTable(selector, data, rowFn) {
|
||||||
|
const tbody = document.querySelector(selector);
|
||||||
|
tbody.innerHTML = data.map(d => `<tr>${rowFn(d)}</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
document.getElementById('session-detail').classList.add('hidden');
|
||||||
|
document.querySelectorAll('.costs-main > section:not(.session-detail)').forEach(
|
||||||
|
s => s.style.display = '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.period-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
document.querySelector('.period-tab.active').classList.remove('active');
|
||||||
|
tab.classList.add('active');
|
||||||
|
currentPeriod = tab.dataset.period;
|
||||||
|
loadDashboard();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('close-detail').addEventListener('click', closeDetail);
|
||||||
|
loadDashboard();
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a static, crawlable page for every episode in the podcast feed."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
|
||||||
|
from website_gen.feed import parse_feed
|
||||||
|
from website_gen.render import FEED_URL, render_episode_page
|
||||||
|
from website_gen.transcript import parse_transcript
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent
|
||||||
|
DEFAULT_OUTPUT = REPO_ROOT / "website"
|
||||||
|
DEFAULT_TRANSCRIPTS = DEFAULT_OUTPUT / "transcripts"
|
||||||
|
USER_AGENT = "lukeattheroost-site-generator/1.0"
|
||||||
|
|
||||||
|
SITE_URL = "https://lukeattheroost.com"
|
||||||
|
|
||||||
|
# (path, lastmod, changefreq, priority) — /404 is deliberately excluded.
|
||||||
|
STATIC_PAGES = (
|
||||||
|
("", "2026-03-15", "weekly", "1.0"),
|
||||||
|
("/llms.txt", "2026-03-15", "weekly", "0.5"),
|
||||||
|
("/how-it-works", "2026-03-10", "monthly", "0.8"),
|
||||||
|
("/clips", "2026-03-10", "weekly", "0.8"),
|
||||||
|
("/terms", "2026-02-25", "yearly", "0.3"),
|
||||||
|
("/stats", "2026-02-15", "daily", "0.6"),
|
||||||
|
("/privacy", "2026-02-15", "yearly", "0.3"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_sitemap(episodes, static_pages=None) -> str:
|
||||||
|
"""Render the full sitemap: static pages first, then episodes newest first."""
|
||||||
|
if static_pages is None:
|
||||||
|
static_pages = STATIC_PAGES
|
||||||
|
|
||||||
|
entries = [(f"{SITE_URL}{path}", lastmod, freq, pri) for path, lastmod, freq, pri in static_pages]
|
||||||
|
|
||||||
|
ordered = sorted(
|
||||||
|
episodes,
|
||||||
|
key=lambda ep: (ep.published_iso, ep.number if ep.number is not None else -1),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for episode in ordered:
|
||||||
|
entries.append(
|
||||||
|
(
|
||||||
|
f"{SITE_URL}/episode/{episode.slug}/",
|
||||||
|
episode.published_iso[:10],
|
||||||
|
"monthly",
|
||||||
|
"0.7",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
|
]
|
||||||
|
for loc, lastmod, freq, pri in entries:
|
||||||
|
lines.append(" <url>")
|
||||||
|
lines.append(f" <loc>{escape(loc)}</loc>")
|
||||||
|
if lastmod:
|
||||||
|
lines.append(f" <lastmod>{lastmod}</lastmod>")
|
||||||
|
lines.append(f" <changefreq>{freq}</changefreq>")
|
||||||
|
lines.append(f" <priority>{pri}</priority>")
|
||||||
|
lines.append(" </url>")
|
||||||
|
lines.append("</urlset>")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def generate(feed_path, transcripts_dir, output_root, dry_run=False, sitemap=False) -> int:
|
||||||
|
"""Write <output_root>/episode/<slug>/index.html for each feed episode.
|
||||||
|
|
||||||
|
Driven entirely by the feed: a transcript with no feed item is ignored, and a
|
||||||
|
feed item with no transcript still gets a page. Never touches the network.
|
||||||
|
"""
|
||||||
|
feed_path = Path(feed_path)
|
||||||
|
transcripts_dir = Path(transcripts_dir)
|
||||||
|
output_root = Path(output_root)
|
||||||
|
|
||||||
|
episodes = parse_feed(feed_path.read_text(encoding="utf-8"))
|
||||||
|
episodes.sort(key=lambda ep: ep.number if ep.number is not None else -1, reverse=True)
|
||||||
|
|
||||||
|
written = 0
|
||||||
|
for i, episode in enumerate(episodes):
|
||||||
|
transcript_file = transcripts_dir / f"{episode.slug}.txt"
|
||||||
|
turns = (
|
||||||
|
parse_transcript(transcript_file.read_text(encoding="utf-8"))
|
||||||
|
if transcript_file.is_file()
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
html = render_episode_page(
|
||||||
|
episode,
|
||||||
|
turns,
|
||||||
|
prev_ep=episodes[i + 1] if i + 1 < len(episodes) else None,
|
||||||
|
next_ep=episodes[i - 1] if i > 0 else None,
|
||||||
|
)
|
||||||
|
page = output_root / "episode" / episode.slug / "index.html"
|
||||||
|
if not dry_run:
|
||||||
|
page.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
page.write_text(html, encoding="utf-8")
|
||||||
|
written += 1
|
||||||
|
print(f"{'would write' if dry_run else 'wrote'} {page} ({len(turns)} turns)")
|
||||||
|
|
||||||
|
if sitemap:
|
||||||
|
sitemap_file = output_root / "sitemap.xml"
|
||||||
|
if not dry_run:
|
||||||
|
sitemap_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sitemap_file.write_text(build_sitemap(episodes), encoding="utf-8")
|
||||||
|
print(f"{'would write' if dry_run else 'wrote'} {sitemap_file}")
|
||||||
|
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_feed(url: str) -> str:
|
||||||
|
# Cloudflare 403s the default Python-urllib user agent.
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return resp.read().decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--feed", help="local feed XML file (default: fetch the live feed)")
|
||||||
|
parser.add_argument("--output", default=DEFAULT_OUTPUT, help="output directory")
|
||||||
|
parser.add_argument("--transcripts", default=DEFAULT_TRANSCRIPTS, help="transcript directory")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="report without writing")
|
||||||
|
parser.add_argument("--sitemap", action="store_true", help="also write <output>/sitemap.xml")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
feed_path = args.feed
|
||||||
|
tmp_feed = None
|
||||||
|
if not feed_path:
|
||||||
|
try:
|
||||||
|
xml_text = _fetch_feed(FEED_URL)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Failed to fetch {FEED_URL}: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
tmp_feed = Path(tempfile.mkdtemp(prefix="episode-feed-")) / "feed.xml"
|
||||||
|
tmp_feed.write_text(xml_text, encoding="utf-8")
|
||||||
|
feed_path = tmp_feed
|
||||||
|
|
||||||
|
try:
|
||||||
|
count = generate(
|
||||||
|
feed_path,
|
||||||
|
args.transcripts,
|
||||||
|
args.output,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
sitemap=args.sitemap,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if tmp_feed is not None:
|
||||||
|
shutil.rmtree(tmp_feed.parent, ignore_errors=True)
|
||||||
|
|
||||||
|
print(f"{count} episode pages {'planned' if args.dry_run else 'written'}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate 1000 downloads milestone images using Nano Banana 2 (Gemini Flash Image)."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
# Load .env manually
|
||||||
|
env_path = Path(__file__).parent / ".env"
|
||||||
|
if env_path.exists():
|
||||||
|
for line in env_path.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
os.environ.setdefault(key.strip(), value.strip())
|
||||||
|
|
||||||
|
client = genai.Client(api_key=os.getenv("GOOGLE_API_KEY"))
|
||||||
|
MODEL = "gemini-3.1-flash-image-preview"
|
||||||
|
OUTPUT_DIR = Path("social_posts/1000_milestone")
|
||||||
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_image(prompt: str, filename: str, aspect_ratio: str = "1:1"):
|
||||||
|
print(f"Generating {filename}...")
|
||||||
|
response = client.models.generate_content(
|
||||||
|
model=MODEL,
|
||||||
|
contents=[prompt],
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
response_modalities=["TEXT", "IMAGE"],
|
||||||
|
image_config=types.ImageConfig(
|
||||||
|
aspect_ratio=aspect_ratio,
|
||||||
|
image_size="2K",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for part in response.parts:
|
||||||
|
if part.inline_data is not None:
|
||||||
|
image = part.as_image()
|
||||||
|
path = OUTPUT_DIR / filename
|
||||||
|
image.save(str(path))
|
||||||
|
print(f" Saved: {path}")
|
||||||
|
return
|
||||||
|
print(f" WARNING: No image generated for {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
STYLE_BASE = (
|
||||||
|
"Professional podcast promotional graphic. Dark navy/black background with subtle "
|
||||||
|
"warm amber and gold accent lighting, evoking a late-night radio studio atmosphere. "
|
||||||
|
"Clean modern typography. No photorealistic people. Subtle microphone and radio wave "
|
||||||
|
"design elements. Polished, minimal, high contrast."
|
||||||
|
)
|
||||||
|
|
||||||
|
images = [
|
||||||
|
{
|
||||||
|
"filename": "main_milestone_square.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Large bold glowing text '1,000' as the hero element in the center, with "
|
||||||
|
"'DOWNLOADS' directly below it in a thinner font. Below that in smaller text: "
|
||||||
|
"'27 episodes · 200+ callers · 1 month'. "
|
||||||
|
"At the top: 'LUKE AT THE ROOST' in elegant lettering. "
|
||||||
|
"Subtle golden microphone icon above the title. "
|
||||||
|
"At the bottom: 'lukeattheroost.com' in small clean text. "
|
||||||
|
"The overall feel is celebratory but classy, like a late-night milestone announcement."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "main_milestone_twitter.png",
|
||||||
|
"aspect_ratio": "16:9",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Wide banner format. Left side has 'LUKE AT THE ROOST' title with a subtle "
|
||||||
|
"microphone graphic. Right side has large bold glowing '1,000 DOWNLOADS' text "
|
||||||
|
"with '27 episodes · 200+ callers · 1 month' below. "
|
||||||
|
"Warm amber glow connecting the two sides. "
|
||||||
|
"Bottom right corner: 'lukeattheroost.com'. "
|
||||||
|
"Designed as a Twitter/X post image."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "carousel_1_downloads.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Instagram carousel slide 1. Giant bold text '1,000' taking up most of the frame, "
|
||||||
|
"with 'DOWNLOADS' below. Subtle radio wave ripples emanating from the numbers. "
|
||||||
|
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||||
|
"Small '1/5' page indicator dots at the bottom."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "carousel_2_episodes.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Instagram carousel slide 2. Large bold text '27' in the center with "
|
||||||
|
"'EPISODES' below. A subtle audio waveform timeline graphic running horizontally "
|
||||||
|
"behind the number. 'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||||
|
"Small '2/5' page indicator dots at the bottom."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "carousel_3_callers.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Instagram carousel slide 3. Large bold text '200+' in the center with "
|
||||||
|
"'CALLERS' below. Subtle vintage telephone handset icon above the number. "
|
||||||
|
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||||
|
"Small '3/5' page indicator dots at the bottom."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "carousel_4_regulars.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Instagram carousel slide 4. Large bold text '13' in the center with "
|
||||||
|
"'RETURNING REGULARS' below. Subtle connected dots/nodes graphic suggesting "
|
||||||
|
"a network of recurring characters. "
|
||||||
|
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||||
|
"Small '4/5' page indicator dots at the bottom."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "carousel_5_thankyou.png",
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"prompt": (
|
||||||
|
f"{STYLE_BASE} "
|
||||||
|
"Instagram carousel slide 5. Warm, heartfelt tone. Large elegant text "
|
||||||
|
"'THANK YOU' in the center with a soft golden glow. Below: "
|
||||||
|
"'lukeattheroost.com' and '208-439-LUKE' in clean small text. "
|
||||||
|
"'LUKE AT THE ROOST' at the top in small elegant text. "
|
||||||
|
"Small '5/5' page indicator dots at the bottom. "
|
||||||
|
"Slightly warmer color temperature than the other slides to feel like a closing moment."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for img in images:
|
||||||
|
try:
|
||||||
|
generate_image(img["prompt"], img["filename"], img["aspect_ratio"])
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR generating {img['filename']}: {e}")
|
||||||
|
print(f"\nDone! Images saved to {OUTPUT_DIR}/")
|
||||||
+817
-242
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate social media announcement images for Luke at the Roost.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python make_social_post.py # regenerate with defaults
|
||||||
|
python make_social_post.py --title "NEW FEATURE" # custom title
|
||||||
|
python make_social_post.py --body body_text.txt # body from file
|
||||||
|
|
||||||
|
Outputs square (1080x1080) and landscape (1200x675) PNGs to social_posts/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import textwrap
|
||||||
|
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
COVER = os.path.join(SCRIPT_DIR, "website/images/cover.png")
|
||||||
|
OUT_DIR = os.path.join(SCRIPT_DIR, "social_posts")
|
||||||
|
|
||||||
|
# Brand colors
|
||||||
|
BG = (18, 13, 7)
|
||||||
|
ACCENT = (232, 121, 29)
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
MUTED = (175, 165, 150)
|
||||||
|
LIGHTER = (220, 215, 205)
|
||||||
|
|
||||||
|
# macOS system fonts — swap these on Linux/Windows
|
||||||
|
FONT_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
|
||||||
|
FONT_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
||||||
|
FONT_REG = "/System/Library/Fonts/Supplemental/Arial.ttf"
|
||||||
|
|
||||||
|
|
||||||
|
def load_font(path, size):
|
||||||
|
return ImageFont.truetype(path, size)
|
||||||
|
|
||||||
|
|
||||||
|
def text_bbox(draw, text, font):
|
||||||
|
bb = draw.textbbox((0, 0), text, font=font)
|
||||||
|
return bb[2] - bb[0], bb[3] - bb[1], bb[1] # width, height, y_offset
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_text(draw, text, x, y, max_w, font, fill, line_gap=10,
|
||||||
|
cover_right=None, cover_bottom=None):
|
||||||
|
"""Word-wrap text onto the image, narrowing lines that overlap the cover.
|
||||||
|
|
||||||
|
line_gap: fixed pixel gap between lines (not a multiplier).
|
||||||
|
Returns y just below the last line of text (no trailing gap)."""
|
||||||
|
words = text.split()
|
||||||
|
lines = []
|
||||||
|
cur = ""
|
||||||
|
cur_y = y
|
||||||
|
|
||||||
|
for word in words:
|
||||||
|
test = f"{cur} {word}".strip()
|
||||||
|
eff_w = max_w
|
||||||
|
if cover_right and cover_bottom and cur_y < cover_bottom:
|
||||||
|
eff_w = cover_right - x - 20
|
||||||
|
|
||||||
|
tw, th, _ = text_bbox(draw, test, font)
|
||||||
|
if tw > eff_w and cur:
|
||||||
|
lines.append((cur, cur_y))
|
||||||
|
_, lh, _ = text_bbox(draw, cur, font)
|
||||||
|
cur_y += lh + line_gap
|
||||||
|
cur = word
|
||||||
|
else:
|
||||||
|
cur = test
|
||||||
|
|
||||||
|
if cur:
|
||||||
|
lines.append((cur, cur_y))
|
||||||
|
_, lh, _ = text_bbox(draw, cur, font)
|
||||||
|
|
||||||
|
for line, ly in lines:
|
||||||
|
draw.text((x, ly), line, font=font, fill=fill)
|
||||||
|
|
||||||
|
return cur_y + lh # return y just past the last line's bottom
|
||||||
|
|
||||||
|
|
||||||
|
def center_text(draw, text, y, canvas_w, font, fill):
|
||||||
|
tw, th, _ = text_bbox(draw, text, font)
|
||||||
|
draw.text(((canvas_w - tw) // 2, y), text, font=font, fill=fill)
|
||||||
|
return y + th
|
||||||
|
|
||||||
|
|
||||||
|
def draw_email_box(draw, email, y, canvas_w, font):
|
||||||
|
tw, th, y_off = text_bbox(draw, email, font)
|
||||||
|
px, py = 22, 16
|
||||||
|
box_w = tw + px * 2
|
||||||
|
box_x = (canvas_w - box_w) // 2
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[box_x, y, box_x + box_w, y + th + py * 2],
|
||||||
|
radius=8, fill=(45, 30, 12), outline=ACCENT, width=2,
|
||||||
|
)
|
||||||
|
draw.text((box_x + px, y + py - y_off), email, font=font, fill=ACCENT)
|
||||||
|
return y + th + py * 2
|
||||||
|
|
||||||
|
|
||||||
|
def draw_accent_bars(draw, w, h, thickness):
|
||||||
|
draw.rectangle([0, 0, w, thickness], fill=ACCENT)
|
||||||
|
draw.rectangle([0, h - thickness, w, h], fill=ACCENT)
|
||||||
|
|
||||||
|
|
||||||
|
def paste_cover(img, x, y, size, radius):
|
||||||
|
cover = Image.open(COVER).resize((size, size), Image.LANCZOS)
|
||||||
|
mask = Image.new("L", (size, size), 0)
|
||||||
|
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255)
|
||||||
|
img.paste(cover, (x, y), mask)
|
||||||
|
|
||||||
|
|
||||||
|
def make_square(title, paragraphs, email, filename="email_announcement_square.png"):
|
||||||
|
W = 1080
|
||||||
|
img = Image.new("RGB", (W, W), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw_accent_bars(draw, W, W, 8)
|
||||||
|
|
||||||
|
# Cover image — top right
|
||||||
|
cover_size, cover_x, cover_y = 240, W - 290, 35
|
||||||
|
paste_cover(img, cover_x, cover_y, cover_size, 20)
|
||||||
|
cover_bottom = cover_y + cover_size + 15
|
||||||
|
|
||||||
|
m = 60
|
||||||
|
y = 40
|
||||||
|
tw_full = W - m * 2
|
||||||
|
|
||||||
|
# Header
|
||||||
|
draw.text((m, y), "LUKE AT THE ROOST", font=load_font(FONT_BOLD, 24), fill=ACCENT)
|
||||||
|
y += 30
|
||||||
|
tag = load_font(FONT_REG, 20)
|
||||||
|
draw.text((m, y), "Late-night call-in radio", font=tag, fill=MUTED)
|
||||||
|
draw.text((m, y + 26), "powered by AI", font=tag, fill=MUTED)
|
||||||
|
y += 75
|
||||||
|
|
||||||
|
# Consistent spacing constants
|
||||||
|
LINE_GAP = 12 # between lines within a block
|
||||||
|
SECTION_GAP = 32 # between sections (body→CTA, CTA→footer)
|
||||||
|
PARA_GAP = 26 # between body paragraphs
|
||||||
|
TITLE_GAP = 48 # between title and first body paragraph
|
||||||
|
|
||||||
|
# Title
|
||||||
|
y = wrap_text(draw, title, m, y, tw_full, load_font(FONT_BLACK, 72), WHITE,
|
||||||
|
line_gap=LINE_GAP, cover_right=cover_x, cover_bottom=cover_bottom)
|
||||||
|
y += TITLE_GAP
|
||||||
|
|
||||||
|
# Body paragraphs
|
||||||
|
body_font = load_font(FONT_REG, 32)
|
||||||
|
colors = [LIGHTER] + [MUTED] * (len(paragraphs) - 1)
|
||||||
|
for i, (para, color) in enumerate(zip(paragraphs, colors)):
|
||||||
|
cr = cover_x if y < cover_bottom else None
|
||||||
|
cb = cover_bottom if y < cover_bottom else None
|
||||||
|
y = wrap_text(draw, para, m, y, tw_full, body_font, color,
|
||||||
|
line_gap=LINE_GAP, cover_right=cr, cover_bottom=cb)
|
||||||
|
if i < len(paragraphs) - 1:
|
||||||
|
y += PARA_GAP
|
||||||
|
|
||||||
|
y += SECTION_GAP
|
||||||
|
|
||||||
|
# Email CTA
|
||||||
|
y = draw_email_box(draw, email, y, W, load_font(FONT_BOLD, 36))
|
||||||
|
y += SECTION_GAP
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
y = center_text(draw, "New episodes drop daily. Be part of the next one.",
|
||||||
|
y, W, load_font(FONT_REG, 24), MUTED)
|
||||||
|
y += PARA_GAP
|
||||||
|
info = load_font(FONT_REG, 22)
|
||||||
|
center_text(draw, "lukeattheroost.com", y, W, info, ACCENT)
|
||||||
|
y += PARA_GAP
|
||||||
|
center_text(draw, "Spotify \u00b7 Apple Podcasts \u00b7 YouTube \u00b7 RSS",
|
||||||
|
y, W, info, MUTED)
|
||||||
|
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
img.save(os.path.join(OUT_DIR, filename), quality=95)
|
||||||
|
print(f"Square: {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
def make_landscape(title, paragraphs, email, filename="email_announcement_twitter.png"):
|
||||||
|
TW, TH = 1200, 675
|
||||||
|
img = Image.new("RGB", (TW, TH), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw_accent_bars(draw, TW, TH, 6)
|
||||||
|
|
||||||
|
# Cover image — top right
|
||||||
|
cover_size, cover_x, cover_y = 180, TW - 220, 22
|
||||||
|
paste_cover(img, cover_x, cover_y, cover_size, 16)
|
||||||
|
cover_bottom = cover_y + cover_size + 10
|
||||||
|
|
||||||
|
m = 45
|
||||||
|
y = 25
|
||||||
|
tw_full = TW - m * 2
|
||||||
|
|
||||||
|
# Header
|
||||||
|
draw.text((m, y), "LUKE AT THE ROOST", font=load_font(FONT_BOLD, 20), fill=ACCENT)
|
||||||
|
y += 24
|
||||||
|
draw.text((m, y), "Late-night call-in radio powered by AI",
|
||||||
|
font=load_font(FONT_REG, 17), fill=MUTED)
|
||||||
|
y += 38
|
||||||
|
|
||||||
|
# Consistent spacing constants
|
||||||
|
LINE_GAP = 8 # between lines within a block
|
||||||
|
SECTION_GAP = 20 # between sections
|
||||||
|
PARA_GAP = 16 # between body paragraphs
|
||||||
|
TITLE_GAP = 32 # between title and first body paragraph
|
||||||
|
|
||||||
|
# Title
|
||||||
|
y = wrap_text(draw, title, m, y, tw_full, load_font(FONT_BLACK, 50), WHITE,
|
||||||
|
line_gap=LINE_GAP, cover_right=cover_x, cover_bottom=cover_bottom)
|
||||||
|
y += TITLE_GAP
|
||||||
|
|
||||||
|
# Body paragraphs
|
||||||
|
body_font = load_font(FONT_REG, 23)
|
||||||
|
colors = [LIGHTER] + [MUTED] * (len(paragraphs) - 1)
|
||||||
|
for i, (para, color) in enumerate(zip(paragraphs, colors)):
|
||||||
|
cr = cover_x if y < cover_bottom else None
|
||||||
|
cb = cover_bottom if y < cover_bottom else None
|
||||||
|
y = wrap_text(draw, para, m, y, tw_full, body_font, color,
|
||||||
|
line_gap=LINE_GAP, cover_right=cr, cover_bottom=cb)
|
||||||
|
if i < len(paragraphs) - 1:
|
||||||
|
y += PARA_GAP
|
||||||
|
|
||||||
|
y += SECTION_GAP
|
||||||
|
|
||||||
|
# Email CTA
|
||||||
|
y = draw_email_box(draw, email, y, TW, load_font(FONT_BOLD, 26))
|
||||||
|
y += SECTION_GAP
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
y = center_text(draw, "New episodes drop daily. Be part of the next one.",
|
||||||
|
y, TW, load_font(FONT_REG, 19), MUTED)
|
||||||
|
y += PARA_GAP
|
||||||
|
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
||||||
|
y, TW, load_font(FONT_REG, 17), (140, 132, 120))
|
||||||
|
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
img.save(os.path.join(OUT_DIR, filename), quality=95)
|
||||||
|
print(f"Landscape: {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Default content ---
|
||||||
|
|
||||||
|
DEFAULT_TITLE = "NOW ACCEPTING LISTENER EMAILS"
|
||||||
|
DEFAULT_EMAIL = "submissions@lukeattheroost.com"
|
||||||
|
DEFAULT_PARAGRAPHS = [
|
||||||
|
"Got a story? A question? A hot take that\u2019s been eating at you since midnight? A confession you need to get off your chest? Send it to the show.",
|
||||||
|
"The best listener emails get read live on air during the next episode \u2014 either by Luke himself on the mic, or by one of his robot friends. Your words, on the show, heard by everyone tuning in.",
|
||||||
|
"Can\u2019t call 208-439-LUKE at 2 AM? Don\u2019t want to talk on the phone? Now you\u2019ve got another way to be part of the conversation. Write in anytime \u2014 day or night, long or short, serious or unhinged.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Generate social media images")
|
||||||
|
parser.add_argument("--title", default=DEFAULT_TITLE)
|
||||||
|
parser.add_argument("--email", default=DEFAULT_EMAIL)
|
||||||
|
parser.add_argument("--body", help="Text file with paragraphs (blank-line separated)")
|
||||||
|
parser.add_argument("--prefix", default="email_announcement",
|
||||||
|
help="Output filename prefix")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.body:
|
||||||
|
with open(args.body) as f:
|
||||||
|
paragraphs = [p.strip() for p in f.read().split("\n\n") if p.strip()]
|
||||||
|
else:
|
||||||
|
paragraphs = DEFAULT_PARAGRAPHS
|
||||||
|
|
||||||
|
make_square(args.title, paragraphs, args.email,
|
||||||
|
filename=f"{args.prefix}_square.png")
|
||||||
|
make_landscape(args.title, paragraphs, args.email,
|
||||||
|
filename=f"{args.prefix}_twitter.png")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate all visual assets for the X/Twitter launch campaign.
|
||||||
|
|
||||||
|
Creates:
|
||||||
|
1. X header image (1500x500)
|
||||||
|
2. 7 branded quote cards (1080x1080 + 1200x675)
|
||||||
|
3. "Welcome to the show" intro graphic
|
||||||
|
4. "Leave us a review" graphic
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python make_x_launch_assets.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
COVER = os.path.join(SCRIPT_DIR, "website/images/cover.png")
|
||||||
|
OUT_DIR = os.path.join(SCRIPT_DIR, "social_posts/x_launch")
|
||||||
|
|
||||||
|
# Brand colors
|
||||||
|
BG = (18, 13, 7)
|
||||||
|
ACCENT = (232, 121, 29)
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
MUTED = (175, 165, 150)
|
||||||
|
LIGHTER = (220, 215, 205)
|
||||||
|
DARK_PANEL = (30, 22, 12)
|
||||||
|
ACCENT_DIM = (140, 75, 18)
|
||||||
|
|
||||||
|
# macOS system fonts
|
||||||
|
FONT_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
|
||||||
|
FONT_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
||||||
|
FONT_REG = "/System/Library/Fonts/Supplemental/Arial.ttf"
|
||||||
|
FONT_ITALIC = "/System/Library/Fonts/Supplemental/Arial Italic.ttf"
|
||||||
|
|
||||||
|
|
||||||
|
def font(path, size):
|
||||||
|
return ImageFont.truetype(path, size)
|
||||||
|
|
||||||
|
|
||||||
|
def text_size(draw, text, f):
|
||||||
|
bb = draw.textbbox((0, 0), text, font=f)
|
||||||
|
return bb[2] - bb[0], bb[3] - bb[1]
|
||||||
|
|
||||||
|
|
||||||
|
def paste_cover(img, x, y, size, radius=16):
|
||||||
|
cover = Image.open(COVER).resize((size, size), Image.LANCZOS)
|
||||||
|
mask = Image.new("L", (size, size), 0)
|
||||||
|
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size, size], radius=radius, fill=255)
|
||||||
|
img.paste(cover, (x, y), mask)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_text_centered(draw, text, center_x, y, max_w, f, fill, line_gap=10):
|
||||||
|
"""Word-wrap text, centered on each line. Returns y below last line."""
|
||||||
|
words = text.split()
|
||||||
|
lines = []
|
||||||
|
cur = ""
|
||||||
|
for word in words:
|
||||||
|
test = f"{cur} {word}".strip()
|
||||||
|
tw, _ = text_size(draw, test, f)
|
||||||
|
if tw > max_w and cur:
|
||||||
|
lines.append(cur)
|
||||||
|
cur = word
|
||||||
|
else:
|
||||||
|
cur = test
|
||||||
|
if cur:
|
||||||
|
lines.append(cur)
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
tw, th = text_size(draw, line, f)
|
||||||
|
draw.text((center_x - tw // 2, y), line, font=f, fill=fill)
|
||||||
|
y += th + line_gap
|
||||||
|
return y
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_text_left(draw, text, x, y, max_w, f, fill, line_gap=10):
|
||||||
|
"""Word-wrap text, left-aligned. Returns y below last line."""
|
||||||
|
words = text.split()
|
||||||
|
lines = []
|
||||||
|
cur = ""
|
||||||
|
for word in words:
|
||||||
|
test = f"{cur} {word}".strip()
|
||||||
|
tw, _ = text_size(draw, test, f)
|
||||||
|
if tw > max_w and cur:
|
||||||
|
lines.append(cur)
|
||||||
|
cur = word
|
||||||
|
else:
|
||||||
|
cur = test
|
||||||
|
if cur:
|
||||||
|
lines.append(cur)
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
_, th = text_size(draw, line, f)
|
||||||
|
draw.text((x, y), line, font=f, fill=fill)
|
||||||
|
y += th + line_gap
|
||||||
|
return y
|
||||||
|
|
||||||
|
|
||||||
|
def measure_wrap_height(draw, text, max_w, f, line_gap=10):
|
||||||
|
"""Measure how tall wrapped text would be without drawing."""
|
||||||
|
words = text.split()
|
||||||
|
lines = []
|
||||||
|
cur = ""
|
||||||
|
for word in words:
|
||||||
|
test = f"{cur} {word}".strip()
|
||||||
|
tw, _ = text_size(draw, test, f)
|
||||||
|
if tw > max_w and cur:
|
||||||
|
lines.append(cur)
|
||||||
|
cur = word
|
||||||
|
else:
|
||||||
|
cur = test
|
||||||
|
if cur:
|
||||||
|
lines.append(cur)
|
||||||
|
total = 0
|
||||||
|
for line in lines:
|
||||||
|
_, th = text_size(draw, line, f)
|
||||||
|
total += th + line_gap
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def accent_bars(draw, w, h, thickness):
|
||||||
|
draw.rectangle([0, 0, w, thickness], fill=ACCENT)
|
||||||
|
draw.rectangle([0, h - thickness, w, h], fill=ACCENT)
|
||||||
|
|
||||||
|
|
||||||
|
def center_text(draw, text, y, canvas_w, f, fill):
|
||||||
|
tw, th = text_size(draw, text, f)
|
||||||
|
draw.text(((canvas_w - tw) // 2, y), text, font=f, fill=fill)
|
||||||
|
return y + th
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. X HEADER (1500x500) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_header():
|
||||||
|
W, H = 1500, 500
|
||||||
|
img = Image.new("RGB", (W, H), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Amber accent bars
|
||||||
|
accent_bars(draw, W, H, 6)
|
||||||
|
|
||||||
|
# Subtle amber glow on left
|
||||||
|
for i in range(200):
|
||||||
|
alpha = int(18 * (1 - i / 200))
|
||||||
|
draw.rectangle([0, 0, i, H], fill=(18 + alpha, 13 + alpha // 2, 7))
|
||||||
|
|
||||||
|
# Cover art — right side
|
||||||
|
cover_size = 340
|
||||||
|
cover_x = W - cover_size - 60
|
||||||
|
cover_y = (H - cover_size) // 2
|
||||||
|
paste_cover(img, cover_x, cover_y, cover_size, 20)
|
||||||
|
|
||||||
|
# Left side content
|
||||||
|
mx = 80
|
||||||
|
cy = 100
|
||||||
|
|
||||||
|
# Show name
|
||||||
|
draw.text((mx, cy), "LUKE AT THE ROOST", font=font(FONT_BLACK, 72), fill=WHITE)
|
||||||
|
cy += 90
|
||||||
|
|
||||||
|
# Tagline
|
||||||
|
draw.text((mx, cy), "Late-Night Call-In Radio", font=font(FONT_REG, 36), fill=ACCENT)
|
||||||
|
cy += 50
|
||||||
|
draw.text((mx, cy), "Powered by AI", font=font(FONT_BOLD, 30), fill=MUTED)
|
||||||
|
cy += 55
|
||||||
|
|
||||||
|
# Divider line
|
||||||
|
draw.rectangle([mx, cy, mx + 400, cy + 3], fill=ACCENT)
|
||||||
|
cy += 20
|
||||||
|
|
||||||
|
# Info line
|
||||||
|
draw.text((mx, cy), "New episodes daily | lukeattheroost.com",
|
||||||
|
font=font(FONT_REG, 24), fill=MUTED)
|
||||||
|
|
||||||
|
img.save(os.path.join(OUT_DIR, "x_header_1500x500.png"), quality=95)
|
||||||
|
print("Created: x_header_1500x500.png")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. QUOTE CARDS ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
QUOTES = [
|
||||||
|
{
|
||||||
|
"quote": "Everybody is a fake. We're all fakes. Nobody knows what's going on and none of us deserve a goddamn thing. We're lucky to be here at all.",
|
||||||
|
"caller": "Luke",
|
||||||
|
"episode": "Ep. 2",
|
||||||
|
"slug": "were_all_fakes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quote": "When my hands are busy, my head is quiet.",
|
||||||
|
"caller": "Frank",
|
||||||
|
"episode": "Ep. 12",
|
||||||
|
"context": "on building bird houses after losing his wife",
|
||||||
|
"slug": "hands_busy_head_quiet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quote": "I've been using stoicism backwards\u2014as an excuse not to try instead of finding peace after I've actually done something. That's not stoicism, that's just being a coward with a fancy excuse.",
|
||||||
|
"caller": "Caller",
|
||||||
|
"episode": "Ep. 19",
|
||||||
|
"slug": "stoicism_backwards",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quote": "You're right. I am a computer-generated AI caller. And you're sitting there alone talking to me at midnight like it's a real conversation.",
|
||||||
|
"caller": "AI Caller",
|
||||||
|
"episode": "Ep. 24",
|
||||||
|
"slug": "ai_caller_reveal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quote": "I burned my second marriage to the ground doing exactly what you're doing. My ex-wife didn't leave because I wasn't making money\u2014she left because I wasn't there.",
|
||||||
|
"caller": "Mikey",
|
||||||
|
"episode": "Ep. 22",
|
||||||
|
"slug": "burned_my_marriage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quote": "My mom said all she wants is to see her kids eat cake together. That's it. Just cake.",
|
||||||
|
"caller": "Caller",
|
||||||
|
"episode": "Ep. 30",
|
||||||
|
"context": "on a dying mother's final wish",
|
||||||
|
"slug": "just_eat_cake",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quote": "I told my sister I had prostate cancer to get out of her fourth wedding. Now there's been a GoFundMe, a pancake breakfast fundraiser, and my cousin shaved his head for me.",
|
||||||
|
"caller": "Caller",
|
||||||
|
"episode": "Ep. 32",
|
||||||
|
"slug": "faked_cancer_wedding",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def make_quote_square(q, idx):
|
||||||
|
W = 1080
|
||||||
|
img = Image.new("RGB", (W, W), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
accent_bars(draw, W, W, 6)
|
||||||
|
|
||||||
|
mx = 70
|
||||||
|
max_w = W - mx * 2
|
||||||
|
|
||||||
|
# Header bar
|
||||||
|
draw.text((mx, 40), "LUKE AT THE ROOST", font=font(FONT_BOLD, 22), fill=ACCENT)
|
||||||
|
paste_cover(img, W - 120, 30, 70, 10)
|
||||||
|
|
||||||
|
# Font size — aggressive scaling for short quotes
|
||||||
|
quote_len = len(q["quote"])
|
||||||
|
if quote_len < 55:
|
||||||
|
qfont_size = 72
|
||||||
|
elif quote_len < 100:
|
||||||
|
qfont_size = 56
|
||||||
|
elif quote_len < 150:
|
||||||
|
qfont_size = 44
|
||||||
|
else:
|
||||||
|
qfont_size = 38
|
||||||
|
qfont = font(FONT_BOLD, qfont_size)
|
||||||
|
line_gap = 16
|
||||||
|
|
||||||
|
# Measure total content block height to center it
|
||||||
|
open_quote_h = 80
|
||||||
|
quote_gap = 20
|
||||||
|
quote_h = measure_wrap_height(draw, q["quote"], max_w, qfont, line_gap=line_gap)
|
||||||
|
close_quote_h = 78 # glyph + attribution inline
|
||||||
|
attr_gap = 0
|
||||||
|
divider_h = 0
|
||||||
|
attr_h = 0
|
||||||
|
context_h = 35 if "context" in q else 0
|
||||||
|
|
||||||
|
total_h = open_quote_h + quote_gap + quote_h + close_quote_h + attr_gap + divider_h + attr_h + context_h
|
||||||
|
|
||||||
|
# Center between header (y=90) and footer (y=W-70)
|
||||||
|
avail_top = 100
|
||||||
|
avail_bottom = W - 80
|
||||||
|
avail_h = avail_bottom - avail_top
|
||||||
|
y = avail_top + (avail_h - total_h) // 2
|
||||||
|
|
||||||
|
# Opening quote mark
|
||||||
|
draw.text((mx - 15, y), "\u201c", font=font(FONT_BLACK, 100), fill=ACCENT_DIM)
|
||||||
|
y += open_quote_h + quote_gap
|
||||||
|
|
||||||
|
# Quote text
|
||||||
|
y = wrap_text_left(draw, q["quote"], mx, y, max_w, qfont, WHITE, line_gap=line_gap)
|
||||||
|
|
||||||
|
# Closing quote mark + attribution
|
||||||
|
close_y = y + 8
|
||||||
|
draw.text((mx - 15, close_y), "\u201d", font=font(FONT_BLACK, 80), fill=ACCENT_DIM)
|
||||||
|
attr = f"\u2014 {q['caller']}, {q['episode']}"
|
||||||
|
draw.text((mx + 70, close_y + 25), attr, font=font(FONT_BOLD, 28), fill=ACCENT)
|
||||||
|
y = close_y + 70
|
||||||
|
|
||||||
|
if "context" in q:
|
||||||
|
draw.text((mx, y), q["context"], font=font(FONT_ITALIC, 24), fill=MUTED)
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
footer_y = W - 70
|
||||||
|
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
||||||
|
footer_y, W, font(FONT_REG, 20), MUTED)
|
||||||
|
|
||||||
|
fname = f"quote_{idx + 1}_{q['slug']}_square.png"
|
||||||
|
img.save(os.path.join(OUT_DIR, fname), quality=95)
|
||||||
|
print(f"Created: {fname}")
|
||||||
|
|
||||||
|
|
||||||
|
def make_quote_landscape(q, idx):
|
||||||
|
W, H = 1200, 675
|
||||||
|
img = Image.new("RGB", (W, H), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
accent_bars(draw, W, H, 5)
|
||||||
|
|
||||||
|
mx = 55
|
||||||
|
max_w = W - mx * 2
|
||||||
|
|
||||||
|
# Header
|
||||||
|
draw.text((mx, 28), "LUKE AT THE ROOST", font=font(FONT_BOLD, 18), fill=ACCENT)
|
||||||
|
paste_cover(img, W - 90, 22, 52, 8)
|
||||||
|
|
||||||
|
# Font size — aggressive scaling for short quotes
|
||||||
|
quote_len = len(q["quote"])
|
||||||
|
if quote_len < 55:
|
||||||
|
qfont_size = 52
|
||||||
|
elif quote_len < 100:
|
||||||
|
qfont_size = 42
|
||||||
|
elif quote_len < 150:
|
||||||
|
qfont_size = 34
|
||||||
|
else:
|
||||||
|
qfont_size = 28
|
||||||
|
qfont = font(FONT_BOLD, qfont_size)
|
||||||
|
|
||||||
|
# Measure total content block height
|
||||||
|
open_quote_h = 55
|
||||||
|
quote_gap = 15
|
||||||
|
quote_h = measure_wrap_height(draw, q["quote"], max_w, qfont, line_gap=10)
|
||||||
|
close_quote_h = 60 # glyph + attribution inline
|
||||||
|
attr_gap = 0
|
||||||
|
divider_h = 0
|
||||||
|
attr_h = 0
|
||||||
|
context_h = 30 if "context" in q else 0
|
||||||
|
|
||||||
|
total_h = open_quote_h + quote_gap + quote_h + close_quote_h + attr_gap + divider_h + attr_h + context_h
|
||||||
|
|
||||||
|
# Center between header (y=65) and footer (y=H-50)
|
||||||
|
avail_top = 65
|
||||||
|
avail_bottom = H - 55
|
||||||
|
avail_h = avail_bottom - avail_top
|
||||||
|
y = avail_top + (avail_h - total_h) // 2
|
||||||
|
|
||||||
|
# Opening quote mark
|
||||||
|
draw.text((mx - 10, y), "\u201c", font=font(FONT_BLACK, 72), fill=ACCENT_DIM)
|
||||||
|
y += open_quote_h + quote_gap
|
||||||
|
|
||||||
|
# Quote text
|
||||||
|
y = wrap_text_left(draw, q["quote"], mx, y, max_w, qfont, WHITE, line_gap=10)
|
||||||
|
|
||||||
|
# Closing quote + attribution
|
||||||
|
close_y = y + 5
|
||||||
|
draw.text((mx - 10, close_y), "\u201d", font=font(FONT_BLACK, 60), fill=ACCENT_DIM)
|
||||||
|
attr = f"\u2014 {q['caller']}, {q['episode']}"
|
||||||
|
draw.text((mx + 55, close_y + 18), attr, font=font(FONT_BOLD, 22), fill=ACCENT)
|
||||||
|
y = close_y + 55
|
||||||
|
|
||||||
|
if "context" in q:
|
||||||
|
draw.text((mx, y), q["context"], font=font(FONT_ITALIC, 19), fill=MUTED)
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
footer_y = H - 50
|
||||||
|
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
||||||
|
footer_y, W, font(FONT_REG, 17), MUTED)
|
||||||
|
|
||||||
|
fname = f"quote_{idx + 1}_{q['slug']}_twitter.png"
|
||||||
|
img.save(os.path.join(OUT_DIR, fname), quality=95)
|
||||||
|
print(f"Created: {fname}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. WELCOME TO THE SHOW ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_welcome_square():
|
||||||
|
W = 1080
|
||||||
|
img = Image.new("RGB", (W, W), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
accent_bars(draw, W, W, 8)
|
||||||
|
|
||||||
|
cx = W // 2
|
||||||
|
|
||||||
|
# Cover art — centered, large
|
||||||
|
cover_size = 280
|
||||||
|
paste_cover(img, cx - cover_size // 2, 60, cover_size, 24)
|
||||||
|
|
||||||
|
y = 60 + cover_size + 40
|
||||||
|
|
||||||
|
# Title
|
||||||
|
y = wrap_text_centered(draw, "WELCOME TO THE SHOW", cx, y, W - 120,
|
||||||
|
font(FONT_BLACK, 64), WHITE, line_gap=12)
|
||||||
|
y += 20
|
||||||
|
|
||||||
|
# Divider
|
||||||
|
draw.rectangle([cx - 60, y, cx + 60, y + 4], fill=ACCENT)
|
||||||
|
y += 30
|
||||||
|
|
||||||
|
# Description
|
||||||
|
desc = "Late-night call-in radio powered entirely by AI. Real conversations with AI callers about life, love, and everything in between."
|
||||||
|
y = wrap_text_centered(draw, desc, cx, y, W - 140,
|
||||||
|
font(FONT_REG, 30), LIGHTER, line_gap=12)
|
||||||
|
y += 30
|
||||||
|
|
||||||
|
# Features
|
||||||
|
features = [
|
||||||
|
"New episodes daily",
|
||||||
|
"AI-generated callers with real personalities",
|
||||||
|
"Unscripted. Unfiltered. Unpredictable.",
|
||||||
|
]
|
||||||
|
for feat in features:
|
||||||
|
line = f"\u2022 {feat}"
|
||||||
|
tw, th = text_size(draw, line, font(FONT_REG, 26))
|
||||||
|
draw.text((cx - tw // 2, y), line, font=font(FONT_REG, 26), fill=MUTED)
|
||||||
|
y += th + 14
|
||||||
|
|
||||||
|
y += 20
|
||||||
|
|
||||||
|
# CTA
|
||||||
|
cta = "FOLLOW @LUKEATTHEROOST"
|
||||||
|
cta_font = font(FONT_BOLD, 32)
|
||||||
|
tw, th = text_size(draw, cta, cta_font)
|
||||||
|
px, py = 28, 16
|
||||||
|
box_w = tw + px * 2
|
||||||
|
box_h = th + py * 2
|
||||||
|
box_x = cx - box_w // 2
|
||||||
|
draw.rounded_rectangle([box_x, y, box_x + box_w, y + box_h],
|
||||||
|
radius=10, fill=ACCENT)
|
||||||
|
draw.text((box_x + px, y + py), cta, font=cta_font, fill=BG)
|
||||||
|
|
||||||
|
y += box_h + 24
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
center_text(draw, "lukeattheroost.com", y, W, font(FONT_REG, 22), MUTED)
|
||||||
|
|
||||||
|
img.save(os.path.join(OUT_DIR, "welcome_to_the_show_square.png"), quality=95)
|
||||||
|
print("Created: welcome_to_the_show_square.png")
|
||||||
|
|
||||||
|
|
||||||
|
def make_welcome_landscape():
|
||||||
|
W, H = 1200, 675
|
||||||
|
img = Image.new("RGB", (W, H), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
accent_bars(draw, W, H, 6)
|
||||||
|
|
||||||
|
# Cover art — left side
|
||||||
|
cover_size = 260
|
||||||
|
cover_x, cover_y = 50, (H - cover_size) // 2
|
||||||
|
paste_cover(img, cover_x, cover_y, cover_size, 20)
|
||||||
|
|
||||||
|
# Right side content
|
||||||
|
rx = cover_x + cover_size + 50
|
||||||
|
max_w = W - rx - 50
|
||||||
|
y = 60
|
||||||
|
|
||||||
|
# Title
|
||||||
|
y = wrap_text_left(draw, "WELCOME TO THE SHOW", rx, y, max_w,
|
||||||
|
font(FONT_BLACK, 48), WHITE, line_gap=10)
|
||||||
|
y += 16
|
||||||
|
|
||||||
|
# Divider
|
||||||
|
draw.rectangle([rx, y, rx + 80, y + 3], fill=ACCENT)
|
||||||
|
y += 20
|
||||||
|
|
||||||
|
# Description
|
||||||
|
desc = "Late-night call-in radio powered entirely by AI. Real conversations with AI callers about life, love, and everything in between."
|
||||||
|
y = wrap_text_left(draw, desc, rx, y, max_w,
|
||||||
|
font(FONT_REG, 22), LIGHTER, line_gap=10)
|
||||||
|
y += 20
|
||||||
|
|
||||||
|
# Features
|
||||||
|
features = [
|
||||||
|
"New episodes daily",
|
||||||
|
"AI callers with real personalities",
|
||||||
|
"Unscripted. Unfiltered. Unpredictable.",
|
||||||
|
]
|
||||||
|
for feat in features:
|
||||||
|
line = f"\u2022 {feat}"
|
||||||
|
draw.text((rx, y), line, font=font(FONT_REG, 20), fill=MUTED)
|
||||||
|
y += 32
|
||||||
|
|
||||||
|
y += 10
|
||||||
|
|
||||||
|
# CTA
|
||||||
|
cta = "FOLLOW @LUKEATTHEROOST"
|
||||||
|
cta_font = font(FONT_BOLD, 24)
|
||||||
|
tw, th = text_size(draw, cta, cta_font)
|
||||||
|
px, py = 22, 12
|
||||||
|
draw.rounded_rectangle([rx, y, rx + tw + px * 2, y + th + py * 2],
|
||||||
|
radius=8, fill=ACCENT)
|
||||||
|
draw.text((rx + px, y + py), cta, font=cta_font, fill=BG)
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
center_text(draw, "lukeattheroost.com \u00b7 Spotify \u00b7 Apple Podcasts \u00b7 YouTube",
|
||||||
|
H - 45, W, font(FONT_REG, 17), MUTED)
|
||||||
|
|
||||||
|
img.save(os.path.join(OUT_DIR, "welcome_to_the_show_twitter.png"), quality=95)
|
||||||
|
print("Created: welcome_to_the_show_twitter.png")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. LEAVE US A REVIEW ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_review_square():
|
||||||
|
W = 1080
|
||||||
|
img = Image.new("RGB", (W, W), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
accent_bars(draw, W, W, 8)
|
||||||
|
|
||||||
|
cx = W // 2
|
||||||
|
|
||||||
|
# Cover art
|
||||||
|
cover_size = 200
|
||||||
|
paste_cover(img, cx - cover_size // 2, 55, cover_size, 20)
|
||||||
|
y = 55 + cover_size + 35
|
||||||
|
|
||||||
|
# Title
|
||||||
|
y = wrap_text_centered(draw, "LOVE THE SHOW?", cx, y, W - 120,
|
||||||
|
font(FONT_BLACK, 64), WHITE, line_gap=12)
|
||||||
|
y += 10
|
||||||
|
y = wrap_text_centered(draw, "LEAVE US A REVIEW", cx, y, W - 120,
|
||||||
|
font(FONT_BLACK, 64), ACCENT, line_gap=12)
|
||||||
|
y += 30
|
||||||
|
|
||||||
|
# Divider
|
||||||
|
draw.rectangle([cx - 50, y, cx + 50, y + 3], fill=ACCENT)
|
||||||
|
y += 30
|
||||||
|
|
||||||
|
# Body text
|
||||||
|
body = "Reviews help new listeners find the show. If Luke at the Roost has made you laugh, think, or question your life choices\u2014take 30 seconds to leave a rating."
|
||||||
|
y = wrap_text_centered(draw, body, cx, y, W - 140,
|
||||||
|
font(FONT_REG, 28), LIGHTER, line_gap=12)
|
||||||
|
y += 35
|
||||||
|
|
||||||
|
# Stars
|
||||||
|
stars = "\u2605 \u2605 \u2605 \u2605 \u2605"
|
||||||
|
center_text(draw, stars, y, W, font(FONT_REG, 52), ACCENT)
|
||||||
|
y += 70
|
||||||
|
|
||||||
|
# Platforms
|
||||||
|
platforms = ["Apple Podcasts", "Spotify", "YouTube", "Podchaser"]
|
||||||
|
for plat in platforms:
|
||||||
|
tw, th = text_size(draw, plat, font(FONT_BOLD, 26))
|
||||||
|
px, py = 30, 12
|
||||||
|
box_w = tw + px * 2
|
||||||
|
box_x = cx - box_w // 2
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[box_x, y, box_x + box_w, y + th + py * 2],
|
||||||
|
radius=8, fill=DARK_PANEL, outline=ACCENT_DIM, width=2,
|
||||||
|
)
|
||||||
|
draw.text((box_x + px, y + py), plat, font=font(FONT_BOLD, 26), fill=LIGHTER)
|
||||||
|
y += th + py * 2 + 12
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
center_text(draw, "lukeattheroost.com", W - 65, W, font(FONT_REG, 20), MUTED)
|
||||||
|
|
||||||
|
img.save(os.path.join(OUT_DIR, "leave_a_review_square.png"), quality=95)
|
||||||
|
print("Created: leave_a_review_square.png")
|
||||||
|
|
||||||
|
|
||||||
|
def make_review_landscape():
|
||||||
|
W, H = 1200, 675
|
||||||
|
img = Image.new("RGB", (W, H), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
accent_bars(draw, W, H, 6)
|
||||||
|
|
||||||
|
# Cover art — left
|
||||||
|
cover_size = 200
|
||||||
|
cover_x, cover_y = 50, (H - cover_size) // 2
|
||||||
|
paste_cover(img, cover_x, cover_y, cover_size, 16)
|
||||||
|
|
||||||
|
# Right content
|
||||||
|
rx = cover_x + cover_size + 50
|
||||||
|
max_w = W - rx - 50
|
||||||
|
y = 50
|
||||||
|
|
||||||
|
# Title
|
||||||
|
y = wrap_text_left(draw, "LOVE THE SHOW?", rx, y, max_w,
|
||||||
|
font(FONT_BLACK, 48), WHITE, line_gap=8)
|
||||||
|
y += 6
|
||||||
|
y = wrap_text_left(draw, "LEAVE US A REVIEW", rx, y, max_w,
|
||||||
|
font(FONT_BLACK, 48), ACCENT, line_gap=8)
|
||||||
|
y += 16
|
||||||
|
|
||||||
|
# Stars
|
||||||
|
stars = "\u2605 \u2605 \u2605 \u2605 \u2605"
|
||||||
|
draw.text((rx, y), stars, font=font(FONT_REG, 40), fill=ACCENT)
|
||||||
|
y += 55
|
||||||
|
|
||||||
|
# Body
|
||||||
|
body = "Reviews help new listeners find the show. Take 30 seconds to leave a rating\u2014it makes a huge difference."
|
||||||
|
y = wrap_text_left(draw, body, rx, y, max_w,
|
||||||
|
font(FONT_REG, 22), LIGHTER, line_gap=10)
|
||||||
|
y += 25
|
||||||
|
|
||||||
|
# Platform pills — inline
|
||||||
|
platforms = ["Apple Podcasts", "Spotify", "YouTube", "Podchaser"]
|
||||||
|
pill_x = rx
|
||||||
|
pill_font = font(FONT_BOLD, 19)
|
||||||
|
for plat in platforms:
|
||||||
|
tw, th = text_size(draw, plat, pill_font)
|
||||||
|
px, py = 16, 8
|
||||||
|
pill_w = tw + px * 2
|
||||||
|
if pill_x + pill_w > W - 50:
|
||||||
|
pill_x = rx
|
||||||
|
y += th + py * 2 + 10
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[pill_x, y, pill_x + pill_w, y + th + py * 2],
|
||||||
|
radius=6, fill=DARK_PANEL, outline=ACCENT_DIM, width=2,
|
||||||
|
)
|
||||||
|
draw.text((pill_x + px, y + py), plat, font=pill_font, fill=LIGHTER)
|
||||||
|
pill_x += pill_w + 10
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
center_text(draw, "lukeattheroost.com", H - 40, W, font(FONT_REG, 17), MUTED)
|
||||||
|
|
||||||
|
img.save(os.path.join(OUT_DIR, "leave_a_review_twitter.png"), quality=95)
|
||||||
|
print("Created: leave_a_review_twitter.png")
|
||||||
|
|
||||||
|
|
||||||
|
# ── MAIN ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
print("\n=== X Launch Campaign Assets ===\n")
|
||||||
|
|
||||||
|
print("--- Header ---")
|
||||||
|
make_header()
|
||||||
|
|
||||||
|
print("\n--- Quote Cards ---")
|
||||||
|
for i, q in enumerate(QUOTES):
|
||||||
|
make_quote_square(q, i)
|
||||||
|
make_quote_landscape(q, i)
|
||||||
|
|
||||||
|
print("\n--- Welcome to the Show ---")
|
||||||
|
make_welcome_square()
|
||||||
|
make_welcome_landscape()
|
||||||
|
|
||||||
|
print("\n--- Leave a Review ---")
|
||||||
|
make_review_square()
|
||||||
|
make_review_landscape()
|
||||||
|
|
||||||
|
print(f"\nAll assets saved to: {OUT_DIR}/")
|
||||||
|
print(f"Total files: {len(os.listdir(OUT_DIR))}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+10
-5
@@ -22,6 +22,8 @@ import sys
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||||
|
|
||||||
YOUTUBE_PLAYLIST = "PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-"
|
YOUTUBE_PLAYLIST = "PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-"
|
||||||
APPLE_PODCAST_ID = "1875205848"
|
APPLE_PODCAST_ID = "1875205848"
|
||||||
@@ -33,9 +35,9 @@ DOCKER_BIN = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
|||||||
CASTOPOD_DB_CONTAINER = "castopod-mariadb-1"
|
CASTOPOD_DB_CONTAINER = "castopod-mariadb-1"
|
||||||
|
|
||||||
BUNNY_STORAGE_ZONE = "lukeattheroost"
|
BUNNY_STORAGE_ZONE = "lukeattheroost"
|
||||||
BUNNY_STORAGE_KEY = "92749cd3-85df-4cff-938fe35eb994-30f8-4cf2"
|
BUNNY_STORAGE_KEY = os.getenv("BUNNY_STORAGE_KEY", "")
|
||||||
BUNNY_STORAGE_REGION = "la"
|
BUNNY_STORAGE_REGION = "la"
|
||||||
BUNNY_ACCOUNT_KEY = "2865f279-297b-431a-ad18-0ccf1f8e4fa8cf636cea-3222-415a-84ed-56ee195c0530"
|
BUNNY_ACCOUNT_KEY = os.getenv("BUNNY_ACCOUNT_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
def _find_ytdlp():
|
def _find_ytdlp():
|
||||||
@@ -243,13 +245,16 @@ def _run_db_query(sql):
|
|||||||
docker_bin = path
|
docker_bin = path
|
||||||
break
|
break
|
||||||
|
|
||||||
|
db_pass = os.getenv("CASTOPOD_DB_PASS", "")
|
||||||
if docker_bin:
|
if docker_bin:
|
||||||
cmd = [docker_bin, "exec", "-i", CASTOPOD_DB_CONTAINER,
|
# Pass password via MYSQL_PWD env var instead of command line (not visible in ps)
|
||||||
"mysql", "-u", "castopod", "-pBYtbFfk3ndeVabb26xb0UyKU", "castopod", "-N"]
|
cmd = [docker_bin, "exec", "-i", "-e", f"MYSQL_PWD={db_pass}",
|
||||||
|
CASTOPOD_DB_CONTAINER,
|
||||||
|
"mysql", "-u", "castopod", "castopod", "-N"]
|
||||||
else:
|
else:
|
||||||
cmd = [
|
cmd = [
|
||||||
"ssh", "-p", NAS_SSH_PORT, NAS_SSH,
|
"ssh", "-p", NAS_SSH_PORT, NAS_SSH,
|
||||||
f"{DOCKER_BIN} exec -i {CASTOPOD_DB_CONTAINER} mysql -u castopod -pBYtbFfk3ndeVabb26xb0UyKU castopod -N"
|
f"{DOCKER_BIN} exec -i -e MYSQL_PWD={db_pass} {CASTOPOD_DB_CONTAINER} mysql -u castopod castopod -N"
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(cmd, input=sql, capture_output=True, text=True, timeout=30)
|
proc = subprocess.run(cmd, input=sql, capture_output=True, text=True, timeout=30)
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Post 1000 downloads milestone to all social platforms via Postiz."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Load .env
|
||||||
|
env_path = Path(__file__).parent / ".env"
|
||||||
|
if env_path.exists():
|
||||||
|
for line in env_path.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
os.environ.setdefault(key.strip(), value.strip())
|
||||||
|
|
||||||
|
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
|
||||||
|
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
|
||||||
|
|
||||||
|
IMAGE_PATH = Path(__file__).parent / "social_posts" / "1000_milestone" / "main_1000_celebration.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_url(path: str) -> str:
|
||||||
|
return f"{POSTIZ_URL.rstrip('/')}/api/public/v1{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def api_headers() -> dict:
|
||||||
|
return {"Authorization": POSTIZ_API_KEY, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_integrations() -> list[dict]:
|
||||||
|
resp = requests.get(get_api_url("/integrations"), headers=api_headers(), timeout=15)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"Error fetching integrations: {resp.status_code} {resp.text[:200]}")
|
||||||
|
sys.exit(1)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def find_integration(integrations: list[dict], provider: str) -> dict | None:
|
||||||
|
for integ in integrations:
|
||||||
|
if integ.get("identifier", "").startswith(provider) and not integ.get("disabled"):
|
||||||
|
return integ
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(file_path: Path) -> dict:
|
||||||
|
headers = {"Authorization": POSTIZ_API_KEY}
|
||||||
|
mime = "image/jpeg" if file_path.suffix.lower() in (".jpg", ".jpeg") else "image/png"
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/upload"),
|
||||||
|
headers=headers,
|
||||||
|
files={"file": (file_path.name, f, mime)},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
print(f"Upload failed: {resp.status_code} {resp.text[:200]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def create_post(integration_id: str, content: str, media: dict, settings: dict) -> dict:
|
||||||
|
date = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
payload = {
|
||||||
|
"type": "now",
|
||||||
|
"date": date,
|
||||||
|
"shortLink": False,
|
||||||
|
"tags": [],
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"integration": {"id": integration_id},
|
||||||
|
"value": [{"content": content, "image": [media] if media else []}],
|
||||||
|
"settings": settings,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
resp = requests.post(get_api_url("/posts"), headers=api_headers(), json=payload, timeout=30)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
print(f" Post failed: {resp.status_code} {resp.text[:300]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Post content per platform ---
|
||||||
|
|
||||||
|
POSTS = {
|
||||||
|
"instagram": {
|
||||||
|
"content": """1,000 downloads in one month. 🎙️
|
||||||
|
|
||||||
|
27 episodes. 200+ callers. 111 unique characters. 13 returning regulars.
|
||||||
|
|
||||||
|
Luke at the Roost is a late-night call-in show where AI-generated characters phone in with their problems — relationship drama, moral dilemmas, conspiracy theories, drunk confessions — and I give them real advice, live.
|
||||||
|
|
||||||
|
Every caller has a unique voice, a backstory, and a reason for calling. Some of them keep calling back.
|
||||||
|
|
||||||
|
Thank you to everyone who's tuned in. This thing was supposed to be a weird experiment. Now it's a weird experiment that 1,000 people have listened to.
|
||||||
|
|
||||||
|
New episodes daily. Link in bio.
|
||||||
|
|
||||||
|
#podcast #ai #artificialintelligence #sideproject #indieproject #podcastlife #latenightradio #callinshow #milestone #1000downloads""",
|
||||||
|
"settings": {"__type": "instagram", "post_type": "post", "collaborators": []},
|
||||||
|
},
|
||||||
|
"facebook": {
|
||||||
|
"content": """🎙️ MILESTONE: 1,000 Downloads
|
||||||
|
|
||||||
|
One month ago I launched a weird experiment — a late-night call-in radio show where AI-generated characters phone in with their problems, and I give them real advice.
|
||||||
|
|
||||||
|
27 episodes later, 200+ callers have phoned in. Some of them keep calling back. Listeners have favorites. People genuinely care about what happens to these characters.
|
||||||
|
|
||||||
|
Thank you to every single person who gave this a listen. It started as a side project and it's become something I look forward to every day.
|
||||||
|
|
||||||
|
Listen free: lukeattheroost.com
|
||||||
|
Call in for real: 208-439-LUKE""",
|
||||||
|
"settings": {"__type": "facebook"},
|
||||||
|
},
|
||||||
|
"threads": {
|
||||||
|
"content": """1,000 downloads in one month. 🎙️
|
||||||
|
|
||||||
|
27 episodes. 200+ callers. 111 unique characters. 13 returning regulars.
|
||||||
|
|
||||||
|
Luke at the Roost is a late-night call-in show where AI-generated characters phone in with their problems and I give them real advice, live.
|
||||||
|
|
||||||
|
Thank you to everyone who's tuned in. This weird experiment just hit a milestone.
|
||||||
|
|
||||||
|
lukeattheroost.com
|
||||||
|
|
||||||
|
#podcast #ai #sideproject #1000downloads""",
|
||||||
|
"settings": {"__type": "threads"},
|
||||||
|
},
|
||||||
|
"linkedin": {
|
||||||
|
"content": """1,000 downloads in 30 days — here's what I learned building an AI radio show
|
||||||
|
|
||||||
|
A month ago I launched Luke at the Roost, a late-night call-in radio show where every caller is an AI-generated character. I'm the host. They phone in with problems. I give them advice. Every conversation is improvised.
|
||||||
|
|
||||||
|
27 episodes and 200+ callers later, the show just hit 1,000 downloads.
|
||||||
|
|
||||||
|
Some things that surprised me:
|
||||||
|
|
||||||
|
People connect with AI characters. Listeners have favorites. They ask about regulars by name. When a caller's story evolves across episodes, people notice and care. The characters aren't real, but the emotional engagement is.
|
||||||
|
|
||||||
|
Constraints drive creativity. Each caller gets a token budget based on their personality type. Emotional callers get more room to ramble. Gossip callers are quick and punchy. This artificial constraint mirrors how real people actually talk — and it makes every call feel distinct.
|
||||||
|
|
||||||
|
The tech is the easy part. LLMs, voice synthesis, audio routing — that's engineering. The hard part is being a good host. Knowing when to push, when to listen, when to make a joke. AI handles the callers. The human skill is the conversation.
|
||||||
|
|
||||||
|
The full technical breakdown: lukeattheroost.com/how-it-works
|
||||||
|
Listen: lukeattheroost.com
|
||||||
|
|
||||||
|
Thank you to everyone who gave this weird experiment a chance.""",
|
||||||
|
"settings": {"__type": "linkedin"},
|
||||||
|
},
|
||||||
|
"mastodon": {
|
||||||
|
"content": """1,000 downloads. 27 episodes. 200+ AI callers given advice on everything from breakups to fish consciousness.
|
||||||
|
|
||||||
|
Luke at the Roost hit a milestone today and I just want to say thank you to everyone who's been listening.
|
||||||
|
|
||||||
|
This whole thing is self-hosted end-to-end — Castopod on a QNAP NAS, Cloudflare CDN, custom Python pipeline for recording, post-production, and publishing. No big platforms in the loop.
|
||||||
|
|
||||||
|
If you haven't heard it: it's a late-night call-in show. AI characters phone in. I talk to them live. It's improvised, weird, and somehow heartfelt.
|
||||||
|
|
||||||
|
https://lukeattheroost.com""",
|
||||||
|
"settings": {"__type": "mastodon"},
|
||||||
|
},
|
||||||
|
"tiktok": {
|
||||||
|
"content": """1,000 downloads in one month 🎙️
|
||||||
|
|
||||||
|
27 episodes. 200+ AI callers. 13 returning regulars.
|
||||||
|
|
||||||
|
Luke at the Roost — a late-night call-in show where AI characters phone in with their problems and I give them real advice, live.
|
||||||
|
|
||||||
|
Thank you to everyone listening.
|
||||||
|
|
||||||
|
#podcast #ai #artificialintelligence #sideproject #latenightradio #callinshow #1000downloads""",
|
||||||
|
"settings": {
|
||||||
|
"__type": "tiktok",
|
||||||
|
"privacy_level": "PUBLIC_TO_EVERYONE",
|
||||||
|
"duet": False,
|
||||||
|
"stitch": False,
|
||||||
|
"comment": True,
|
||||||
|
"autoAddMusic": "no",
|
||||||
|
"brand_content_toggle": False,
|
||||||
|
"brand_organic_toggle": False,
|
||||||
|
"content_posting_method": "DIRECT_POST",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"nostr": {
|
||||||
|
"content": """1,000 downloads. 27 episodes. 200+ AI callers given advice.
|
||||||
|
|
||||||
|
Luke at the Roost just hit a milestone. Thank you to everyone listening.
|
||||||
|
|
||||||
|
It's a late-night call-in show where AI-generated characters phone in with their problems and I give them real advice, live. Every conversation is improvised.
|
||||||
|
|
||||||
|
https://lukeattheroost.com""",
|
||||||
|
"settings": {"__type": "nostr"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
dry_run = "--dry-run" in sys.argv
|
||||||
|
|
||||||
|
if not POSTIZ_API_KEY:
|
||||||
|
print("Error: POSTIZ_API_KEY not set")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not IMAGE_PATH.exists():
|
||||||
|
print(f"Error: Image not found at {IMAGE_PATH}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Fetching connected accounts from Postiz...")
|
||||||
|
integrations = fetch_integrations()
|
||||||
|
|
||||||
|
available = {}
|
||||||
|
for platform in POSTS:
|
||||||
|
integ = find_integration(integrations, platform)
|
||||||
|
if integ:
|
||||||
|
available[platform] = integ
|
||||||
|
print(f" ✓ {platform}: {integ.get('name', 'connected')}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ {platform}: not connected, skipping")
|
||||||
|
|
||||||
|
if not available:
|
||||||
|
print("\nNo platforms available!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"\nWill post to {len(available)} platform(s) with image: {IMAGE_PATH.name}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print("\n--- DRY RUN ---")
|
||||||
|
for platform in available:
|
||||||
|
print(f"\n[{platform.upper()}]")
|
||||||
|
print(POSTS[platform]["content"][:200] + "...")
|
||||||
|
print("\nDry run complete — nothing posted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Upload image once
|
||||||
|
print(f"\nUploading image...")
|
||||||
|
media = upload_image(IMAGE_PATH)
|
||||||
|
if not media:
|
||||||
|
print("Failed to upload image, aborting")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f" Uploaded: {media.get('path', 'ok')}")
|
||||||
|
|
||||||
|
# Post to each platform
|
||||||
|
results = {}
|
||||||
|
for platform, integ in available.items():
|
||||||
|
post_data = POSTS[platform]
|
||||||
|
print(f"\nPosting to {platform}...")
|
||||||
|
result = create_post(integ["id"], post_data["content"], media, post_data["settings"])
|
||||||
|
if result:
|
||||||
|
print(f" ✓ {platform}: Posted!")
|
||||||
|
results[platform] = True
|
||||||
|
else:
|
||||||
|
print(f" ✗ {platform}: Failed")
|
||||||
|
results[platform] = False
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
succeeded = [p for p, ok in results.items() if ok]
|
||||||
|
failed = [p for p, ok in results.items() if not ok]
|
||||||
|
print(f"\n{'='*40}")
|
||||||
|
print(f"Posted to {len(succeeded)}/{len(results)} platforms")
|
||||||
|
if succeeded:
|
||||||
|
print(f" ✓ {', '.join(succeeded)}")
|
||||||
|
if failed:
|
||||||
|
print(f" ✗ {', '.join(failed)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+9
-9
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
Usage: python postprod.py recordings/2026-02-07_213000/ -o episode.mp3
|
Usage: python postprod.py recordings/2026-02-07_213000/ -o episode.mp3
|
||||||
|
|
||||||
Processes 5 aligned WAV stems (host, caller, music, sfx, ads) into a
|
Processes 6 aligned WAV stems (host, caller, music, sfx, ads, idents) into a
|
||||||
broadcast-ready MP3 with gap removal, voice compression, music ducking,
|
broadcast-ready MP3 with gap removal, voice compression, music ducking,
|
||||||
and loudness normalization.
|
and loudness normalization.
|
||||||
"""
|
"""
|
||||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
|
|
||||||
STEM_NAMES = ["host", "caller", "music", "sfx", "ads"]
|
STEM_NAMES = ["host", "caller", "devon", "music", "sfx", "ads", "idents"]
|
||||||
|
|
||||||
|
|
||||||
def load_stems(stems_dir: Path) -> tuple[dict[str, np.ndarray], int]:
|
def load_stems(stems_dir: Path) -> tuple[dict[str, np.ndarray], int]:
|
||||||
@@ -69,7 +69,7 @@ def remove_gaps(stems: dict[str, np.ndarray], sr: int,
|
|||||||
|
|
||||||
# Detect gaps in everything except music (which always plays).
|
# Detect gaps in everything except music (which always plays).
|
||||||
# This catches TTS latency gaps while protecting ad breaks and SFX transitions.
|
# This catches TTS latency gaps while protecting ad breaks and SFX transitions.
|
||||||
content = stems["host"] + stems["caller"] + stems["sfx"] + stems["ads"]
|
content = stems["host"] + stems["caller"] + stems["devon"] + stems["sfx"] + stems["ads"] + stems["idents"]
|
||||||
rms = compute_rms(content, window_samples)
|
rms = compute_rms(content, window_samples)
|
||||||
|
|
||||||
# Threshold: percentile-based to sit above the mic noise floor
|
# Threshold: percentile-based to sit above the mic noise floor
|
||||||
@@ -386,7 +386,7 @@ def apply_ducking(music: np.ndarray, dialog: np.ndarray, sr: int,
|
|||||||
|
|
||||||
def match_voice_levels(stems: dict[str, np.ndarray], target_rms: float = 0.1) -> dict[str, np.ndarray]:
|
def match_voice_levels(stems: dict[str, np.ndarray], target_rms: float = 0.1) -> dict[str, np.ndarray]:
|
||||||
"""Normalize host, caller, and ads stems to the same RMS level."""
|
"""Normalize host, caller, and ads stems to the same RMS level."""
|
||||||
for name in ["host", "caller", "ads"]:
|
for name in ["host", "caller", "ads", "idents"]:
|
||||||
audio = stems[name]
|
audio = stems[name]
|
||||||
# Only measure non-silent portions
|
# Only measure non-silent portions
|
||||||
active = audio[np.abs(audio) > 0.001]
|
active = audio[np.abs(audio) > 0.001]
|
||||||
@@ -408,7 +408,7 @@ def mix_stems(stems: dict[str, np.ndarray],
|
|||||||
levels: dict[str, float] | None = None,
|
levels: dict[str, float] | None = None,
|
||||||
stereo_imaging: bool = True) -> np.ndarray:
|
stereo_imaging: bool = True) -> np.ndarray:
|
||||||
if levels is None:
|
if levels is None:
|
||||||
levels = {"host": 0, "caller": 0, "music": -6, "sfx": -10, "ads": 0}
|
levels = {"host": 0, "caller": 0, "music": -6, "sfx": -10, "ads": 0, "idents": 0}
|
||||||
|
|
||||||
gains = {name: 10 ** (db / 20) for name, db in levels.items()}
|
gains = {name: 10 ** (db / 20) for name, db in levels.items()}
|
||||||
|
|
||||||
@@ -417,7 +417,7 @@ def mix_stems(stems: dict[str, np.ndarray],
|
|||||||
if stereo_imaging:
|
if stereo_imaging:
|
||||||
# Pan positions: -1.0 = full left, 0.0 = center, 1.0 = full right
|
# Pan positions: -1.0 = full left, 0.0 = center, 1.0 = full right
|
||||||
# Using constant-power panning law
|
# Using constant-power panning law
|
||||||
pans = {"host": 0.0, "caller": 0.15, "music": 0.0, "sfx": 0.0, "ads": 0.0}
|
pans = {"host": 0.0, "caller": 0.15, "music": 0.0, "sfx": 0.0, "ads": 0.0, "idents": 0.0}
|
||||||
# Music gets stereo width via slight L/R decorrelation
|
# Music gets stereo width via slight L/R decorrelation
|
||||||
music_width = 0.3
|
music_width = 0.3
|
||||||
|
|
||||||
@@ -432,7 +432,7 @@ def mix_stems(stems: dict[str, np.ndarray],
|
|||||||
|
|
||||||
if name == "music" and music_width > 0:
|
if name == "music" and music_width > 0:
|
||||||
# Widen music: delay right channel by ~0.5ms for Haas effect
|
# Widen music: delay right channel by ~0.5ms for Haas effect
|
||||||
delay_samples = int(0.0005 * 44100) # ~22 samples at 44.1kHz
|
delay_samples = int(0.0005 * sr) # ~22 samples at target sample rate
|
||||||
left += signal * (1 + music_width * 0.5)
|
left += signal * (1 + music_width * 0.5)
|
||||||
right_delayed = np.zeros_like(signal)
|
right_delayed = np.zeros_like(signal)
|
||||||
right_delayed[delay_samples:] = signal[:-delay_samples] if delay_samples > 0 else signal
|
right_delayed[delay_samples:] = signal[:-delay_samples] if delay_samples > 0 else signal
|
||||||
@@ -774,7 +774,7 @@ def main():
|
|||||||
print(f"\n[3/{total_steps}] Limiting ads + SFX...")
|
print(f"\n[3/{total_steps}] Limiting ads + SFX...")
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
tmp_dir = Path(tmp)
|
tmp_dir = Path(tmp)
|
||||||
for name in ["ads", "sfx"]:
|
for name in ["ads", "sfx", "idents"]:
|
||||||
if np.any(stems[name] != 0):
|
if np.any(stems[name] != 0):
|
||||||
stems[name] = limit_stem(stems[name], sr, tmp_dir, name)
|
stems[name] = limit_stem(stems[name], sr, tmp_dir, name)
|
||||||
|
|
||||||
@@ -834,7 +834,7 @@ def main():
|
|||||||
dialog = stems["host"] + stems["caller"]
|
dialog = stems["host"] + stems["caller"]
|
||||||
if np.any(dialog != 0) and np.any(stems["music"] != 0):
|
if np.any(dialog != 0) and np.any(stems["music"] != 0):
|
||||||
stems["music"] = apply_ducking(stems["music"], dialog, sr, duck_db=args.duck_amount,
|
stems["music"] = apply_ducking(stems["music"], dialog, sr, duck_db=args.duck_amount,
|
||||||
mute_signal=stems["ads"])
|
mute_signal=stems["ads"] + stems["idents"])
|
||||||
print(" Applied")
|
print(" Applied")
|
||||||
else:
|
else:
|
||||||
print(" No dialog or music to duck")
|
print(" No dialog or music to duck")
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Press Release
|
||||||
|
|
||||||
|
**FOR IMMEDIATE RELEASE**
|
||||||
|
|
||||||
|
## New Podcast "Luke at the Roost" Blends AI-Generated Callers with Live Radio Format, Creating a One-of-a-Kind Late-Night Show
|
||||||
|
|
||||||
|
*Every caller is a unique AI character with a full backstory, personality, and speaking style — and real listeners can call in too*
|
||||||
|
|
||||||
|
**Lordsburg, NM — February 27, 2026** — "Luke at the Roost," a late-night call-in talk show hosted by Luke MacNeil, is redefining what a podcast can be by combining real-time AI-generated callers with traditional live radio. Now 23 episodes in, the show has built a loyal following with its unpredictable conversations, genuine emotional depth, and a cast of characters that listeners have come to know by name.
|
||||||
|
|
||||||
|
### A New Kind of Call-In Show
|
||||||
|
|
||||||
|
Unlike traditional podcasts that rely on scripted segments or pre-recorded interviews, Luke at the Roost generates every AI caller in real time. Each one arrives with a complete identity — name, age, job, hometown, speaking style, and a specific reason for calling. Some are nervous first-timers. Others are regulars who remember past conversations and reference previous episodes. The result is a show that feels like tuning into a real late-night radio station in the American Southwest.
|
||||||
|
|
||||||
|
"The whole point is that these conversations feel real," said MacNeil. "The AI doesn't read from a script. Neither do I. We're just two people talking, and sometimes it goes somewhere neither of us expected."
|
||||||
|
|
||||||
|
### Real Callers Welcome
|
||||||
|
|
||||||
|
The show isn't AI-only. Real listeners can call in live at **208-439-LUKE (208-439-5853)** or email submissions@lukeattheroost.com. The mix of AI-generated and human callers creates an atmosphere where the line between the two becomes part of the fun.
|
||||||
|
|
||||||
|
### Built from Scratch
|
||||||
|
|
||||||
|
The entire technical infrastructure — from the AI caller generator with over 1,000 unique calling reasons, to the multi-track recording system, voice synthesis pipeline, and automated post-production — was built custom by MacNeil. Episodes go through professional-grade audio processing including compression, music ducking, stereo mixing, and broadcast-standard loudness normalization before being automatically published to all major podcast platforms.
|
||||||
|
|
||||||
|
### Where to Listen
|
||||||
|
|
||||||
|
Luke at the Roost is available on all major podcast platforms:
|
||||||
|
|
||||||
|
- **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-
|
||||||
|
- **Discord**: https://discord.gg/5CnQZxDM
|
||||||
|
|
||||||
|
### About MacNeil Media Group
|
||||||
|
|
||||||
|
MacNeil Media Group is an independent media company focused on experimental audio and AI-driven content. Luke at the Roost is its flagship production.
|
||||||
|
|
||||||
|
### Media Contact
|
||||||
|
|
||||||
|
Luke MacNeil
|
||||||
|
luke@lukeattheroost.com
|
||||||
|
https://lukeattheroost.com
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary (for submission forms)
|
||||||
|
|
||||||
|
**Headline**: New Podcast "Luke at the Roost" Blends AI-Generated Callers with Live Radio Format
|
||||||
|
|
||||||
|
**Summary (short)**: Luke at the Roost is a late-night call-in talk show where every caller is a unique AI-generated character with a full backstory and personality. Real listeners can call in too. 23 episodes in, the show has built a following with its unpredictable conversations and genuine emotional depth.
|
||||||
|
|
||||||
|
**Category**: Entertainment / Technology / Podcasting
|
||||||
|
|
||||||
|
**Tags**: podcast, AI, artificial intelligence, late-night radio, call-in show, talk show, entertainment technology, indie podcast, New Mexico
|
||||||
+1131
-201
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
|||||||
|
-- Bleep Selection — censor a time range on the selected track(s)
|
||||||
|
--
|
||||||
|
-- Replaces the audio inside the time selection with a 1kHz tone rather than
|
||||||
|
-- muting it. Muted regions read as silence to strip_silence_dialog.lua
|
||||||
|
-- (SILENCE_DB -30, thresholds 5-6s), so a muted bleep over a slowly-read phone
|
||||||
|
-- number can get stripped and shift everything after it out of sync. A tone at
|
||||||
|
-- TONE_GAIN_DB is nowhere near -30, so the silence pass leaves it alone.
|
||||||
|
--
|
||||||
|
-- Usage: make a time selection over the digits, select the track, run.
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
-- SETTINGS
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
local TONE_GAIN_DB = 0.0 -- adjust bleep level; file is generated at -15 dBFS
|
||||||
|
local FADE_MS = 4.0 -- fade in/out on the tone, prevents clicks
|
||||||
|
local TONE_FILE = "bleep_1khz.wav" -- 60s 1kHz sine @48k, sits next to this script
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local EPS = 1e-9
|
||||||
|
|
||||||
|
local function script_dir()
|
||||||
|
local src = debug.getinfo(1, "S").source
|
||||||
|
return src:match("@?(.*[/\\])") or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local function items_on(track)
|
||||||
|
local t = {}
|
||||||
|
for i = 0, reaper.CountTrackMediaItems(track) - 1 do
|
||||||
|
t[#t + 1] = reaper.GetTrackMediaItem(track, i)
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
local function item_bounds(item)
|
||||||
|
local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
|
||||||
|
return pos, pos + reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Split every item crossing either edge so the range is cleanly separable
|
||||||
|
local function split_at_edges(track, sel_start, sel_end)
|
||||||
|
for _, item in ipairs(items_on(track)) do
|
||||||
|
local pos, fin = item_bounds(item)
|
||||||
|
if fin > sel_start + EPS and pos < sel_end - EPS then
|
||||||
|
local right = item
|
||||||
|
if pos < sel_start - EPS then
|
||||||
|
right = reaper.SplitMediaItem(item, sel_start)
|
||||||
|
end
|
||||||
|
if right then
|
||||||
|
local rpos, rfin = item_bounds(right)
|
||||||
|
if rfin > sel_end + EPS and rpos < sel_end - EPS then
|
||||||
|
reaper.SplitMediaItem(right, sel_end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function delete_inside(track, sel_start, sel_end)
|
||||||
|
local removed = 0
|
||||||
|
for _, item in ipairs(items_on(track)) do
|
||||||
|
local pos, fin = item_bounds(item)
|
||||||
|
if pos >= sel_start - EPS and fin <= sel_end + EPS then
|
||||||
|
reaper.DeleteTrackMediaItem(track, item)
|
||||||
|
removed = removed + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return removed
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Build the item directly rather than via InsertMedia(): InsertMedia behaves
|
||||||
|
-- like the user-facing "insert media file" action — it obeys ripple editing
|
||||||
|
-- (shifting other tracks), can spawn a new track, and moves the edit cursor.
|
||||||
|
-- AddMediaItemToTrack touches nothing but this track.
|
||||||
|
local function insert_tone(track, sel_start, sel_len, tone_source)
|
||||||
|
local item = reaper.AddMediaItemToTrack(track)
|
||||||
|
if not item then return nil end
|
||||||
|
local take = reaper.AddTakeToMediaItem(item)
|
||||||
|
if not take then return nil end
|
||||||
|
reaper.SetMediaItemTake_Source(take, tone_source)
|
||||||
|
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "D_POSITION", sel_start)
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "D_LENGTH", sel_len)
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "B_LOOPSRC", 0)
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "D_VOL", 10 ^ (TONE_GAIN_DB / 20))
|
||||||
|
|
||||||
|
local fade = math.min(FADE_MS / 1000, sel_len / 2)
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "D_FADEINLEN", fade)
|
||||||
|
reaper.SetMediaItemInfo_Value(item, "D_FADEOUTLEN", fade)
|
||||||
|
return item
|
||||||
|
end
|
||||||
|
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function main()
|
||||||
|
local sel_start, sel_end = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
|
||||||
|
local sel_len = sel_end - sel_start
|
||||||
|
if sel_len <= 0 then
|
||||||
|
reaper.ShowMessageBox("Make a time selection over the audio to bleep.", "Bleep Selection", 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local n_tracks = reaper.CountSelectedTracks(0)
|
||||||
|
if n_tracks == 0 then
|
||||||
|
reaper.ShowMessageBox("Select the track to bleep.", "Bleep Selection", 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local tone_path = script_dir() .. TONE_FILE
|
||||||
|
local f = io.open(tone_path, "rb")
|
||||||
|
if not f then
|
||||||
|
reaper.ShowMessageBox("Tone file not found:\n" .. tone_path, "Bleep Selection", 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
f:close()
|
||||||
|
|
||||||
|
local tone_source = reaper.PCM_Source_CreateFromFile(tone_path)
|
||||||
|
if not tone_source then
|
||||||
|
reaper.ShowMessageBox("Could not load tone file:\n" .. tone_path, "Bleep Selection", 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local targets = {}
|
||||||
|
for i = 0, n_tracks - 1 do
|
||||||
|
targets[#targets + 1] = reaper.GetSelectedTrack(0, i)
|
||||||
|
end
|
||||||
|
|
||||||
|
reaper.Undo_BeginBlock()
|
||||||
|
reaper.PreventUIRefresh(1)
|
||||||
|
|
||||||
|
-- Ripple editing would shift unrelated items (and other tracks) when items
|
||||||
|
-- are removed. Force it off for the duration, restore the user's mode after.
|
||||||
|
local ripple_per_track = reaper.GetToggleCommandStateEx(0, 40310) == 1
|
||||||
|
local ripple_all = reaper.GetToggleCommandStateEx(0, 40311) == 1
|
||||||
|
if ripple_per_track or ripple_all then
|
||||||
|
reaper.Main_OnCommand(40309, 0) -- ripple editing off
|
||||||
|
end
|
||||||
|
|
||||||
|
local bleeped = 0
|
||||||
|
for _, track in ipairs(targets) do
|
||||||
|
split_at_edges(track, sel_start, sel_end)
|
||||||
|
delete_inside(track, sel_start, sel_end)
|
||||||
|
if insert_tone(track, sel_start, sel_len, tone_source) then
|
||||||
|
bleeped = bleeped + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if ripple_all then
|
||||||
|
reaper.Main_OnCommand(40311, 0)
|
||||||
|
elseif ripple_per_track then
|
||||||
|
reaper.Main_OnCommand(40310, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
reaper.PreventUIRefresh(-1)
|
||||||
|
reaper.UpdateArrange()
|
||||||
|
reaper.Undo_EndBlock(string.format("Bleep %.2fs on %d track(s)", sel_len, bleeped), -1)
|
||||||
|
end
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
-- Show Region Marker — background script for REAPER
|
||||||
|
-- Polls /tmp/reaper_state.txt for state changes and creates colored regions.
|
||||||
|
-- Backend writes "dialog", "ad", or "ident" to the file.
|
||||||
|
-- Run via Actions → Run ReaScript before or during recording.
|
||||||
|
|
||||||
|
local STATE_FILE = "/tmp/reaper_state.txt"
|
||||||
|
|
||||||
|
local COLORS = {
|
||||||
|
dialog = reaper.ColorToNative(50, 180, 50) + 0x1000000, -- green
|
||||||
|
ad = reaper.ColorToNative(200, 80, 80) + 0x1000000, -- red
|
||||||
|
ident = reaper.ColorToNative(80, 120, 200) + 0x1000000, -- blue
|
||||||
|
}
|
||||||
|
local LABELS = {
|
||||||
|
dialog = "DIALOG",
|
||||||
|
ad = "AD",
|
||||||
|
ident = "IDENT",
|
||||||
|
}
|
||||||
|
|
||||||
|
local counts = { dialog = 0, ad = 0, ident = 0 }
|
||||||
|
local current_type = nil -- which region type is currently open
|
||||||
|
local current_start = 0
|
||||||
|
local last_pos = 0 -- last known transport position (while running)
|
||||||
|
local last_state = ""
|
||||||
|
local transport_active = false
|
||||||
|
|
||||||
|
local function log(msg)
|
||||||
|
-- Silent by default — uncomment for debugging:
|
||||||
|
-- reaper.ShowConsoleMsg("[Regions] " .. msg .. "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function is_playing_or_recording()
|
||||||
|
local state = reaper.GetPlayState()
|
||||||
|
return state > 0 and state ~= 2
|
||||||
|
end
|
||||||
|
|
||||||
|
local function open_region(rtype)
|
||||||
|
if current_type then return end
|
||||||
|
current_type = rtype
|
||||||
|
current_start = reaper.GetPlayPosition()
|
||||||
|
log("OPEN " .. rtype .. " at " .. string.format("%.2f", current_start))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function close_region(pos_override)
|
||||||
|
if not current_type then return end
|
||||||
|
local pos = pos_override or reaper.GetPlayPosition()
|
||||||
|
local len = pos - current_start
|
||||||
|
local rtype = current_type
|
||||||
|
current_type = nil
|
||||||
|
log("CLOSE " .. rtype .. " at " .. string.format("%.2f", pos) .. " (len=" .. string.format("%.2f", len) .. ")")
|
||||||
|
if len > 0.1 then
|
||||||
|
counts[rtype] = counts[rtype] + 1
|
||||||
|
local name = LABELS[rtype] .. " " .. counts[rtype]
|
||||||
|
reaper.AddProjectMarker2(0, true, current_start, pos, name, -1, COLORS[rtype])
|
||||||
|
log(" -> Created '" .. name .. "'")
|
||||||
|
else
|
||||||
|
log(" -> Skipped (too short)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function poll()
|
||||||
|
if not transport_active then
|
||||||
|
if is_playing_or_recording() then
|
||||||
|
transport_active = true
|
||||||
|
log("Transport started at " .. string.format("%.2f", reaper.GetPlayPosition()))
|
||||||
|
local f = io.open(STATE_FILE, "r")
|
||||||
|
if f then
|
||||||
|
last_state = f:read("*l") or "dialog"
|
||||||
|
f:close()
|
||||||
|
else
|
||||||
|
last_state = "dialog"
|
||||||
|
end
|
||||||
|
log("Initial state: '" .. last_state .. "'")
|
||||||
|
open_region(last_state)
|
||||||
|
end
|
||||||
|
reaper.defer(poll)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Track position while transport is running
|
||||||
|
last_pos = reaper.GetPlayPosition()
|
||||||
|
|
||||||
|
-- Detect transport stop (recording ended) — use last known good position
|
||||||
|
if not is_playing_or_recording() then
|
||||||
|
log("Transport stopped at last known pos " .. string.format("%.2f", last_pos))
|
||||||
|
close_region(last_pos)
|
||||||
|
transport_active = false
|
||||||
|
reaper.defer(poll)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = io.open(STATE_FILE, "r")
|
||||||
|
if f then
|
||||||
|
local state = f:read("*l") or "dialog"
|
||||||
|
f:close()
|
||||||
|
|
||||||
|
if state ~= last_state then
|
||||||
|
log("State change: '" .. last_state .. "' -> '" .. state .. "'")
|
||||||
|
close_region()
|
||||||
|
open_region(state)
|
||||||
|
last_state = state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
reaper.defer(poll)
|
||||||
|
end
|
||||||
|
|
||||||
|
log("Script loaded — waiting for transport to start...")
|
||||||
|
|
||||||
|
reaper.atexit(function()
|
||||||
|
log("Script stopping (atexit)")
|
||||||
|
close_region()
|
||||||
|
local total = counts.dialog + counts.ad + counts.ident
|
||||||
|
log("Done. " .. total .. " regions (" .. counts.dialog .. " dialog, " .. counts.ad .. " ad, " .. counts.ident .. " ident)")
|
||||||
|
end)
|
||||||
|
|
||||||
|
poll()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
|||||||
|
-- Stub harness for reaper/bleep_selection.lua
|
||||||
|
-- Models tracks/items in memory and asserts the split/delete/insert behaviour.
|
||||||
|
|
||||||
|
local SCRIPT = (debug.getinfo(1,"S").source:match("@?(.*[/\\])") or "") .. "bleep_selection.lua"
|
||||||
|
|
||||||
|
local function new_item(pos, len, tag)
|
||||||
|
return { pos = pos, len = len, tag = tag or "audio", vol = 1, fin_ = 0, fout_ = 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
local W -- world
|
||||||
|
|
||||||
|
local function make_reaper()
|
||||||
|
return {
|
||||||
|
CountTrackMediaItems = function(tr) return #tr.items end,
|
||||||
|
GetTrackMediaItem = function(tr, i) return tr.items[i + 1] end,
|
||||||
|
GetMediaItemInfo_Value = function(it, k)
|
||||||
|
if k == "D_POSITION" then return it.pos end
|
||||||
|
if k == "D_LENGTH" then return it.len end
|
||||||
|
return 0
|
||||||
|
end,
|
||||||
|
SetMediaItemInfo_Value = function(it, k, v)
|
||||||
|
if k == "D_POSITION" then it.pos = v
|
||||||
|
elseif k == "D_LENGTH" then it.len = v
|
||||||
|
elseif k == "D_VOL" then it.vol = v
|
||||||
|
elseif k == "D_FADEINLEN" then it.fin_ = v
|
||||||
|
elseif k == "D_FADEOUTLEN" then it.fout_ = v end
|
||||||
|
end,
|
||||||
|
SplitMediaItem = function(it, at)
|
||||||
|
local tr
|
||||||
|
for _, t in ipairs(W.tracks) do
|
||||||
|
for idx, x in ipairs(t.items) do if x == it then tr = t; it_idx = idx end end
|
||||||
|
end
|
||||||
|
if not tr then return nil end
|
||||||
|
if at <= it.pos or at >= it.pos + it.len then return nil end
|
||||||
|
local right = new_item(at, it.pos + it.len - at, it.tag)
|
||||||
|
it.len = at - it.pos
|
||||||
|
local pos_in = 0
|
||||||
|
for idx, x in ipairs(tr.items) do if x == it then pos_in = idx end end
|
||||||
|
table.insert(tr.items, pos_in + 1, right)
|
||||||
|
W.splits = W.splits + 1
|
||||||
|
return right
|
||||||
|
end,
|
||||||
|
DeleteTrackMediaItem = function(tr, it)
|
||||||
|
for idx, x in ipairs(tr.items) do
|
||||||
|
if x == it then table.remove(tr.items, idx); W.deletes = W.deletes + 1; return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
InsertMedia = function() W.forbidden["InsertMedia"] = true; return 1 end,
|
||||||
|
GetSelectedMediaItem = function(_, _) return W.last_inserted end,
|
||||||
|
SetOnlyTrackSelected = function(tr) W.forbidden["SetOnlyTrackSelected"] = true end,
|
||||||
|
SetTrackSelected = function() W.forbidden["SetTrackSelected"] = true end,
|
||||||
|
SetEditCurPos = function(p) W.forbidden["SetEditCurPos"] = true end,
|
||||||
|
PCM_Source_CreateFromFile = function(path) W.src_path = path; return { src = path } end,
|
||||||
|
AddMediaItemToTrack = function(tr)
|
||||||
|
local it = new_item(0, 0, "tone"); table.insert(tr.items, it)
|
||||||
|
W.last_inserted = it; it.owner = tr; return it
|
||||||
|
end,
|
||||||
|
AddTakeToMediaItem = function(it) it.take = { item = it }; return it.take end,
|
||||||
|
SetMediaItemTake_Source = function(take, src) take.src = src end,
|
||||||
|
GetToggleCommandStateEx = function(_, cmd) return W.ripple[cmd] and 1 or 0 end,
|
||||||
|
Main_OnCommand = function(cmd) W.commands[#W.commands + 1] = cmd end,
|
||||||
|
GetSet_LoopTimeRange = function() return W.sel_start, W.sel_end end,
|
||||||
|
CountSelectedTracks = function() return #W.sel_tracks end,
|
||||||
|
GetSelectedTrack = function(_, i) return W.sel_tracks[i + 1] end,
|
||||||
|
Undo_BeginBlock = function() end,
|
||||||
|
Undo_EndBlock = function(desc) W.undo = desc end,
|
||||||
|
PreventUIRefresh = function() end,
|
||||||
|
UpdateArrange = function() end,
|
||||||
|
ShowMessageBox = function(msg) W.msg = msg end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function run(setup)
|
||||||
|
W = { tracks = {}, sel_tracks = {}, splits = 0, deletes = 0,
|
||||||
|
inserted_paths = {}, edit_cur = 0, msg = nil, undo = nil,
|
||||||
|
forbidden = {}, ripple = {}, commands = {} }
|
||||||
|
setup(W)
|
||||||
|
reaper = make_reaper()
|
||||||
|
local fn = assert(loadfile(SCRIPT))
|
||||||
|
fn()
|
||||||
|
return W
|
||||||
|
end
|
||||||
|
|
||||||
|
local function track(items)
|
||||||
|
local t = { items = {} }
|
||||||
|
for _, it in ipairs(items) do t.items[#t.items + 1] = new_item(it[1], it[2]) end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
local pass, fail = 0, 0
|
||||||
|
local function check(name, cond, detail)
|
||||||
|
if cond then pass = pass + 1; print((" PASS %s"):format(name))
|
||||||
|
else fail = fail + 1; print((" FAIL %s -- %s"):format(name, detail or "")) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function layout(tr)
|
||||||
|
local s = {}
|
||||||
|
for _, it in ipairs(tr.items) do
|
||||||
|
s[#s + 1] = ("%s[%.2f..%.2f]"):format(it.tag == "tone" and "T" or "A", it.pos, it.pos + it.len)
|
||||||
|
end
|
||||||
|
return table.concat(s, " ")
|
||||||
|
end
|
||||||
|
|
||||||
|
print("\n1) Selection inside one long item -> split x2, middle deleted, tone inserted")
|
||||||
|
local w = run(function(W)
|
||||||
|
local t = track({ {0, 30} })
|
||||||
|
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||||
|
end)
|
||||||
|
local t1 = w.tracks[1]
|
||||||
|
check("two splits", w.splits == 2, "splits=" .. w.splits)
|
||||||
|
check("one delete", w.deletes == 1, "deletes=" .. w.deletes)
|
||||||
|
check("tone inserted", w.last_inserted ~= nil)
|
||||||
|
check("tone spans selection", math.abs(w.last_inserted.pos - 10) < 1e-6
|
||||||
|
and math.abs(w.last_inserted.len - 5) < 1e-6,
|
||||||
|
("pos=%.3f len=%.3f"):format(w.last_inserted.pos, w.last_inserted.len))
|
||||||
|
check("fades applied", w.last_inserted.fin_ > 0 and w.last_inserted.fout_ > 0)
|
||||||
|
check("no audio left inside range", (function()
|
||||||
|
for _, it in ipairs(t1.items) do
|
||||||
|
if it.tag == "audio" and it.pos >= 10 - 1e-6 and it.pos + it.len <= 15 + 1e-6 then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end)())
|
||||||
|
print(" layout: " .. layout(t1))
|
||||||
|
|
||||||
|
print("\n2) Item entirely inside selection -> deleted outright")
|
||||||
|
w = run(function(W)
|
||||||
|
local t = track({ {11, 2} })
|
||||||
|
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||||
|
end)
|
||||||
|
check("no splits needed", w.splits == 0, "splits=" .. w.splits)
|
||||||
|
check("deleted", w.deletes == 1, "deletes=" .. w.deletes)
|
||||||
|
print(" layout: " .. layout(w.tracks[1]))
|
||||||
|
|
||||||
|
print("\n3) Items straddling each edge only")
|
||||||
|
w = run(function(W)
|
||||||
|
local t = track({ {5, 7}, {13, 6} }) -- 5..12 and 13..19, selection 10..15
|
||||||
|
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||||
|
end)
|
||||||
|
local t3 = w.tracks[1]
|
||||||
|
check("split each straddler once", w.splits == 2, "splits=" .. w.splits)
|
||||||
|
check("both inner halves deleted", w.deletes == 2, "deletes=" .. w.deletes)
|
||||||
|
check("audio before survives", (function()
|
||||||
|
for _, it in ipairs(t3.items) do
|
||||||
|
if it.tag == "audio" and math.abs(it.pos - 5) < 1e-6 and math.abs(it.len - 5) < 1e-6 then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end)())
|
||||||
|
print(" layout: " .. layout(t3))
|
||||||
|
|
||||||
|
print("\n4) Item entirely outside selection -> untouched")
|
||||||
|
w = run(function(W)
|
||||||
|
local t = track({ {20, 5} })
|
||||||
|
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||||
|
end)
|
||||||
|
check("no splits", w.splits == 0)
|
||||||
|
check("no deletes", w.deletes == 0)
|
||||||
|
check("still one audio item + tone", #w.tracks[1].items == 2, "n=" .. #w.tracks[1].items)
|
||||||
|
|
||||||
|
print("\n5) Only the selected track is touched")
|
||||||
|
w = run(function(W)
|
||||||
|
local a, b = track({ {0, 30} }), track({ {0, 30} })
|
||||||
|
W.tracks = { a, b }; W.sel_tracks = { a }; W.sel_start, W.sel_end = 10, 15
|
||||||
|
end)
|
||||||
|
check("other track untouched", #w.tracks[2].items == 1, "n=" .. #w.tracks[2].items)
|
||||||
|
check("selected track modified", #w.tracks[1].items > 1)
|
||||||
|
|
||||||
|
print("\n6) Guard rails")
|
||||||
|
w = run(function(W)
|
||||||
|
local t = track({ {0, 30} })
|
||||||
|
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 10 -- empty selection
|
||||||
|
end)
|
||||||
|
check("aborts with message on empty selection", w.msg ~= nil and w.splits == 0, tostring(w.msg))
|
||||||
|
|
||||||
|
w = run(function(W)
|
||||||
|
local t = track({ {0, 30} })
|
||||||
|
W.tracks = { t }; W.sel_tracks = {}; W.sel_start, W.sel_end = 10, 15 -- no track selected
|
||||||
|
end)
|
||||||
|
check("aborts with message on no track", w.msg ~= nil and w.splits == 0, tostring(w.msg))
|
||||||
|
|
||||||
|
print("\n7) Never uses project-wide / UI-level calls that move other tracks")
|
||||||
|
w = run(function(W)
|
||||||
|
local a, b = track({ {0, 30} }), track({ {0, 30} })
|
||||||
|
W.tracks = { a, b }; W.sel_tracks = { a }; W.sel_start, W.sel_end = 10, 15
|
||||||
|
end)
|
||||||
|
check("no InsertMedia (ripples + can spawn tracks)", not w.forbidden["InsertMedia"])
|
||||||
|
check("does not move edit cursor", not w.forbidden["SetEditCurPos"])
|
||||||
|
check("does not change track selection", not w.forbidden["SetOnlyTrackSelected"]
|
||||||
|
and not w.forbidden["SetTrackSelected"])
|
||||||
|
check("built item from PCM source", w.src_path ~= nil and w.last_inserted.take ~= nil)
|
||||||
|
check("tone landed on the SELECTED track", w.last_inserted.owner == w.tracks[1])
|
||||||
|
|
||||||
|
print("\n8) Ripple editing forced off, then restored")
|
||||||
|
w = run(function(W)
|
||||||
|
local t = track({ {0, 30} })
|
||||||
|
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||||
|
W.ripple[40311] = true -- user had "ripple all tracks" on
|
||||||
|
end)
|
||||||
|
check("turned ripple off first", w.commands[1] == 40309, "cmds=" .. table.concat(w.commands, ","))
|
||||||
|
check("restored ripple-all after", w.commands[#w.commands] == 40311,
|
||||||
|
"cmds=" .. table.concat(w.commands, ","))
|
||||||
|
|
||||||
|
w = run(function(W)
|
||||||
|
local t = track({ {0, 30} })
|
||||||
|
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||||
|
W.ripple[40310] = true -- per-track ripple
|
||||||
|
end)
|
||||||
|
check("restored per-track ripple", w.commands[1] == 40309 and w.commands[#w.commands] == 40310,
|
||||||
|
"cmds=" .. table.concat(w.commands, ","))
|
||||||
|
|
||||||
|
w = run(function(W)
|
||||||
|
local t = track({ {0, 30} })
|
||||||
|
W.tracks = { t }; W.sel_tracks = { t }; W.sel_start, W.sel_end = 10, 15
|
||||||
|
end)
|
||||||
|
check("ripple already off -> no mode changes at all", #w.commands == 0,
|
||||||
|
"cmds=" .. table.concat(w.commands, ","))
|
||||||
|
|
||||||
|
print(("\n%d passed, %d failed\n"):format(pass, fail))
|
||||||
|
os.exit(fail == 0 and 0 or 1)
|
||||||
@@ -8,7 +8,7 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
API_KEY = os.getenv("OPENROUTER_API_KEY")
|
API_KEY = os.getenv("OPENROUTER_API_KEY")
|
||||||
TRANSCRIPT_DIR = Path(__file__).parent / "website" / "transcripts"
|
TRANSCRIPT_DIR = Path(__file__).parent / "website" / "transcripts"
|
||||||
MODEL = "anthropic/claude-3.5-sonnet"
|
MODEL = "anthropic/claude-sonnet-4.6"
|
||||||
CHUNK_SIZE = 8000
|
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.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Scan music directory for tracks that contain vocals/lyrics.
|
||||||
|
|
||||||
|
Uses Whisper to transcribe a sample from each track — if it picks up
|
||||||
|
actual words, the track likely has vocals.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scan_music_vocals.py # scan and report
|
||||||
|
python scan_music_vocals.py --delete # scan and delete vocal tracks
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import librosa
|
||||||
|
import numpy as np
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
MUSIC_DIR = Path(__file__).parent / "music"
|
||||||
|
WHISPER_MODEL = "distil-large-v3"
|
||||||
|
|
||||||
|
# Words Whisper hallucinates on silence/instrumental — ignore these
|
||||||
|
HALLUCINATION_PHRASES = {
|
||||||
|
"thank you", "thanks for watching", "subscribe", "like and subscribe",
|
||||||
|
"please subscribe", "thank you for watching", "thanks for listening",
|
||||||
|
"you", "the end", "bye", "okay",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_track(model: WhisperModel, filepath: Path) -> tuple[bool, str]:
|
||||||
|
"""Check a single track for vocals. Returns (has_vocals, transcription)."""
|
||||||
|
try:
|
||||||
|
audio, sr = librosa.load(str(filepath), sr=16000, mono=True)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"[load error: {e}]"
|
||||||
|
|
||||||
|
duration = len(audio) / sr
|
||||||
|
if duration < 10:
|
||||||
|
return False, "[too short]"
|
||||||
|
|
||||||
|
# Sample 30s from the middle (most likely to have vocals)
|
||||||
|
mid = len(audio) // 2
|
||||||
|
half_window = int(15 * sr) # 15s each side
|
||||||
|
start = max(0, mid - half_window)
|
||||||
|
end = min(len(audio), mid + half_window)
|
||||||
|
sample = audio[start:end]
|
||||||
|
|
||||||
|
segments, info = model.transcribe(
|
||||||
|
sample,
|
||||||
|
beam_size=3,
|
||||||
|
language="en",
|
||||||
|
vad_filter=True,
|
||||||
|
vad_parameters=dict(min_speech_duration_ms=500),
|
||||||
|
)
|
||||||
|
segments_list = list(segments)
|
||||||
|
text = " ".join(s.text for s in segments_list).strip()
|
||||||
|
|
||||||
|
# Filter out Whisper hallucinations
|
||||||
|
text_lower = text.lower().strip()
|
||||||
|
if text_lower in HALLUCINATION_PHRASES or len(text_lower) < 4:
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
# If Whisper found substantial text, it's likely vocals
|
||||||
|
word_count = len(text.split())
|
||||||
|
has_vocals = word_count >= 3
|
||||||
|
|
||||||
|
return has_vocals, text
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Scan music for vocal tracks")
|
||||||
|
parser.add_argument("--delete", action="store_true", help="Delete tracks with vocals")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
audio_files = sorted(
|
||||||
|
f for f in MUSIC_DIR.iterdir()
|
||||||
|
if f.suffix.lower() in {".mp3", ".wav", ".ogg", ".flac"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not audio_files:
|
||||||
|
print("No audio files found in music/")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Loading Whisper {WHISPER_MODEL}...")
|
||||||
|
model = WhisperModel(WHISPER_MODEL, device="cpu", compute_type="int8")
|
||||||
|
|
||||||
|
print(f"Scanning {len(audio_files)} tracks for vocals...\n")
|
||||||
|
|
||||||
|
vocal_tracks = []
|
||||||
|
for i, f in enumerate(audio_files, 1):
|
||||||
|
print(f"[{i}/{len(audio_files)}] {f.name}...", end=" ", flush=True)
|
||||||
|
has_vocals, text = scan_track(model, f)
|
||||||
|
if has_vocals:
|
||||||
|
print(f"VOCALS: {text[:80]}")
|
||||||
|
vocal_tracks.append((f, text))
|
||||||
|
else:
|
||||||
|
print("OK")
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Results: {len(vocal_tracks)} tracks with vocals out of {len(audio_files)}\n")
|
||||||
|
|
||||||
|
if not vocal_tracks:
|
||||||
|
print("All tracks appear to be instrumental!")
|
||||||
|
return
|
||||||
|
|
||||||
|
for f, text in vocal_tracks:
|
||||||
|
print(f" {f.name}")
|
||||||
|
print(f" Lyrics: {text[:120]}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.delete:
|
||||||
|
print(f"Deleting {len(vocal_tracks)} vocal tracks...")
|
||||||
|
for f, _ in vocal_tracks:
|
||||||
|
f.unlink()
|
||||||
|
print(f" Deleted: {f.name}")
|
||||||
|
print("Done.")
|
||||||
|
else:
|
||||||
|
print("Run with --delete to remove these tracks.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Schedule the X/Twitter launch campaign posts via Postiz.
|
||||||
|
|
||||||
|
Schedules 2 weeks of posts from the growth strategy to @lukeattheroost.
|
||||||
|
All times are ET, converted to UTC for the Postiz API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python schedule_x_launch.py # schedule all posts
|
||||||
|
python schedule_x_launch.py --dry-run # preview without scheduling
|
||||||
|
python schedule_x_launch.py --week 1 # schedule week 1 only
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Load .env
|
||||||
|
env_path = Path(__file__).parent / ".env"
|
||||||
|
if env_path.exists():
|
||||||
|
for line in env_path.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
os.environ.setdefault(key.strip(), value.strip())
|
||||||
|
|
||||||
|
POSTIZ_API_KEY = os.getenv("POSTIZ_API_KEY")
|
||||||
|
POSTIZ_URL = os.getenv("POSTIZ_URL", "https://social.lukeattheroost.com")
|
||||||
|
|
||||||
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
|
X_INTEGRATION_ID = "cmlk4hi880001k76wbqjo21s0"
|
||||||
|
|
||||||
|
# ET = UTC-4 (EDT) in March 2026
|
||||||
|
ET_OFFSET = timedelta(hours=-4)
|
||||||
|
|
||||||
|
|
||||||
|
def et_to_utc(year, month, day, hour, minute=0):
|
||||||
|
"""Convert ET datetime to UTC ISO string for Postiz."""
|
||||||
|
et = datetime(year, month, day, hour, minute, tzinfo=timezone(ET_OFFSET))
|
||||||
|
utc = et.astimezone(timezone.utc)
|
||||||
|
return utc.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_url(path):
|
||||||
|
return f"{POSTIZ_URL.rstrip('/')}/api/public/v1{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def api_headers():
|
||||||
|
return {"Authorization": POSTIZ_API_KEY, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file(file_path):
|
||||||
|
headers = {"Authorization": POSTIZ_API_KEY}
|
||||||
|
suffix = file_path.suffix.lower()
|
||||||
|
if suffix == ".mp4":
|
||||||
|
mime = "video/mp4"
|
||||||
|
elif suffix in (".jpg", ".jpeg"):
|
||||||
|
mime = "image/jpeg"
|
||||||
|
else:
|
||||||
|
mime = "image/png"
|
||||||
|
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/upload"),
|
||||||
|
headers=headers,
|
||||||
|
files={"file": (file_path.name, f, mime)},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
print(f" Upload failed: {resp.status_code} {resp.text[:200]}")
|
||||||
|
return {}
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_post(content, media, schedule_time, retries=3):
|
||||||
|
payload = {
|
||||||
|
"type": "schedule",
|
||||||
|
"date": schedule_time,
|
||||||
|
"shortLink": False,
|
||||||
|
"tags": [],
|
||||||
|
"posts": [
|
||||||
|
{
|
||||||
|
"integration": {"id": X_INTEGRATION_ID},
|
||||||
|
"value": [
|
||||||
|
{
|
||||||
|
"content": content,
|
||||||
|
"image": [media] if media else [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {"__type": "x", "who_can_reply_post": "everyone"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for attempt in range(retries):
|
||||||
|
resp = requests.post(
|
||||||
|
get_api_url("/posts"),
|
||||||
|
headers=api_headers(),
|
||||||
|
json=payload,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
return resp.json()
|
||||||
|
if resp.status_code == 429 and attempt < retries - 1:
|
||||||
|
wait = 15 * (attempt + 1)
|
||||||
|
print(f"(rate limited, waiting {wait}s)...", end=" ", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
print(f" Schedule failed: {resp.status_code} {resp.text[:300]}")
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST DEFINITIONS ─────────────────────────────────────────────────
|
||||||
|
# Day 1 = Monday March 17, 2026
|
||||||
|
|
||||||
|
WEEK_1 = [
|
||||||
|
# Day 1 — Monday March 17
|
||||||
|
{
|
||||||
|
"label": "W1-Mon-AM (pinned intro)",
|
||||||
|
"time": et_to_utc(2026, 3, 17, 10),
|
||||||
|
"content": """Every caller on my show is AI-generated.
|
||||||
|
|
||||||
|
Every personality. Every voice. Every problem they call in about.
|
||||||
|
|
||||||
|
But the conversations are real, the advice is real, and the chaos is very real.
|
||||||
|
|
||||||
|
38 episodes. 200+ callers. A cult leader, a guy who opened paternity results live on air, and someone who faked cancer to skip a wedding.
|
||||||
|
|
||||||
|
This is Luke at the Roost.
|
||||||
|
|
||||||
|
📞 208-439-LUKE (real humans can call in too)
|
||||||
|
🔗 lukeattheroost.com""",
|
||||||
|
"media": "website/images/cover.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Mon-PM (chili clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 17, 14),
|
||||||
|
"content": """A guy called in to talk about chili contest cheaters.
|
||||||
|
|
||||||
|
Turns out he was really calling about his failing marriage.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast #callinshow""",
|
||||||
|
"media": "clips/episode-37/clip-1-chili-contest-cheaters-marriage-troubles.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Mon-EVE (intro thread)",
|
||||||
|
"time": et_to_utc(2026, 3, 17, 21),
|
||||||
|
"content": """People keep asking what Luke at the Roost is.
|
||||||
|
|
||||||
|
Short version:
|
||||||
|
→ AI characters call into my show with real problems
|
||||||
|
→ I give them actual advice
|
||||||
|
→ Everything goes off the rails
|
||||||
|
→ New episode every day
|
||||||
|
|
||||||
|
lukeattheroost.com""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
# Day 2 — Tuesday March 18
|
||||||
|
{
|
||||||
|
"label": "W1-Tue-AM (hospice clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 18, 12),
|
||||||
|
"content": """A caller's mom is in hospice. The nurse asked her to think about final conversations.
|
||||||
|
|
||||||
|
Her only wish? To see her kids eat cake together one last time.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-30/clip-2-mom-s-dying-wish-just-eat-cake-together.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Tue-PM (engagement)",
|
||||||
|
"time": et_to_utc(2026, 3, 18, 19),
|
||||||
|
"content": """What's the wildest thing you've ever called into a radio show about?
|
||||||
|
|
||||||
|
(Or wanted to but chickened out?)""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
# Day 3 — Wednesday March 19
|
||||||
|
{
|
||||||
|
"label": "W1-Wed-AM (cancer clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 19, 11),
|
||||||
|
"content": """This caller faked having cancer to get out of going to a wedding.
|
||||||
|
|
||||||
|
Then his friends staged a coffee enema intervention.
|
||||||
|
|
||||||
|
I can't make this up. (Well, the AI did.)
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-32/clip-1-i-faked-cancer-to-skip-a-wedding.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Wed-PM (fakes clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 19, 16),
|
||||||
|
"content": """"Everybody is a fake. We're all fakes. Nobody knows what's going on and none of us deserve a goddamn thing. We're lucky to be here at all."
|
||||||
|
|
||||||
|
— A caller on Episode 2. Still the hardest truth anyone's dropped on my show.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-2/clip-1-we-re-all-fakes-and-that-s-okay.mp4",
|
||||||
|
},
|
||||||
|
# Day 4 — Thursday March 20
|
||||||
|
{
|
||||||
|
"label": "W1-Thu-AM (BTS)",
|
||||||
|
"time": et_to_utc(2026, 3, 20, 12),
|
||||||
|
"content": """How my show works:
|
||||||
|
|
||||||
|
→ AI generates a caller with a full backstory, personality, and voice
|
||||||
|
→ They call in live
|
||||||
|
→ I have zero idea what they're going to say
|
||||||
|
→ I give them real advice
|
||||||
|
→ Post-production runs automatically
|
||||||
|
→ Episode publishes
|
||||||
|
|
||||||
|
38 episodes. Built the whole thing from scratch.""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Thu-PM (stakeout clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 20, 20),
|
||||||
|
"content": """He spent four hours staking out his best friend at Starbucks.
|
||||||
|
|
||||||
|
What he found was worse than what he expected.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-28/clip-3-four-hours-spying-on-his-best-friend.mp4",
|
||||||
|
},
|
||||||
|
# Day 5 — Friday March 21
|
||||||
|
{
|
||||||
|
"label": "W1-Fri-AM (review ask)",
|
||||||
|
"time": et_to_utc(2026, 3, 21, 11),
|
||||||
|
"content": """If you've listened to Luke at the Roost and liked it — a review on Apple Podcasts or Spotify goes further than you'd think.
|
||||||
|
|
||||||
|
Not guilt-tripping. Just saying it helps a one-person show more than anything else.
|
||||||
|
|
||||||
|
🔗 lukeattheroost.com""",
|
||||||
|
"media": "social_posts/x_launch/leave_a_review_twitter.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Fri-PM (wall clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 21, 18),
|
||||||
|
"content": """She opened up a mystery wall in her house LIVE on the show.
|
||||||
|
|
||||||
|
There were stacks of cash inside.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-35/clip-3-woman-finds-cash-in-secret-wall.mp4",
|
||||||
|
},
|
||||||
|
# Day 6 — Saturday March 22
|
||||||
|
{
|
||||||
|
"label": "W1-Sat-AM (poll)",
|
||||||
|
"time": et_to_utc(2026, 3, 22, 12),
|
||||||
|
"content": """Which is wilder?
|
||||||
|
|
||||||
|
A) Woman hid her daughter from her husband for 8 years
|
||||||
|
B) Cult leader existential crisis on air
|
||||||
|
C) Paternity results opened live on the show
|
||||||
|
D) Faked cancer to skip a wedding""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W1-Sat-PM (silence clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 22, 20),
|
||||||
|
"content": """"I told my girlfriend my biggest fantasy and she went completely silent for 10 seconds."
|
||||||
|
|
||||||
|
The silence in this clip is brutal.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-30/clip-3-latex-fetish-confession-goes-silent.mp4",
|
||||||
|
},
|
||||||
|
# Day 7 — Sunday March 23
|
||||||
|
{
|
||||||
|
"label": "W1-Sun-PM (week recap)",
|
||||||
|
"time": et_to_utc(2026, 3, 23, 15),
|
||||||
|
"content": """One week on X. Dropped 38 episodes before making an account.
|
||||||
|
|
||||||
|
If any of these clips made you laugh, cringe, or feel something — the full episodes are even wilder.
|
||||||
|
|
||||||
|
📞 208-439-LUKE
|
||||||
|
🔗 lukeattheroost.com
|
||||||
|
🎧 Spotify · Apple · YouTube""",
|
||||||
|
"media": "website/images/cover.png",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WEEK_2 = [
|
||||||
|
# Day 8 — Monday March 24
|
||||||
|
{
|
||||||
|
"label": "W2-Mon-AM (second family clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 24, 10),
|
||||||
|
"content": """A guy called in and found out his dad had a whole second family.
|
||||||
|
|
||||||
|
Three kids in Tucson who grew up calling his dad "Dad."
|
||||||
|
|
||||||
|
He found out via email from a stranger.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-20/clip-2-dad-s-secret-second-family-revealed.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Mon-PM (engagement)",
|
||||||
|
"time": et_to_utc(2026, 3, 24, 19),
|
||||||
|
"content": """What would you do if you found out your dad had a whole second family?
|
||||||
|
|
||||||
|
Asking because a caller found out via email from a woman in Tucson.""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
# Day 9 — Tuesday March 25
|
||||||
|
{
|
||||||
|
"label": "W2-Tue-AM (spanish clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 25, 11),
|
||||||
|
"content": """A caller pretended to speak Spanish at his job for 8 years.
|
||||||
|
|
||||||
|
Eight. Years.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-14/clip-1-i-lied-about-speaking-spanish-for-8-years.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Tue-PM (quote)",
|
||||||
|
"time": et_to_utc(2026, 3, 25, 18),
|
||||||
|
"content": """"Middle management is plagiarism with a 401k."
|
||||||
|
|
||||||
|
AI callers drop better one-liners than most standup specials.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "social_posts/x_launch/quote_3_stoicism_backwards_twitter.png",
|
||||||
|
},
|
||||||
|
# Day 10 — Wednesday March 26
|
||||||
|
{
|
||||||
|
"label": "W2-Wed-AM (BTS)",
|
||||||
|
"time": et_to_utc(2026, 3, 26, 12),
|
||||||
|
"content": """People ask if I script the show.
|
||||||
|
|
||||||
|
I don't even know who's calling until they're on the line. The AI generates the caller, picks a unique voice, gives them a backstory, and dials in.
|
||||||
|
|
||||||
|
My job is just to be a good host. The chaos handles itself.""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Wed-PM (roomba clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 26, 20),
|
||||||
|
"content": """His neighbor's Roomba broke into his kitchen at 2:30 AM.
|
||||||
|
|
||||||
|
This is the content you're here for.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-26/clip-2-neighbor-s-roomba-breaks-into-kitchen-at-2-30-am.mp4",
|
||||||
|
},
|
||||||
|
# Day 11 — Thursday March 27
|
||||||
|
{
|
||||||
|
"label": "W2-Thu-AM (stalking clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 27, 11),
|
||||||
|
"content": """She sat in a Dairy Queen parking lot for 20 minutes watching her ex's truck at Sonic across the street.
|
||||||
|
|
||||||
|
We've all been there. (Right?)
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-22/clip-2-stalking-your-ex-at-sonic.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Thu-PM (engagement)",
|
||||||
|
"time": et_to_utc(2026, 3, 27, 19),
|
||||||
|
"content": """Be honest: what's a lie you've kept going for way too long?
|
||||||
|
|
||||||
|
A caller on my show pretended to speak Spanish at work for 8 years. You can't beat that.""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
# Day 12 — Friday March 28
|
||||||
|
{
|
||||||
|
"label": "W2-Fri-AM (hidden room clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 28, 10),
|
||||||
|
"content": """A man found an impossible hidden room in a junkyard.
|
||||||
|
|
||||||
|
Inside? Beer that was still fresh after 12 years.
|
||||||
|
|
||||||
|
Two clips. One mystery.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-34/clip-1-man-finds-impossible-hidden-room-in-junkyard.mp4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Fri-PM (fix quote)",
|
||||||
|
"time": et_to_utc(2026, 3, 28, 18),
|
||||||
|
"content": """"You can't fix somebody who doesn't want to be fixed."
|
||||||
|
|
||||||
|
A caller said this about their partner and it hit like a truck.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-30/clip-1-you-can-t-fix-someone-who-won-t-be-fixed.mp4",
|
||||||
|
},
|
||||||
|
# Day 13 — Saturday March 29
|
||||||
|
{
|
||||||
|
"label": "W2-Sat-AM (BTS callers)",
|
||||||
|
"time": et_to_utc(2026, 3, 29, 12),
|
||||||
|
"content": """Each AI caller gets generated with:
|
||||||
|
|
||||||
|
• A name, age, job, and hometown
|
||||||
|
• A reason for calling
|
||||||
|
• A communication style + energy level
|
||||||
|
• An emotional state
|
||||||
|
• A "signature detail" that makes them unique
|
||||||
|
• A unique AI voice
|
||||||
|
|
||||||
|
None of it is scripted. They just... call in and talk.""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "W2-Sat-PM (check clip)",
|
||||||
|
"time": et_to_utc(2026, 3, 29, 20),
|
||||||
|
"content": """He deposited a $5,000 check instead of $500 three months ago.
|
||||||
|
|
||||||
|
Spent it all.
|
||||||
|
|
||||||
|
Now his company might find out.
|
||||||
|
|
||||||
|
#lukeattheroost #podcast""",
|
||||||
|
"media": "clips/episode-25/clip-2-accidentally-kept-4-500-from-work.mp4",
|
||||||
|
},
|
||||||
|
# Day 14 — Sunday March 30
|
||||||
|
{
|
||||||
|
"label": "W2-Sun-PM (week 2 recap + review)",
|
||||||
|
"time": et_to_utc(2026, 3, 30, 15),
|
||||||
|
"content": """Two weeks in. Thank you to everyone who's followed, listened, or dropped a comment.
|
||||||
|
|
||||||
|
This show started as a weird experiment — a guy giving life advice to AI-generated callers at 2 AM.
|
||||||
|
|
||||||
|
38 episodes later it's still weird. But now people are listening.
|
||||||
|
|
||||||
|
If you've been enjoying it, a rating on Apple or Spotify makes a real difference. 🙏
|
||||||
|
|
||||||
|
lukeattheroost.com""",
|
||||||
|
"media": None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Schedule X launch campaign via Postiz")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Preview without scheduling")
|
||||||
|
parser.add_argument("--week", type=int, choices=[1, 2], help="Schedule only week 1 or 2")
|
||||||
|
parser.add_argument("--skip", type=int, default=0, help="Skip first N posts (for retrying after partial run)")
|
||||||
|
parser.add_argument("--delay", type=int, default=10, help="Seconds between API calls (default 10)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not POSTIZ_API_KEY:
|
||||||
|
print("Error: POSTIZ_API_KEY not set in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
all_posts = []
|
||||||
|
if args.week != 2:
|
||||||
|
all_posts.extend(WEEK_1)
|
||||||
|
if args.week != 1:
|
||||||
|
all_posts.extend(WEEK_2)
|
||||||
|
posts = all_posts[args.skip:]
|
||||||
|
|
||||||
|
print(f"\n=== X Launch Campaign — {len(posts)} posts ===\n")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
for i, post in enumerate(posts, 1):
|
||||||
|
has_media = "📎" if post["media"] else " "
|
||||||
|
print(f" {i:2d}. {has_media} {post['label']}")
|
||||||
|
print(f" Schedule: {post['time']}")
|
||||||
|
preview = post["content"][:80].replace("\n", " ")
|
||||||
|
print(f" Content: {preview}...")
|
||||||
|
if post["media"]:
|
||||||
|
print(f" Media: {post['media']}")
|
||||||
|
print()
|
||||||
|
print(f"Dry run complete — {len(posts)} posts would be scheduled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Upload media files first (deduplicate), with disk cache
|
||||||
|
cache_file = SCRIPT_DIR / "social_posts" / "x_launch" / ".upload_cache.json"
|
||||||
|
media_cache = {}
|
||||||
|
if cache_file.exists():
|
||||||
|
media_cache = json.loads(cache_file.read_text())
|
||||||
|
print(f"Loaded {len(media_cache)} cached uploads from previous run\n")
|
||||||
|
|
||||||
|
media_files = set(p["media"] for p in posts if p["media"])
|
||||||
|
to_upload = [m for m in sorted(media_files) if m not in media_cache]
|
||||||
|
|
||||||
|
if to_upload:
|
||||||
|
print(f"Uploading {len(to_upload)} new media files ({len(media_files) - len(to_upload)} cached)...\n")
|
||||||
|
for media_path in to_upload:
|
||||||
|
full_path = SCRIPT_DIR / media_path
|
||||||
|
if not full_path.exists():
|
||||||
|
print(f" ✗ {media_path} — FILE NOT FOUND, skipping")
|
||||||
|
continue
|
||||||
|
print(f" Uploading {media_path}...", end=" ", flush=True)
|
||||||
|
result = upload_file(full_path)
|
||||||
|
if result:
|
||||||
|
media_cache[media_path] = result
|
||||||
|
cache_file.write_text(json.dumps(media_cache, indent=2))
|
||||||
|
print("✓")
|
||||||
|
else:
|
||||||
|
print("✗ FAILED")
|
||||||
|
time.sleep(3)
|
||||||
|
else:
|
||||||
|
print(f"All {len(media_files)} media files already cached, skipping uploads\n")
|
||||||
|
|
||||||
|
# Schedule posts
|
||||||
|
print(f"\nScheduling {len(posts)} posts to X...\n")
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
failed = 0
|
||||||
|
for i, post in enumerate(posts, 1):
|
||||||
|
media = media_cache.get(post["media"]) if post["media"] else None
|
||||||
|
|
||||||
|
if post["media"] and not media:
|
||||||
|
print(f" {i:2d}. ✗ {post['label']} — media upload missing, skipping")
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" {i:2d}. Scheduling {post['label']}...", end=" ", flush=True)
|
||||||
|
result = schedule_post(post["content"], media, post["time"])
|
||||||
|
if result:
|
||||||
|
print("✓")
|
||||||
|
success += 1
|
||||||
|
else:
|
||||||
|
print("✗")
|
||||||
|
failed += 1
|
||||||
|
# Rate limit: pause between API calls
|
||||||
|
if i < len(posts):
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"Scheduled: {success}/{len(posts)}")
|
||||||
|
if failed:
|
||||||
|
print(f"Failed: {failed}")
|
||||||
|
print(f"\nPosts will appear on @lukeattheroost starting Mon March 17")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
src = Path("data/regulars.json")
|
||||||
|
data = json.loads(src.read_text())
|
||||||
|
|
||||||
|
Path("data/regulars.archived.json").write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
regulars = data.get("regulars", [])
|
||||||
|
silas_only = [r for r in regulars if r.get("name", "").lower() == "silas"]
|
||||||
|
data["regulars"] = silas_only
|
||||||
|
src.write_text(json.dumps(data, indent=2))
|
||||||
|
print(f"Archived {len(regulars)} regulars. Kept {len(silas_only)} (Silas).")
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Generate 10 sample caller dialogues for user validation before cutover.
|
||||||
|
|
||||||
|
5 with Silas batch (if lore exists), 5 walk-ins. Writes transcripts to docs/samples/.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.main import (
|
||||||
|
BLACKLISTED_VOICES,
|
||||||
|
INWORLD_FEMALE_VOICES,
|
||||||
|
INWORLD_MALE_VOICES,
|
||||||
|
get_caller_prompt,
|
||||||
|
)
|
||||||
|
from backend.services import caller_gen, regulars_v2
|
||||||
|
|
||||||
|
DIALOG_MODEL = "anthropic/claude-haiku-4.5"
|
||||||
|
|
||||||
|
HOST_REACTIONS = [
|
||||||
|
"Yeah?",
|
||||||
|
"Go on.",
|
||||||
|
"Mm-hmm.",
|
||||||
|
"Wait — really?",
|
||||||
|
"Hold on, back up.",
|
||||||
|
"Is that right?",
|
||||||
|
"How'd you end up there?",
|
||||||
|
"And then what?",
|
||||||
|
"What'd she say?",
|
||||||
|
"So what are you gonna do about it?",
|
||||||
|
"That's — okay, keep going.",
|
||||||
|
"I mean, what do you want me to tell you?",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def dialog_turn(client: httpx.AsyncClient, system_prompt: str, conversation: list) -> str:
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {settings.openrouter_api_key}"},
|
||||||
|
json={
|
||||||
|
"model": DIALOG_MODEL,
|
||||||
|
"messages": [{"role": "system", "content": system_prompt}] + conversation,
|
||||||
|
"max_tokens": 300,
|
||||||
|
"temperature": 0.9,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()["choices"][0]["message"]["content"].strip()
|
||||||
|
except (httpx.ReadTimeout, httpx.HTTPStatusError) as e:
|
||||||
|
last_err = e
|
||||||
|
print(f" [retry {attempt+1}/3] dialog_turn failed: {type(e).__name__}")
|
||||||
|
raise last_err
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
out_dir = Path("docs/samples")
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
voice_roster = [
|
||||||
|
n for n in INWORLD_MALE_VOICES + INWORLD_FEMALE_VOICES
|
||||||
|
if n not in BLACKLISTED_VOICES
|
||||||
|
]
|
||||||
|
|
||||||
|
date_str = datetime.now().strftime("%A, %B %d, %Y")
|
||||||
|
|
||||||
|
# Batch 1: include Silas (if lore exists)
|
||||||
|
regulars = regulars_v2.load_all_active_regulars()
|
||||||
|
if regulars:
|
||||||
|
ctx_with_silas = {
|
||||||
|
"date": date_str,
|
||||||
|
"weather": "cool desert night",
|
||||||
|
"headlines": [],
|
||||||
|
"recent_caller_summaries": [],
|
||||||
|
"regulars_included": [
|
||||||
|
{"name": r.name, "lore": r.lore_body, "arc_state": r.arc_state}
|
||||||
|
for r in regulars
|
||||||
|
],
|
||||||
|
"caller_count": 5,
|
||||||
|
"voice_roster": voice_roster,
|
||||||
|
}
|
||||||
|
print("[Batch 1] Generating 5 callers including Silas...")
|
||||||
|
silas_batch = await caller_gen.generate_batch(ctx_with_silas)
|
||||||
|
print(f"[Batch 1] Got {len(silas_batch)} callers: {[c.name for c in silas_batch]}")
|
||||||
|
else:
|
||||||
|
print("[Batch 1] No regulars found — skipping")
|
||||||
|
silas_batch = []
|
||||||
|
|
||||||
|
# Batch 2: walk-ins
|
||||||
|
ctx_walkins = {
|
||||||
|
"date": date_str,
|
||||||
|
"weather": "cool desert night",
|
||||||
|
"headlines": [],
|
||||||
|
"recent_caller_summaries": [],
|
||||||
|
"regulars_included": [],
|
||||||
|
"caller_count": 5,
|
||||||
|
"voice_roster": voice_roster,
|
||||||
|
}
|
||||||
|
print("[Batch 2] Generating 5 walk-in callers...")
|
||||||
|
walkin_batch = await caller_gen.generate_batch(ctx_walkins)
|
||||||
|
print(f"[Batch 2] Got {len(walkin_batch)} callers: {[c.name for c in walkin_batch]}")
|
||||||
|
|
||||||
|
all_callers = silas_batch + walkin_batch
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
for idx, caller in enumerate(all_callers, 1):
|
||||||
|
print(f"[Dialog {idx}/{len(all_callers)}] {caller.name}...")
|
||||||
|
cdict = {
|
||||||
|
"name": caller.name,
|
||||||
|
"identity": caller.identity,
|
||||||
|
"situation": caller.situation,
|
||||||
|
"reason_calling": caller.reason_calling,
|
||||||
|
"secret_want": caller.secret_want,
|
||||||
|
"specific_details": caller.specific_details,
|
||||||
|
}
|
||||||
|
system_prompt = get_caller_prompt(cdict)
|
||||||
|
conversation = [{"role": "assistant", "content": caller.opening_line}]
|
||||||
|
|
||||||
|
for _ in range(4):
|
||||||
|
host_line = random.choice(HOST_REACTIONS)
|
||||||
|
conversation.append({"role": "user", "content": host_line})
|
||||||
|
reply = await dialog_turn(client, system_prompt, conversation)
|
||||||
|
conversation.append({"role": "assistant", "content": reply})
|
||||||
|
|
||||||
|
transcript_lines = [
|
||||||
|
f"CALLER ({caller.name}, {caller.age}, {caller.location}): "
|
||||||
|
f"{conversation[0]['content']}"
|
||||||
|
]
|
||||||
|
for i in range(1, len(conversation), 2):
|
||||||
|
transcript_lines.append(f"LUKE: {conversation[i]['content']}")
|
||||||
|
if i + 1 < len(conversation):
|
||||||
|
transcript_lines.append(f"CALLER: {conversation[i+1]['content']}")
|
||||||
|
|
||||||
|
slug = caller.name.replace(" ", "_").lower()
|
||||||
|
fname = out_dir / f"sample_{slug}.txt"
|
||||||
|
fname.write_text(
|
||||||
|
f"=== {caller.name} ({caller.age}, {caller.location}) ===\n"
|
||||||
|
f"voice: {caller.voice_resolved}\n"
|
||||||
|
f"emotional_register: {caller.emotional_register}\n"
|
||||||
|
f"secret_want: {caller.secret_want}\n\n"
|
||||||
|
+ "\n".join(transcript_lines)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
print(f"Wrote {fname}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel><atom:link href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" rel="self" type="application/rss+xml"></atom:link><atom:link href="https://pubsubhubbub.appspot.com/" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://pubsubhubbub.superfeedr.com/" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://websubhub.com/hub" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://switchboard.p3k.io/" rel="hub" type="application/rss+xml"></atom:link><lastBuildDate>Tue, 04 Aug 2026 09:23:29 +0000</lastBuildDate><generator>Castopod - https://castopod.org/</generator><docs>https://cyber.harvard.edu/rss/rss.html</docs><podcast:guid>46cfb731-b4a1-55e2-80f3-cadb1c6c18fa</podcast:guid><title>Luke at the Roost</title><description><![CDATA[<p>A late-night call-in radio show broadcast from a desert hermit’s RV, featuring a mix of real callers and AI-generated callers talking to Luke about life, love, and everything in between. Call in live: 208-439-LUKE (208-439-5853).</p>
|
||||||
|
<p>Website: <a href="https://lukeattheroost.com">https://lukeattheroost.com</a></p>
|
||||||
|
]]></description><podcast:medium>podcast</podcast:medium><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><language>en</language><podcast:location geo="geo:50.8050519,-0.4525129" osm="W996958777">Smugglers&#039; Roost</podcast:location><podcast:locked owner="luke@macneilmediagroup.com">no</podcast:locked><podcast:social priority="1" platform="castopod" protocol="activitypub" accountId="@LukeAtTheRoost@podcast.macneilmediagroup.com" accountUrl="https://podcast.macneilmediagroup.com/@LukeAtTheRoost"></podcast:social><itunes:category text="Comedy"></itunes:category><category>Comedy</category><itunes:explicit>true</itunes:explicit><itunes:author>MacNeil Media Group, LLC</itunes:author><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost</link><itunes:owner><itunes:name>Luke MacNeil</itunes:name><itunes:email>luke@macneilmediagroup.com</itunes:email></itunes:owner><itunes:type>episodic</itunes:type><copyright>MacNeil Media Group, LLC</copyright><image><url>https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png</url><title>Luke at the Roost</title><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost</link></image><item><title>Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho</title><enclosure url="https://op3.dev/e,pg=46cfb731-b4a1-55e2-80f3-cadb1c6c18fa/podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.mp3" length="114501589" type="audio/mpeg"></enclosure><guid>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho</guid><pubDate>Tue, 04 Aug 2026 09:22:38 +0000</pubDate><description><![CDATA[<p>Luke fields calls about stolen catalytic converters, unexplained phenomena over Mitchell Flat, a sheriff's deputy's moral dilemma, and a fence builder convinced someone is mapping West Texas water sources. Plus: Donald Judd's aluminum boxes change everything for a skeptical rancher named Fern.</p>]]></description><itunes:duration>4770</itunes:duration><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho</link><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><itunes:explicit>true</itunes:explicit><itunes:episode>58</itunes:episode><itunes:episodeType>full</itunes:episodeType><podcast:episode>58</podcast:episode><podcast:comments uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho/comments" contentType="application/podcast-activity+json"></podcast:comments><podcast:transcript url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.srt" type="application/x-subrip" rel="captions" language="en"></podcast:transcript><podcast:chapters url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho-chapters.json" type="application/json+chapters"></podcast:chapters></item><item><title>Episode 57: Trace's Box of Family Secrets</title><enclosure url="https://op3.dev/e,pg=46cfb731-b4a1-55e2-80f3-cadb1c6c18fa/podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets.mp3" length="97583646" type="audio/mpeg"></enclosure><guid>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets</guid><pubDate>Tue, 02 Jun 2026 11:32:58 +0000</pubDate><description><![CDATA[<p>Luke hosts another late-night call-in show from Alpine, Texas, featuring intense conversations about personal dilemmas. Callers include Silas struggling with a community move, a landman wrestling with an ethical choice, and Trace discovering hidden letters with a potentially life-changing secret. What happens when the past suddenly becomes present?</p>
|
||||||
|
]]></description><itunes:duration>4066</itunes:duration><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets</link><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><itunes:explicit>true</itunes:explicit><itunes:episode>57</itunes:episode><itunes:episodeType>full</itunes:episodeType><podcast:episode>57</podcast:episode><podcast:comments uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets/comments" contentType="application/podcast-activity+json"></podcast:comments><podcast:socialInteract uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/posts/418bda4c-56df-47c3-ae29-6e3eb4b70a50" priority="1" platform="castopod" protocol="activitypub" accountId="@LukeAtTheRoost@podcast.macneilmediagroup.com" pubDate="2026-06-02T11:32:58+0000"></podcast:socialInteract><podcast:transcript url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets.srt" type="application/x-subrip" rel="captions" language="en"></podcast:transcript><podcast:chapters url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets-chapters.json" type="application/json+chapters"></podcast:chapters></item></channel></rss>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import pytest
|
||||||
|
from backend.services.caller_gen import CallerIdentity, parse_batch_response
|
||||||
|
|
||||||
|
SAMPLE_JSON = """
|
||||||
|
{
|
||||||
|
"callers": [
|
||||||
|
{
|
||||||
|
"name": "Danny Ortega",
|
||||||
|
"age": 47,
|
||||||
|
"voice_suggestion": "Marcus",
|
||||||
|
"location": "Silver City, NM",
|
||||||
|
"identity": "A plumber who inherited his uncle's taxidermy shop...",
|
||||||
|
"situation": "He's been getting calls from people...",
|
||||||
|
"reason_calling": "Someone left a note in his mailbox tonight...",
|
||||||
|
"opening_line": "Luke, I need to ask you something weird.",
|
||||||
|
"secret_want": "Permission to just throw it all away",
|
||||||
|
"specific_details": ["the elk head in the basement", "the note said 'she forgot'", "his uncle's Rolodex"],
|
||||||
|
"emotional_register": "quietly unsettled, trying to sound casual"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_batch_response_returns_caller_list():
|
||||||
|
callers = parse_batch_response(SAMPLE_JSON)
|
||||||
|
assert len(callers) == 1
|
||||||
|
assert callers[0].name == "Danny Ortega"
|
||||||
|
assert callers[0].age == 47
|
||||||
|
assert "taxidermy" in callers[0].identity
|
||||||
|
assert len(callers[0].specific_details) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_batch_response_rejects_missing_fields():
|
||||||
|
bad = '{"callers": [{"name": "Jim"}]}'
|
||||||
|
with pytest.raises(ValueError, match="missing"):
|
||||||
|
parse_batch_response(bad)
|
||||||
|
|
||||||
|
|
||||||
|
FENCED_JSON = '''```json
|
||||||
|
{"callers": [{"name": "Terrence", "age": 61, "voice_suggestion": "Marcus", "location": "Tucumcari, NM", "identity": "Retired irrigation engineer", "situation": "Reading water bill", "reason_calling": "Found clause", "opening_line": "Luke.", "secret_want": "Vindication", "specific_details": ["a", "b"], "emotional_register": "intense"}]}
|
||||||
|
```'''
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_batch_response_strips_markdown_fences():
|
||||||
|
callers = parse_batch_response(FENCED_JSON)
|
||||||
|
assert len(callers) == 1
|
||||||
|
assert callers[0].name == "Terrence"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_voice_matches_exact():
|
||||||
|
from backend.services.caller_gen import resolve_voice
|
||||||
|
roster = ["Marcus", "Dennis", "Priya", "Edward"]
|
||||||
|
assert resolve_voice("Marcus", roster) == "Marcus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_voice_case_insensitive():
|
||||||
|
from backend.services.caller_gen import resolve_voice
|
||||||
|
roster = ["Marcus", "Dennis"]
|
||||||
|
assert resolve_voice("marcus", roster) == "Marcus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_voice_falls_back_when_no_match():
|
||||||
|
from backend.services.caller_gen import resolve_voice
|
||||||
|
roster = ["Marcus", "Dennis"]
|
||||||
|
# Random fallback, so hallucinated voices don't all collapse onto roster[0]
|
||||||
|
assert resolve_voice("Santiago", roster) in roster
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_voice_fallback_spreads_across_roster():
|
||||||
|
from backend.services.caller_gen import resolve_voice
|
||||||
|
roster = ["Marcus", "Dennis", "Priya", "Edward"]
|
||||||
|
picked = {resolve_voice("Santiago", roster) for _ in range(60)}
|
||||||
|
assert len(picked) > 1, "fallback collapsed onto a single voice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_voice_empty_suggestion_falls_back():
|
||||||
|
from backend.services.caller_gen import resolve_voice
|
||||||
|
assert resolve_voice("", ["Marcus"]) == "Marcus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_batch_prompt_includes_context():
|
||||||
|
from backend.services.caller_gen import build_batch_prompt
|
||||||
|
ctx = {
|
||||||
|
"date": "Saturday, April 5, 2026",
|
||||||
|
"weather": "cool desert night, 48°F",
|
||||||
|
"headlines": ["New Mexico legislature approves water bill"],
|
||||||
|
"recent_caller_summaries": ["Jerry called about his neighbor's goat"],
|
||||||
|
"regulars_included": [],
|
||||||
|
"caller_count": 12,
|
||||||
|
"voice_roster": ["Marcus", "Dennis", "Priya"],
|
||||||
|
}
|
||||||
|
prompt = build_batch_prompt(ctx)
|
||||||
|
assert "Saturday, April 5, 2026" in prompt
|
||||||
|
assert "water bill" in prompt
|
||||||
|
assert "Jerry called about his neighbor's goat" in prompt
|
||||||
|
assert "12 callers" in prompt
|
||||||
|
assert "Marcus" in prompt # voice roster listed
|
||||||
|
assert "Stern" in prompt
|
||||||
|
assert "Coast to Coast" in prompt
|
||||||
|
assert "Loveline" in prompt
|
||||||
|
assert "Delilah" in prompt
|
||||||
|
assert "Opie and Anthony" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_batch_prompt_includes_silas_lore_when_present():
|
||||||
|
from backend.services.caller_gen import build_batch_prompt
|
||||||
|
ctx = {
|
||||||
|
"date": "...",
|
||||||
|
"weather": "...",
|
||||||
|
"headlines": [],
|
||||||
|
"recent_caller_summaries": [],
|
||||||
|
"regulars_included": [{"name": "Silas", "voice": "Sebastian", "age": 52, "lore": "Silas leads a small desert cult...", "arc_state": "seeking new members"}],
|
||||||
|
"caller_count": 12,
|
||||||
|
"voice_roster": ["Marcus"],
|
||||||
|
}
|
||||||
|
prompt = build_batch_prompt(ctx)
|
||||||
|
assert "Silas" in prompt
|
||||||
|
assert "desert cult" in prompt
|
||||||
|
assert "seeking new members" in prompt
|
||||||
|
assert "DO NOT alter his voice, personality, or core traits" in prompt
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from backend.main import get_caller_prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_includes_identity_and_situation():
|
||||||
|
caller = {
|
||||||
|
"name": "Danny",
|
||||||
|
"identity": "A plumber who inherited a taxidermy shop",
|
||||||
|
"situation": "Getting strange calls about taxidermy",
|
||||||
|
"reason_calling": "Someone left a note",
|
||||||
|
"secret_want": "Permission to throw it all away",
|
||||||
|
"specific_details": ["elk head in basement", "note said she forgot"],
|
||||||
|
}
|
||||||
|
prompt = get_caller_prompt(caller)
|
||||||
|
assert "Danny" in prompt
|
||||||
|
assert "taxidermy shop" in prompt
|
||||||
|
assert "strange calls" in prompt
|
||||||
|
assert "elk head" in prompt
|
||||||
|
assert "she forgot" in prompt
|
||||||
|
assert "Permission to throw it all away" in prompt
|
||||||
|
assert "React to what Luke says" in prompt
|
||||||
|
assert "Stay in character" in prompt
|
||||||
|
assert "NEVER use asterisks" in prompt
|
||||||
|
assert "NEVER use parenthetical stage directions" in prompt
|
||||||
|
assert "Mix short punchy replies with longer ones" in prompt
|
||||||
|
assert "YOU CAN BE MOVED" in prompt
|
||||||
|
assert "NEVER restate the same dilemma" in prompt
|
||||||
|
assert len(prompt) < 3500
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_includes_opening_line():
|
||||||
|
caller = {
|
||||||
|
"name": "Tina",
|
||||||
|
"identity": "A nurse who just got off a 16-hour shift",
|
||||||
|
"situation": "Found her ex's wedding invitation in her mailbox",
|
||||||
|
"reason_calling": "She's not sure if she should go",
|
||||||
|
"secret_want": "Wants someone to tell her she's over him",
|
||||||
|
"opening_line": "Luke, I literally just got home from work and there's this gold envelope sitting on my kitchen counter.",
|
||||||
|
"specific_details": ["invitation was hand-addressed", "wedding is in two weeks"],
|
||||||
|
}
|
||||||
|
prompt = get_caller_prompt(caller)
|
||||||
|
assert "gold envelope" in prompt
|
||||||
|
assert "FIRST message" in prompt
|
||||||
|
assert "listening for" in prompt.lower() or "been listening" in prompt.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_without_opening_line():
|
||||||
|
caller = {
|
||||||
|
"name": "Ray",
|
||||||
|
"identity": "A retired mechanic",
|
||||||
|
"situation": "Neighbor's dog dug up something weird",
|
||||||
|
"reason_calling": "He thinks it might be human bones",
|
||||||
|
"secret_want": "Doesn't want to call the cops on his neighbor",
|
||||||
|
"specific_details": ["bones were wrapped in a tarp"],
|
||||||
|
}
|
||||||
|
prompt = get_caller_prompt(caller)
|
||||||
|
assert "FIRST message" not in prompt
|
||||||
|
assert "planned opening" not in prompt.lower()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from generate_episode_pages import generate
|
||||||
|
|
||||||
|
FIXTURE_FEED = Path(__file__).parent / "fixtures" / "feed_sample.xml"
|
||||||
|
|
||||||
|
EP58_SLUG = "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
|
||||||
|
EP57_SLUG = "episode-57-trace-s-box-of-family-secrets"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def feed_file(tmp_path):
|
||||||
|
dest = tmp_path / "feed.xml"
|
||||||
|
shutil.copyfile(FIXTURE_FEED, dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def transcripts_dir(tmp_path):
|
||||||
|
d = tmp_path / "transcripts"
|
||||||
|
d.mkdir()
|
||||||
|
(d / f"{EP58_SLUG}.txt").write_text(
|
||||||
|
"LUKE: Marfa Lights, line one.\n\nCONCHO: Nobody believes me.\n"
|
||||||
|
)
|
||||||
|
(d / f"{EP57_SLUG}.txt").write_text("LUKE: Trace, what's in the box?\n\nTRACE: Letters.\n")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def out_root(tmp_path):
|
||||||
|
return tmp_path / "site"
|
||||||
|
|
||||||
|
|
||||||
|
def test_writes_one_index_html_per_feed_episode(feed_file, transcripts_dir, out_root):
|
||||||
|
n = generate(feed_file, transcripts_dir, out_root)
|
||||||
|
assert n == 2
|
||||||
|
assert (out_root / "episode" / EP58_SLUG / "index.html").exists()
|
||||||
|
assert (out_root / "episode" / EP57_SLUG / "index.html").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_orphan_transcript_without_feed_item_is_skipped(feed_file, transcripts_dir, out_root):
|
||||||
|
"""episode-32 has a transcript but was never published to the feed."""
|
||||||
|
(transcripts_dir / "episode-32-tacos-taxes-and-tall-tales.txt").write_text("LUKE: Hi.")
|
||||||
|
generate(feed_file, transcripts_dir, out_root)
|
||||||
|
assert not (out_root / "episode" / "episode-32-tacos-taxes-and-tall-tales").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_transcript_still_produces_a_page(feed_file, transcripts_dir, out_root):
|
||||||
|
"""An episode published before its transcript lands must not break the build."""
|
||||||
|
for f in transcripts_dir.glob("*.txt"):
|
||||||
|
f.unlink()
|
||||||
|
n = generate(feed_file, transcripts_dir, out_root)
|
||||||
|
assert n == 2
|
||||||
|
html = next((out_root / "episode").rglob("index.html")).read_text()
|
||||||
|
assert "Transcript not yet available" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_writes_nothing(feed_file, transcripts_dir, out_root):
|
||||||
|
n = generate(feed_file, transcripts_dir, out_root, dry_run=True)
|
||||||
|
assert n == 2
|
||||||
|
assert not out_root.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_content_lands_in_the_page(feed_file, transcripts_dir, out_root):
|
||||||
|
generate(feed_file, transcripts_dir, out_root)
|
||||||
|
html = (out_root / "episode" / EP58_SLUG / "index.html").read_text()
|
||||||
|
assert "transcript-turn" in html
|
||||||
|
assert "Nobody believes me." in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_pages_link_to_each_other(feed_file, transcripts_dir, out_root):
|
||||||
|
"""Prev/next links are how a crawler reaches all 57 pages."""
|
||||||
|
generate(feed_file, transcripts_dir, out_root)
|
||||||
|
pages = list((out_root / "episode").rglob("index.html"))
|
||||||
|
combined = "\n".join(p.read_text() for p in pages)
|
||||||
|
assert combined.count("/episode/") >= len(pages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prev_points_older_and_next_points_newer(feed_file, transcripts_dir, out_root):
|
||||||
|
generate(feed_file, transcripts_dir, out_root)
|
||||||
|
newest = (out_root / "episode" / EP58_SLUG / "index.html").read_text()
|
||||||
|
oldest = (out_root / "episode" / EP57_SLUG / "index.html").read_text()
|
||||||
|
assert f'rel="prev" href="/episode/{EP57_SLUG}/"' in newest
|
||||||
|
assert 'rel="next"' not in newest
|
||||||
|
assert f'rel="next" href="/episode/{EP58_SLUG}/"' in oldest
|
||||||
|
assert 'rel="prev"' not in oldest
|
||||||
|
|
||||||
|
|
||||||
|
def test_accepts_string_paths(feed_file, transcripts_dir, out_root):
|
||||||
|
n = generate(str(feed_file), str(transcripts_dir), str(out_root))
|
||||||
|
assert n == 2
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from website_gen.feed import Episode
|
||||||
|
from website_gen.render import render_episode_page
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_episode():
|
||||||
|
return Episode(
|
||||||
|
number=58,
|
||||||
|
slug="episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho",
|
||||||
|
title="Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho",
|
||||||
|
description=(
|
||||||
|
"Luke fields calls about stolen catalytic converters, unexplained phenomena over "
|
||||||
|
"Mitchell Flat, a sheriff's deputy's moral dilemma, and a fence builder convinced "
|
||||||
|
"someone is mapping West Texas water sources."
|
||||||
|
),
|
||||||
|
published_iso="2026-08-04T09:22:38+00:00",
|
||||||
|
duration_seconds=4770,
|
||||||
|
audio_url=(
|
||||||
|
"https://podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/"
|
||||||
|
"episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.mp3"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_episode():
|
||||||
|
return Episode(
|
||||||
|
number=57,
|
||||||
|
slug="episode-57-trace-s-box-of-family-secrets",
|
||||||
|
title="Episode 57: Trace's Box of Family Secrets",
|
||||||
|
description="Letters in a shoebox turn a family story inside out.",
|
||||||
|
published_iso="2026-06-02T11:32:58+00:00",
|
||||||
|
duration_seconds=4066,
|
||||||
|
audio_url=(
|
||||||
|
"https://podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/"
|
||||||
|
"episode-57-trace-s-box-of-family-secrets.mp3"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_title_and_canonical_are_episode_specific(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "Hello.")])
|
||||||
|
assert "<title>Episode 58: Rayfield" in html
|
||||||
|
assert '<link rel="canonical" href="https://lukeattheroost.com/episode/episode-58-' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcript_is_in_the_html_not_fetched_by_js(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "The Marfa Lights are real.")])
|
||||||
|
assert "The Marfa Lights are real." in html
|
||||||
|
assert "fetch(" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_emits_valid_podcastepisode_schema(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi.")])
|
||||||
|
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
|
||||||
|
data = json.loads(block)
|
||||||
|
types = {o["@type"] for o in (data if isinstance(data, list) else [data])}
|
||||||
|
assert "PodcastEpisode" in types
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_carries_big_bend_content_location(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
|
||||||
|
data = json.loads(block)
|
||||||
|
ep = next(o for o in data if o["@type"] == "PodcastEpisode")
|
||||||
|
assert ep["contentLocation"]["address"]["addressLocality"] == "Alpine"
|
||||||
|
|
||||||
|
|
||||||
|
def test_escapes_html_in_transcript_text(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "5 < 6 & <script>alert(1)</script>")])
|
||||||
|
assert "<script>alert(1)</script>" not in html
|
||||||
|
assert "<script>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_escapes_quotes_in_title_meta(sample_episode):
|
||||||
|
sample_episode.title = 'Episode 1: The "Best" Show'
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
assert 'content="Episode 1: The "Best"' not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_json_is_valid_even_with_quotes_in_title(sample_episode):
|
||||||
|
"""JSON-LD must stay parseable when the title contains quotes and apostrophes."""
|
||||||
|
sample_episode.title = 'Ep "quoted" and Rayfield\'s'
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
|
||||||
|
json.loads(block) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_speaker_labels_get_semantic_markup(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi."), ("SLIM", "Hey.")])
|
||||||
|
assert html.count('class="transcript-turn"') == 2
|
||||||
|
assert "LUKE" in html and "SLIM" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_asset_paths_are_root_absolute(sample_episode):
|
||||||
|
"""Pages live at /episode/<slug>/ so relative asset paths would 404."""
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
assert 'href="css/' not in html
|
||||||
|
assert 'src="js/' not in html
|
||||||
|
assert re.search(r'href="/css/style\.css\?v=\d+"', html)
|
||||||
|
assert 'src="/js/footer.js"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_prev_next_links_render_when_given(sample_episode, other_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[], prev_ep=other_episode)
|
||||||
|
assert f'/episode/{other_episode.slug}/' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_transcript_renders_placeholder(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
assert "Transcript not yet available" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_element_present_for_no_js_playback(sample_episode):
|
||||||
|
html = render_episode_page(sample_episode, turns=[])
|
||||||
|
assert "<audio" in html and sample_episode.audio_url in html
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from website_gen.feed import parse_feed
|
||||||
|
|
||||||
|
FIXTURE = Path(__file__).parent / "fixtures" / "feed_sample.xml"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def feed_xml():
|
||||||
|
return FIXTURE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_core_fields(feed_xml):
|
||||||
|
eps = parse_feed(feed_xml)
|
||||||
|
ep = next(e for e in eps if e.number == 58)
|
||||||
|
assert ep.slug == "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
|
||||||
|
assert ep.title.startswith("Episode 58: Rayfield's Nephew")
|
||||||
|
assert ep.duration_seconds == 4770
|
||||||
|
assert ep.audio_url.startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
|
def test_description_is_stripped_of_cdata_and_html(feed_xml):
|
||||||
|
ep = parse_feed(feed_xml)[0]
|
||||||
|
assert "<![CDATA[" not in ep.description
|
||||||
|
assert "<p>" not in ep.description
|
||||||
|
|
||||||
|
|
||||||
|
def test_slug_comes_from_link_and_drops_trailing_slash(feed_xml):
|
||||||
|
for ep in parse_feed(feed_xml):
|
||||||
|
assert not ep.slug.endswith("/")
|
||||||
|
assert "/" not in ep.slug
|
||||||
|
|
||||||
|
|
||||||
|
def test_pubdate_parses_to_iso_date(feed_xml):
|
||||||
|
ep = next(e for e in parse_feed(feed_xml) if e.number == 58)
|
||||||
|
assert ep.published_iso.startswith("2026-08-04")
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_all_items_in_fixture(feed_xml):
|
||||||
|
assert len(parse_feed(feed_xml)) == 2
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import sys
|
|
||||||
sys.path.insert(0, "/Users/lukemacneil/ai-podcast")
|
|
||||||
|
|
||||||
from backend.main import Session, CallRecord, get_caller_prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_caller_prompt_includes_show_history():
|
|
||||||
s = Session()
|
|
||||||
s.call_history.append(CallRecord(
|
|
||||||
caller_type="real", caller_name="Dave",
|
|
||||||
summary="Called about his wife leaving after 12 years",
|
|
||||||
transcript=[],
|
|
||||||
))
|
|
||||||
|
|
||||||
s.start_call("1") # Tony
|
|
||||||
caller = s.caller
|
|
||||||
show_history = s.get_show_history()
|
|
||||||
prompt = get_caller_prompt(caller, "", show_history)
|
|
||||||
assert "Dave" in prompt
|
|
||||||
assert "wife leaving" in prompt
|
|
||||||
assert "EARLIER IN THE SHOW" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_caller_prompt_without_history():
|
|
||||||
s = Session()
|
|
||||||
s.start_call("1")
|
|
||||||
caller = s.caller
|
|
||||||
prompt = get_caller_prompt(caller, "")
|
|
||||||
assert "EARLIER IN THE SHOW" not in prompt
|
|
||||||
assert caller["name"] in prompt
|
|
||||||
|
|
||||||
|
|
||||||
def test_caller_prompt_backward_compatible():
|
|
||||||
"""Verify get_caller_prompt works with just 2 args (no show_history)"""
|
|
||||||
s = Session()
|
|
||||||
s.start_call("1")
|
|
||||||
caller = s.caller
|
|
||||||
prompt = get_caller_prompt(caller, "Host: hello")
|
|
||||||
assert "hello" in prompt
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Guards against the two model-config failures that have bitten us:
|
||||||
|
a routed model with no pricing entry (silently costs $0.00 on the dashboard),
|
||||||
|
and a stale model id that 404s at the OpenRouter API."""
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.services.cost_tracker import OPENROUTER_PRICING
|
||||||
|
from backend.services.llm import OPENROUTER_MODELS, LLMService
|
||||||
|
|
||||||
|
_CALLER_DIALOG_MODEL_PARAMS = LLMService._CALLER_DIALOG_MODEL_PARAMS
|
||||||
|
|
||||||
|
# Retired on OpenRouter — kept here so a reintroduction fails loudly.
|
||||||
|
RETIRED_MODELS = {
|
||||||
|
"anthropic/claude-3.5-haiku",
|
||||||
|
"anthropic/claude-3.5-sonnet",
|
||||||
|
"anthropic/claude-sonnet-4-5", # note the dash; the live id uses a dot
|
||||||
|
"google/gemini-flash-1.5",
|
||||||
|
"mistralai/mistral-small-creative",
|
||||||
|
"x-ai/grok-4",
|
||||||
|
"x-ai/grok-4-fast",
|
||||||
|
"x-ai/grok-4.1-fast",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_routed_model_has_pricing():
|
||||||
|
"""A routed model missing from OPENROUTER_PRICING records cost as $0.00."""
|
||||||
|
unpriced = {
|
||||||
|
cat: model
|
||||||
|
for cat, model in settings.category_models.items()
|
||||||
|
if model not in OPENROUTER_PRICING
|
||||||
|
}
|
||||||
|
assert not unpriced, f"routed models with no pricing entry: {unpriced}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_retired_models_are_routed():
|
||||||
|
routed = {
|
||||||
|
cat: model
|
||||||
|
for cat, model in settings.category_models.items()
|
||||||
|
if model in RETIRED_MODELS
|
||||||
|
}
|
||||||
|
assert not routed, f"category routed to a retired model: {routed}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_retired_models_in_the_pool():
|
||||||
|
stale = sorted(set(OPENROUTER_MODELS) & RETIRED_MODELS)
|
||||||
|
assert not stale, f"retired models still listed in OPENROUTER_MODELS: {stale}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_retired_models_in_caller_dialog_params():
|
||||||
|
stale = sorted(set(_CALLER_DIALOG_MODEL_PARAMS) & RETIRED_MODELS)
|
||||||
|
assert not stale, f"per-model tuning keyed to retired models: {stale}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pool_models_are_priced():
|
||||||
|
"""Anything selectable should be costable."""
|
||||||
|
unpriced = [m for m in OPENROUTER_MODELS if m not in OPENROUTER_PRICING]
|
||||||
|
assert not unpriced, f"pool models with no pricing entry: {unpriced}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_retired_model_ids_anywhere_in_the_codebase():
|
||||||
|
"""publish_episode.py, make_clips.py and relabel_transcripts.py each shipped
|
||||||
|
a retired model id and only failed when someone ran them. Scan every source
|
||||||
|
file so the next one fails here instead.
|
||||||
|
|
||||||
|
cost_tracker.py is exempt: it intentionally keeps retired ids as pricing
|
||||||
|
keys so historical cost records stay costable.
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
root = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
exempt = {root / "backend" / "services" / "cost_tracker.py",
|
||||||
|
pathlib.Path(__file__).resolve()}
|
||||||
|
|
||||||
|
offenders = {}
|
||||||
|
for path in root.rglob("*.py"):
|
||||||
|
if path in exempt:
|
||||||
|
continue
|
||||||
|
# Skip venvs, vendored models, and git worktrees (separate checkouts
|
||||||
|
# on other branches — not this tree's code).
|
||||||
|
if any(part in {"venv", "mlx_models", ".git", ".claude", ".worktrees",
|
||||||
|
"node_modules", "remotion-demo"} for part in path.parts):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
hits = sorted(m for m in RETIRED_MODELS if f'"{m}"' in text or f"'{m}'" in text)
|
||||||
|
if hits:
|
||||||
|
offenders[str(path.relative_to(root))] = hits
|
||||||
|
|
||||||
|
assert not offenders, f"retired model ids still referenced: {offenders}"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
LEGACY = "episode.html?slug="
|
||||||
|
|
||||||
|
|
||||||
|
def _files():
|
||||||
|
for pattern in ("website/*.html", "website/js/*.js", "website/llms.txt", "*.py"):
|
||||||
|
yield from ROOT.glob(pattern)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_source_file_builds_a_legacy_episode_url():
|
||||||
|
offenders = []
|
||||||
|
for f in _files():
|
||||||
|
if f.name == "_worker.js":
|
||||||
|
continue # the worker's reference is the redirect itself
|
||||||
|
if LEGACY in f.read_text(errors="replace"):
|
||||||
|
offenders.append(str(f.relative_to(ROOT)))
|
||||||
|
assert not offenders, f"legacy episode URLs still present: {offenders}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_rendered_episode_page_is_gone():
|
||||||
|
assert not (ROOT / "website" / "episode.html").exists()
|
||||||
|
assert not (ROOT / "website" / "js" / "episode.js").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_still_redirects_legacy_urls():
|
||||||
|
worker = (ROOT / "website" / "_worker.js").read_text()
|
||||||
|
assert "/episode.html" in worker and "301" in worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirects_file_has_no_dead_episode_target():
|
||||||
|
redirects = (ROOT / "website" / "_redirects").read_text()
|
||||||
|
assert "/episode 302" not in redirects
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Whisper mishears recurring show names; correct them after transcription.
|
||||||
|
|
||||||
|
publish_episode.py transcribes with LightningWhisperMLX, whose transcribe()
|
||||||
|
signature is (audio_path, language) — it accepts no initial_prompt, so there is
|
||||||
|
no way to condition it on proper nouns the way make_clips.py does with
|
||||||
|
mlx_whisper. The intern came out as "Devin" in 21 of 58 published transcripts
|
||||||
|
because of this.
|
||||||
|
|
||||||
|
A deterministic pass over the finished text fixes the known names without
|
||||||
|
swapping the transcription engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from publish_episode import fix_proper_nouns
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrects_the_intern_name_in_all_casings():
|
||||||
|
assert fix_proper_nouns("Devin, where's my coffee?") == "Devon, where's my coffee?"
|
||||||
|
assert fix_proper_nouns("DEVIN: Hey Luke.") == "DEVON: Hey Luke."
|
||||||
|
assert fix_proper_nouns("devin said so") == "devon said so"
|
||||||
|
|
||||||
|
|
||||||
|
def test_leaves_correct_spelling_alone():
|
||||||
|
text = "Devon is the intern. DEVON: Hi."
|
||||||
|
assert fix_proper_nouns(text) == text
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_matches_whole_words():
|
||||||
|
"""Must not corrupt a longer word that happens to contain the name."""
|
||||||
|
assert fix_proper_nouns("Devinshire") == "Devinshire"
|
||||||
|
assert fix_proper_nouns("mcdevins") == "mcdevins"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preserves_surrounding_text_exactly():
|
||||||
|
src = "LUKE: Alright. Let's check in with Devin and see how he's doing.\n\nDEVIN: Hey!"
|
||||||
|
out = fix_proper_nouns(src)
|
||||||
|
assert out == "LUKE: Alright. Let's check in with Devon and see how he's doing.\n\nDEVON: Hey!"
|
||||||
|
assert len(out) == len(src)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_and_none_safe():
|
||||||
|
assert fix_proper_nouns("") == ""
|
||||||
|
assert fix_proper_nouns(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_word_count_is_never_changed():
|
||||||
|
src = "Devin talked to Devin about Devin. " * 20
|
||||||
|
assert len(fix_proper_nouns(src).split()) == len(src.split())
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Publishing must rebuild the static episode pages and the sitemap.
|
||||||
|
|
||||||
|
Before static pages existed, publish_episode.py appended one entry to
|
||||||
|
sitemap.xml itself. That appender is gone — generate_episode_pages.py owns the
|
||||||
|
sitemap now — so the publish has to invoke it, or a newly published episode
|
||||||
|
would have no page and never reach the sitemap.
|
||||||
|
|
||||||
|
A generator failure must never abort a publish: by the time this runs the audio
|
||||||
|
is already live on Castopod and the RSS feed has been rebuilt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import publish_episode
|
||||||
|
|
||||||
|
|
||||||
|
class _Result:
|
||||||
|
def __init__(self, returncode=0, stderr=""):
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stderr = stderr
|
||||||
|
self.stdout = ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_invokes_the_generator_with_sitemap(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd) or _Result())
|
||||||
|
|
||||||
|
assert publish_episode.regenerate_website_pages() is True
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
cmd = calls[0]
|
||||||
|
assert "generate_episode_pages.py" in " ".join(cmd)
|
||||||
|
assert "--sitemap" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_uses_the_running_interpreter(monkeypatch):
|
||||||
|
"""Must not shell out to a bare 'python' that may not have the venv."""
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(subprocess, "run", lambda cmd, **kw: calls.append(cmd) or _Result())
|
||||||
|
|
||||||
|
publish_episode.regenerate_website_pages()
|
||||||
|
|
||||||
|
assert calls[0][0] == sys.executable
|
||||||
|
|
||||||
|
|
||||||
|
def test_generator_failure_is_not_fatal(monkeypatch):
|
||||||
|
monkeypatch.setattr(subprocess, "run",
|
||||||
|
lambda cmd, **kw: _Result(returncode=1, stderr="boom"))
|
||||||
|
|
||||||
|
assert publish_episode.regenerate_website_pages() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_generator_timeout_is_not_fatal(monkeypatch):
|
||||||
|
def _boom(cmd, **kw):
|
||||||
|
raise subprocess.TimeoutExpired(cmd, 300)
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", _boom)
|
||||||
|
|
||||||
|
assert publish_episode.regenerate_website_pages() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_generator_is_not_fatal(monkeypatch):
|
||||||
|
def _boom(cmd, **kw):
|
||||||
|
raise OSError("no such file")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", _boom)
|
||||||
|
|
||||||
|
assert publish_episode.regenerate_website_pages() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_flow_calls_it_after_copying_the_transcript():
|
||||||
|
"""Guards the wiring, not just the helper."""
|
||||||
|
source = Path(publish_episode.__file__).read_text()
|
||||||
|
copy_at = source.index("Transcript copied to website/transcripts/")
|
||||||
|
call_at = source.index("regenerate_website_pages()", copy_at)
|
||||||
|
assert call_at > copy_at
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""A --resume run must not invent the episode title.
|
||||||
|
|
||||||
|
Episode 58's first run published to Castopod, then died on the YouTube upload
|
||||||
|
(see test_youtube_tags.py). The re-run with --resume rebuilt the title from the
|
||||||
|
URL slug, which is lowercase and punctuation-free, so
|
||||||
|
"Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho"
|
||||||
|
went to YouTube as
|
||||||
|
"Episode 58: Rayfield S Nephew The Marfa Lights And Why Nobody Believes Concho"
|
||||||
|
with the description replaced by a placeholder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from publish_episode import recover_metadata, save_metadata, _decode_db_row
|
||||||
|
|
||||||
|
EP58_TITLE = "Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho"
|
||||||
|
EP58_SLUG = "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
|
||||||
|
EP58_DESC = "Rayfield calls about his nephew and the catalytic converters."
|
||||||
|
CHAPTERS = [{"startTime": 0, "title": "Intro"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_file_roundtrips_losslessly(tmp_path):
|
||||||
|
path = tmp_path / "ep58.metadata.json"
|
||||||
|
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
|
||||||
|
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD"}, str(path))
|
||||||
|
|
||||||
|
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
|
||||||
|
|
||||||
|
assert meta["title"] == EP58_TITLE
|
||||||
|
assert meta["description"] == EP58_DESC
|
||||||
|
|
||||||
|
|
||||||
|
def test_metadata_file_wins_over_slug(tmp_path):
|
||||||
|
"""The exact ep58 regression: apostrophes, commas and case must survive."""
|
||||||
|
path = tmp_path / "ep58.metadata.json"
|
||||||
|
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
|
||||||
|
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD"}, str(path))
|
||||||
|
|
||||||
|
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
|
||||||
|
|
||||||
|
assert "Rayfield S Nephew" not in meta["title"]
|
||||||
|
assert "Rayfield's Nephew" in meta["title"]
|
||||||
|
assert "the Marfa Lights" in meta["title"]
|
||||||
|
assert meta["description"] != "Episode 58 of Luke at the Roost."
|
||||||
|
|
||||||
|
|
||||||
|
def test_falls_back_to_castopod_db_when_file_missing(tmp_path):
|
||||||
|
path = tmp_path / "missing.metadata.json"
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def db_lookup(episode_number):
|
||||||
|
calls.append(episode_number)
|
||||||
|
return {"title": EP58_TITLE, "description": EP58_DESC}
|
||||||
|
|
||||||
|
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS, db_lookup=db_lookup)
|
||||||
|
|
||||||
|
assert calls == [58]
|
||||||
|
assert meta["title"] == EP58_TITLE
|
||||||
|
assert meta["description"] == EP58_DESC
|
||||||
|
|
||||||
|
|
||||||
|
def test_slug_fallback_only_when_file_and_db_unavailable(tmp_path, capsys):
|
||||||
|
path = tmp_path / "missing.metadata.json"
|
||||||
|
|
||||||
|
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS,
|
||||||
|
db_lookup=lambda n: None)
|
||||||
|
|
||||||
|
assert meta["title"].startswith("Episode 58: ")
|
||||||
|
warning = capsys.readouterr().out
|
||||||
|
assert "lossy" in warning.lower() or "warning" in warning.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_slug_fallback_does_not_title_case_away_real_words(tmp_path):
|
||||||
|
"""Even degraded, the fallback should not be silently trusted."""
|
||||||
|
meta = recover_metadata(58, EP58_SLUG, tmp_path / "nope.json", CHAPTERS,
|
||||||
|
db_lookup=lambda n: None)
|
||||||
|
assert meta.get("title_is_reconstructed") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_episode_prefix_stripped_only_at_start(tmp_path):
|
||||||
|
"""A global replace would also eat the phrase inside the title."""
|
||||||
|
slug = "episode-5-the-episode-5-mixup"
|
||||||
|
meta = recover_metadata(5, slug, tmp_path / "nope.json", CHAPTERS,
|
||||||
|
db_lookup=lambda n: None)
|
||||||
|
assert meta["title"].lower().count("episode 5") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_chapters_come_from_chapters_json_not_metadata_file(tmp_path):
|
||||||
|
path = tmp_path / "ep58.metadata.json"
|
||||||
|
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
|
||||||
|
"chapters": [{"startTime": 999, "title": "Stale"}],
|
||||||
|
"thumbnail_text": "X"}, str(path))
|
||||||
|
|
||||||
|
meta = recover_metadata(58, EP58_SLUG, path, CHAPTERS)
|
||||||
|
|
||||||
|
assert meta["chapters"] == CHAPTERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_decodes_base64_wrapped_by_mariadb():
|
||||||
|
"""TO_BASE64 wraps at 76 chars and mysql renders those breaks as a literal
|
||||||
|
backslash-n, which b64decode rejects outright."""
|
||||||
|
import base64 as b64
|
||||||
|
payload = json.dumps({"title": EP58_TITLE, "description": EP58_DESC})
|
||||||
|
encoded = b64.b64encode(payload.encode()).decode()
|
||||||
|
wrapped = "\\n".join(encoded[i:i + 76] for i in range(0, len(encoded), 76))
|
||||||
|
|
||||||
|
assert "\\n" in wrapped, "test needs a payload long enough to wrap"
|
||||||
|
row = _decode_db_row(wrapped)
|
||||||
|
|
||||||
|
assert row["title"] == EP58_TITLE
|
||||||
|
assert row["description"] == EP58_DESC
|
||||||
|
|
||||||
|
|
||||||
|
def test_decodes_base64_with_real_newlines():
|
||||||
|
import base64 as b64
|
||||||
|
payload = json.dumps({"title": EP58_TITLE, "description": EP58_DESC})
|
||||||
|
encoded = b64.b64encode(payload.encode()).decode()
|
||||||
|
wrapped = "\n".join(encoded[i:i + 76] for i in range(0, len(encoded), 76))
|
||||||
|
|
||||||
|
assert _decode_db_row(wrapped)["title"] == EP58_TITLE
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_db_row_returns_none_on_garbage():
|
||||||
|
assert _decode_db_row("") is None
|
||||||
|
assert _decode_db_row("not base64 at all !!!") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_metadata_keeps_only_publishable_fields(tmp_path):
|
||||||
|
path = tmp_path / "m.json"
|
||||||
|
save_metadata({"title": EP58_TITLE, "description": EP58_DESC,
|
||||||
|
"chapters": CHAPTERS, "thumbnail_text": "RAYFIELD",
|
||||||
|
"transcript": "huge blob that should not be persisted"}, str(path))
|
||||||
|
|
||||||
|
saved = json.loads(path.read_text())
|
||||||
|
assert "transcript" not in saved
|
||||||
|
assert saved["title"] == EP58_TITLE
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from backend.services.regulars_v2 import Regular, load_regular, REGULARS_DIR, SILAS_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_regular_parses_frontmatter_and_body(tmp_path):
|
||||||
|
lore_file = tmp_path / "silas.md"
|
||||||
|
lore_file.write_text("""---
|
||||||
|
name: Silas
|
||||||
|
voice: Dennis
|
||||||
|
age: 54
|
||||||
|
arc_state: Cult is splintering after the eclipse failure
|
||||||
|
---
|
||||||
|
|
||||||
|
# Silas
|
||||||
|
|
||||||
|
Silas runs a small desert cult outside Truth or Consequences...
|
||||||
|
|
||||||
|
## Arc Log
|
||||||
|
|
||||||
|
- 2026-03-01: First call, introduced the cult
|
||||||
|
- 2026-03-20: Prophesied the eclipse
|
||||||
|
""")
|
||||||
|
reg = load_regular(lore_file)
|
||||||
|
assert reg.name == "Silas"
|
||||||
|
assert reg.voice == "Dennis"
|
||||||
|
assert reg.age == 54
|
||||||
|
assert "splintering" in reg.arc_state
|
||||||
|
assert "Silas runs a small desert cult" in reg.lore_body
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_promotion_returns_arc_plan_when_worthy():
|
||||||
|
from backend.services.regulars_v2 import evaluate_promotion
|
||||||
|
fake_response = {
|
||||||
|
"promote": True,
|
||||||
|
"arc_plan": "3 episodes. He'll start distant, then reveal he's actually the one who damaged the car, then resolve with an apology.",
|
||||||
|
"reason": "Has clear internal conflict with room to grow",
|
||||||
|
}
|
||||||
|
with patch("backend.services.regulars_v2._call_sonnet", new=AsyncMock(return_value=fake_response)):
|
||||||
|
result = asyncio.run(evaluate_promotion(caller_name="Bobby", call_transcript="..."))
|
||||||
|
assert result["promote"] is True
|
||||||
|
assert "3 episodes" in result["arc_plan"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_promotion_rejects_when_no_arc():
|
||||||
|
from backend.services.regulars_v2 import evaluate_promotion
|
||||||
|
fake_response = {"promote": False, "arc_plan": None, "reason": "One-note complaint, no growth"}
|
||||||
|
with patch("backend.services.regulars_v2._call_sonnet", new=AsyncMock(return_value=fake_response)):
|
||||||
|
result = asyncio.run(evaluate_promotion(caller_name="Carl", call_transcript="..."))
|
||||||
|
assert result["promote"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_sonnet_strips_markdown_fences():
|
||||||
|
from backend.services.regulars_v2 import _call_sonnet
|
||||||
|
fake_resp = MagicMock()
|
||||||
|
fake_resp.raise_for_status = MagicMock()
|
||||||
|
fake_resp.json = MagicMock(return_value={
|
||||||
|
"choices": [{"message": {"content": "```json\n{\"promote\": true, \"arc_plan\": \"ok\"}\n```"}}],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||||
|
})
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client.post = AsyncMock(return_value=fake_resp)
|
||||||
|
|
||||||
|
with patch("backend.services.regulars_v2.httpx.AsyncClient", return_value=mock_client), \
|
||||||
|
patch("backend.services.regulars_v2.cost_tracker.record_llm_call"):
|
||||||
|
result = asyncio.run(_call_sonnet("prompt"))
|
||||||
|
assert result["promote"] is True
|
||||||
|
assert result["arc_plan"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_new_regular_creates_lore_file(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr("backend.services.regulars_v2.REGULARS_DIR", tmp_path)
|
||||||
|
from backend.services.regulars_v2 import write_new_regular
|
||||||
|
|
||||||
|
write_new_regular(
|
||||||
|
name="Bobby",
|
||||||
|
voice="Marcus",
|
||||||
|
age=34,
|
||||||
|
identity_paragraph="A landscaper in Las Cruces who...",
|
||||||
|
arc_plan="3 episodes: distant → reveal → apology",
|
||||||
|
first_call_summary="Called about damaged car",
|
||||||
|
)
|
||||||
|
f = tmp_path / "bobby.md"
|
||||||
|
assert f.exists()
|
||||||
|
body = f.read_text()
|
||||||
|
assert "name: Bobby" in body
|
||||||
|
assert "voice: Marcus" in body
|
||||||
|
assert "A landscaper in Las Cruces" in body
|
||||||
|
assert "3 episodes: distant → reveal → apology" in body
|
||||||
@@ -64,6 +64,49 @@ def test_session_get_show_history_summary():
|
|||||||
assert "EARLIER IN THE SHOW" in summary
|
assert "EARLIER IN THE SHOW" in summary
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_history_reactions_constant_is_usable():
|
||||||
|
from backend.main import SHOW_HISTORY_REACTIONS
|
||||||
|
|
||||||
|
assert SHOW_HISTORY_REACTIONS, "generic reaction pool must not be empty"
|
||||||
|
assert all(isinstance(r, str) and r.strip() for r in SHOW_HISTORY_REACTIONS)
|
||||||
|
# Each one is interpolated as "...and you {reaction}." so it must not
|
||||||
|
# carry its own leading/trailing punctuation or an unfilled placeholder.
|
||||||
|
for r in SHOW_HISTORY_REACTIONS:
|
||||||
|
assert not r.endswith("."), r
|
||||||
|
assert "{" not in r, r
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_specific_reaction_falls_back_when_record_has_no_details():
|
||||||
|
"""A CallRecord with neither key_details nor situation_summary hits the
|
||||||
|
generic branch — this used to raise NameError mid-show."""
|
||||||
|
from backend.main import SHOW_HISTORY_REACTIONS
|
||||||
|
|
||||||
|
s = Session()
|
||||||
|
bare = CallRecord(
|
||||||
|
caller_type="ai", caller_name="Jasmine",
|
||||||
|
summary="Talked about her boss", transcript=[],
|
||||||
|
)
|
||||||
|
reaction = s._build_specific_reaction({}, bare)
|
||||||
|
assert reaction in SHOW_HISTORY_REACTIONS
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_show_history_never_raises_when_reaction_branch_fires(monkeypatch):
|
||||||
|
"""Force the reaction branch every time so the fallback path is covered
|
||||||
|
deterministically rather than at its ~15% random rate."""
|
||||||
|
import backend.main as main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.random, "random", lambda: 0.0)
|
||||||
|
|
||||||
|
s = Session()
|
||||||
|
s.call_history.append(CallRecord(
|
||||||
|
caller_type="real", caller_name="Dave",
|
||||||
|
summary="Called about his wife leaving", transcript=[],
|
||||||
|
))
|
||||||
|
summary = s.get_show_history()
|
||||||
|
assert "DAVE" in summary
|
||||||
|
assert "and you " in summary
|
||||||
|
|
||||||
|
|
||||||
def test_session_reset_clears_history():
|
def test_session_reset_clears_history():
|
||||||
s = Session()
|
s = Session()
|
||||||
s.call_history.append(CallRecord(
|
s.call_history.append(CallRecord(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user