repo split: move projects to their own repos as submodules; bulk data purged to Gitea-Storage (Jupiter)
This commit is contained in:
1
projects/radio-show
Submodule
1
projects/radio-show
Submodule
Submodule projects/radio-show added at ca11edbb2a
@@ -1,62 +0,0 @@
|
||||
# Computer Guru Radio Show — Project State
|
||||
|
||||
> READ THIS before starting work on this project.
|
||||
> UPDATE THIS when you begin work (claim a lock) and when you finish (release lock + log changes).
|
||||
> Last updated: 2026-04-20
|
||||
|
||||
---
|
||||
|
||||
## Active Session Locks
|
||||
|
||||
| Session | Working On | Status | Started |
|
||||
|---------|-----------|--------|---------|
|
||||
| _(none active)_ | | | |
|
||||
|
||||
**How to claim a lock:** Add a row before starting work. Remove it when done. Locks older than 2 hours with no update are considered stale.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
**Status:** ACTIVE (recurring weekly)
|
||||
**Last Activity:** 2026-05-16 (most recent episode: "Breakthroughs and Breaches")
|
||||
|
||||
Weekly radio show audio production project. Post-show workflow transforms recorded broadcasts into tiered content: debrief notes, article drafts, archive-ready files. Episodes are stored under `episodes/` by date and title. The `audio-processor/` directory contains processing tooling; `website/` holds show website assets.
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure / Access
|
||||
|
||||
- Episodes directory: `projects/radio-show/episodes/`
|
||||
- Workflow reference: `projects/radio-show/post-show-workflow.md`
|
||||
- Audio processor: `projects/radio-show/audio-processor/`
|
||||
- Website assets: `projects/radio-show/website/`
|
||||
|
||||
No server infrastructure — all local processing.
|
||||
|
||||
---
|
||||
|
||||
## Pending / Next Up
|
||||
|
||||
- [ ] Verify post-show processing complete for 2026-04-18 episode ("Tech That Makes Life Fun")
|
||||
- [ ] Process any queued episodes per `post-show-workflow.md`
|
||||
|
||||
---
|
||||
|
||||
## Recent Changes
|
||||
|
||||
| Date | By | Change | Status |
|
||||
|------|-----|--------|--------|
|
||||
| 2026-05-16 | Mike | Episode: "Breakthroughs and Breaches" | PREPPED (revised - Segment 3 swapped for audience-empowerment content [Google vibe-coded widgets, ChatGPT free upgrade, Quick Share iPhone-Android]; health discoveries [Bristol, McGill, Sydney] moved to Segment 5 buffer) |
|
||||
| 2026-04-18 | Mike | Episode: "Tech That Makes Life Fun" | RECORDED |
|
||||
| 2026-04-11 | Mike | Episode: "Hidden Price Tags" | RECORDED |
|
||||
| 2026-04-05 | Mike | Episode: "AI Gold Rush Warp Speed" | RECORDED |
|
||||
| 2026-03-21 | Mike | Episode: "Who Controls Your Tech" | RECORDED |
|
||||
| 2026-03-14 | Mike | Episode: "AI Misconceptions" | RECORDED |
|
||||
|
||||
---
|
||||
|
||||
## How to Update
|
||||
|
||||
**When starting:** Add your session to Active Session Locks.
|
||||
**When finishing:** Remove your lock row, add entries to Recent Changes, update Current State if needed.
|
||||
27
projects/radio-show/audio-processor/.gitignore
vendored
27
projects/radio-show/audio-processor/.gitignore
vendored
@@ -1,27 +0,0 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv/
|
||||
*.egg-info/
|
||||
|
||||
# Large data files
|
||||
test-data/episodes/
|
||||
test-data/transcripts/
|
||||
episodes/
|
||||
processed/
|
||||
archive-data/
|
||||
logs/
|
||||
|
||||
# Databases (regenerable)
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Model cache
|
||||
.cache/
|
||||
*.pt
|
||||
*.bin
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,142 +0,0 @@
|
||||
# GURU-BEAST-ROG Benchmark Setup
|
||||
|
||||
RTX 4090 performance comparison against DESKTOP-0O8A1RL (RTX 5070 Ti baseline: **149.5x realtime**).
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Sync repo
|
||||
|
||||
The audio-processor lives inside the claudetools repo. Pull latest on main.
|
||||
|
||||
```powershell
|
||||
cd D:\claudetools # or wherever claudetools is cloned on this machine
|
||||
git pull
|
||||
```
|
||||
|
||||
If not yet cloned:
|
||||
```powershell
|
||||
git clone https://azcomputerguru@git.azcomputerguru.com/azcomputerguru/claudetools.git D:\claudetools
|
||||
cd D:\claudetools\projects\radio-show\audio-processor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Python environment
|
||||
|
||||
Requires Python 3.11+. Use `py` launcher on Windows.
|
||||
|
||||
ffmpeg/ffprobe must be on PATH — the voice profiler shells out for audio duration. Without it the pipeline crashes on the first diarize call.
|
||||
|
||||
```powershell
|
||||
# Install ffmpeg if not already present
|
||||
winget install --id=Gyan.FFmpeg -e --accept-source-agreements --accept-package-agreements
|
||||
# Open a new shell so the new PATH takes effect, then verify
|
||||
ffprobe -version
|
||||
```
|
||||
|
||||
```powershell
|
||||
cd D:\claudetools\projects\radio-show\audio-processor
|
||||
|
||||
py -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
|
||||
# PyTorch with CUDA 12.8 (matches RTX 4090 driver)
|
||||
pip install torch==2.11.0+cu128 --index-url https://download.pytorch.org/whl/cu128
|
||||
|
||||
# Core deps
|
||||
pip install faster-whisper==1.2.1 transformers==5.6.2 soundfile==0.13.1
|
||||
pip install numpy==2.4.4 rich==15.0.0 ollama==0.6.1 pyyaml scikit-learn
|
||||
|
||||
# Install project in editable mode
|
||||
pip install -e . --no-deps
|
||||
```
|
||||
|
||||
Verify GPU is visible:
|
||||
```powershell
|
||||
.venv\Scripts\python -c "import torch; print(torch.cuda.get_device_name(0))"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Copy voice profiles from DESKTOP-0O8A1RL
|
||||
|
||||
Voice profiles are not in git (binary numpy files). Copy from the 5070 Ti machine via Tailscale.
|
||||
DESKTOP-0O8A1RL Tailscale IP: **100.92.127.64**
|
||||
|
||||
```powershell
|
||||
# From GURU-BEAST-ROG — pulls the voice-profiles directory over Tailscale
|
||||
robocopy "\\100.92.127.64\claudetools\projects\radio-show\audio-processor\voice-profiles" `
|
||||
"D:\claudetools\projects\radio-show\audio-processor\voice-profiles" /E /COPYALL
|
||||
```
|
||||
|
||||
If the network share isn't available, copy manually or use scp:
|
||||
```powershell
|
||||
scp -r mike@100.92.127.64:"D:/claudetools/projects/radio-show/audio-processor/voice-profiles" .
|
||||
```
|
||||
|
||||
Expected contents after copy:
|
||||
```
|
||||
voice-profiles/
|
||||
profiles.json
|
||||
mike-swanson/
|
||||
composite.npy
|
||||
embedding_0000.npy ... embedding_0179.npy (180 files)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Download test episodes from IX server
|
||||
|
||||
Tailscale must be running. IX server: **172.16.3.10** (use Python paramiko — raw SSH has key agent interference).
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python - << 'EOF'
|
||||
import paramiko, os
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect('172.16.3.10', username='root', password='Gptf*77ttb!@#!@#',
|
||||
look_for_keys=False, allow_agent=False, timeout=30)
|
||||
sftp = client.open_sftp()
|
||||
|
||||
os.makedirs('test-data/episodes', exist_ok=True)
|
||||
|
||||
downloads = [
|
||||
('/home/gurushow/public_html/archive/2011/3-12-11 HR 1.mp3', 'test-data/episodes/2011-03-12-hr1.mp3'),
|
||||
('/home/gurushow/public_html/archive/2012/3 - March/3-10-12HR1.mp3','test-data/episodes/2012-03-10-hr1.mp3'),
|
||||
('/home/gurushow/public_html/archive/2012/6 - June/6-9-12-HR1.mp3', 'test-data/episodes/2012-06-09-hr1.mp3'),
|
||||
('/home/gurushow/public_html/archive/2014/06/s6e19.mp3', 'test-data/episodes/2014-s6e19.mp3'),
|
||||
('/home/gurushow/public_html/archive/2016/06/s8e43.mp3', 'test-data/episodes/2016-s8e43.mp3'),
|
||||
('/home/gurushow/public_html/archive/2017/04/s9e30.mp3', 'test-data/episodes/2017-s9e30.mp3'),
|
||||
]
|
||||
|
||||
for remote, local in downloads:
|
||||
size_mb = sftp.stat(remote).st_size / 1024 / 1024
|
||||
print(f'Downloading {local} ({size_mb:.1f} MB)...', flush=True)
|
||||
sftp.get(remote, local)
|
||||
print(' done', flush=True)
|
||||
|
||||
sftp.close()
|
||||
client.close()
|
||||
print('All downloads complete.')
|
||||
EOF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Run benchmark
|
||||
|
||||
```powershell
|
||||
.venv\Scripts\python benchmark.py
|
||||
```
|
||||
|
||||
This diarizes all 6 test episodes, prints per-episode timing, and compares to the 5070 Ti baseline.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Report results
|
||||
|
||||
Post the benchmark output in the session log or share back to DESKTOP-0O8A1RL.
|
||||
|
||||
The key number to compare: **total realtime factor** (5070 Ti got 149.5x).
|
||||
|
||||
Also note any Q&A pair count differences — same episodes should produce same pairs on both machines (results are deterministic given the same voice profiles).
|
||||
@@ -1,135 +0,0 @@
|
||||
# Mac Build Task: Radio Show Audio Processor
|
||||
|
||||
**Date:** 2026-03-21
|
||||
**From:** CachyOS workstation (acg-guru-5070)
|
||||
**To:** Mac Claude instance (Mikes-MacBook-Air, M4)
|
||||
**Priority:** High — this is blocked on the Linux workstation
|
||||
|
||||
---
|
||||
|
||||
## What We Need
|
||||
|
||||
Build a Mac-native version of the radio show audio processor that can run the **transcription and voice profiling pipeline** on Apple Silicon (M4). The Linux workstation's RTX 5070 Ti has a known GPU firmware bug that crashes after ~3 minutes of sustained compute, making GPU-accelerated transcription impossible until NVIDIA fixes it.
|
||||
|
||||
## Why the Mac
|
||||
|
||||
The CachyOS workstation's NVIDIA RTX 5070 Ti Laptop GPU hits a **GSP (GPU System Processor) firmware crash** under sustained load. This is a known, unresolved bug across all RTX 50-series (Blackwell) GPUs on Linux:
|
||||
|
||||
- Error: `NVRM: _issueRpcLarge: rpcSendMessage failed with status 0x00000062`
|
||||
- Triggers after ~3-5 minutes of continuous GPU compute
|
||||
- GPU enters full ERR! state, requires hard reboot (warm reboot hangs)
|
||||
- Cannot disable GSP on 50-series (open kernel module required, no `NVreg_EnableGpuFirmware=0`)
|
||||
- NVIDIA internal bug #5953411 filed, no fix available
|
||||
- Affects drivers 580.x, 590.x, 595.x (current: 595.45.04)
|
||||
- Power management tweaks, persistence mode, clock locking — none helped
|
||||
- See: session logs `2026-03-21-session.md` and `2026-03-20-session.md` for full diagnosis
|
||||
|
||||
The M4 MacBook Air with 16GB unified memory can run this workload on CPU or MPS backend without driver issues.
|
||||
|
||||
## The Project
|
||||
|
||||
**Location in repo:** `projects/radio-show/audio-processor/`
|
||||
|
||||
**Goal:** Automated pipeline for processing "The Computer Guru Show" radio recordings. The immediate task is **voice training** — transcribing 9 archive episodes and building speaker embeddings to identify the host (Mike Swanson) vs. callers/guests/commercials.
|
||||
|
||||
### What Already Works (built on Linux, may need Mac adaptation)
|
||||
|
||||
1. **Voice Profiler** (`src/voice_profiler.py`) — Uses WavLM (Microsoft `microsoft/wavlm-base-sv`) for speaker verification via x-vector embeddings. **WORKING** — 180 embeddings generated, composite built. Host voice scores 0.90-0.98 similarity, non-host 0.53-0.65. Threshold tuned to 0.83.
|
||||
|
||||
2. **Transcriber** (`src/transcriber.py`) — Uses `faster-whisper` with `large-v3` model. On Linux it was configured for CUDA. Only 1 of 9 episodes transcribed before GPU died.
|
||||
|
||||
3. **Config** (`config.yaml`) — All pipeline settings, thresholds, paths.
|
||||
|
||||
4. **Voice profiles** — `voice-profiles/mike-swanson/` has 180 `.npy` embedding files + `composite.npy` + `profiles.json`. These are numpy arrays, platform-independent.
|
||||
|
||||
5. **One completed transcript** — `training-data/transcripts/2010-10-02-hr1/` (534 segments, transcript.json + .srt + .txt)
|
||||
|
||||
### What Needs Doing on Mac
|
||||
|
||||
**Primary task: Transcribe the remaining 8 episodes:**
|
||||
```
|
||||
training-data/episodes/2011-06-04-hr1.mp3 (7.4MB, ~43 min)
|
||||
training-data/episodes/2011-09-10-hr1.mp3 (11MB)
|
||||
training-data/episodes/2014-s6e05.mp3 (9.5MB)
|
||||
training-data/episodes/2015-s7e30.mp3 (9.0MB)
|
||||
training-data/episodes/2016-s8e42.mp3 (19MB)
|
||||
training-data/episodes/2017-s9e26.mp3 (48MB)
|
||||
training-data/episodes/2018-s10e17.mp3 (21MB)
|
||||
training-data/episodes/2018-s10e21.mp3 (21MB)
|
||||
```
|
||||
|
||||
Output each to: `training-data/transcripts/{episode-stem}/transcript.json` (+ .srt, .txt)
|
||||
|
||||
**Secondary: Verify voice profiles work on Mac** — load the existing `.npy` embeddings and run similarity checks against the new transcripts.
|
||||
|
||||
### Mac-Specific Build Notes
|
||||
|
||||
1. **Do NOT try to port the Linux code directly.** Build fresh for Mac hardware. The existing code has CUDA-specific paths (`src/gpu.py` sets `LD_LIBRARY_PATH` for CUDA 12), nvidia-specific device selection, etc. It's cleaner to build natively.
|
||||
|
||||
2. **Reference the existing code for architecture and logic**, especially:
|
||||
- `src/voice_profiler.py` — the WavLM embedding approach, similarity thresholds, profile structure
|
||||
- `src/transcriber.py` — the Whisper pipeline stages, output format (TranscriptSegment dataclass)
|
||||
- `config.yaml` — all the tuned parameters
|
||||
- `README.md` — full architecture doc for the 6-stage pipeline
|
||||
|
||||
3. **Hardware target: Apple M4, 16GB unified memory**
|
||||
- `faster-whisper` + ctranslate2 supports CPU on macOS (no MPS backend for ctranslate2)
|
||||
- `large-v3` model needs ~3GB RAM — fits easily in 16GB
|
||||
- Expected speed: ~1x realtime on M4 CPU (43-min episode takes ~43 min)
|
||||
- Consider `medium` model if `large-v3` is too slow — tradeoff is accuracy
|
||||
- PyTorch MPS backend works for `pyannote.audio` and WavLM (transformers)
|
||||
|
||||
4. **Dependencies for Mac:**
|
||||
```bash
|
||||
brew install ffmpeg
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install faster-whisper pyannote.audio torch torchaudio pydub librosa scikit-learn ollama rich pyyaml
|
||||
```
|
||||
- No CUDA packages needed — pip will pull CPU-only torch or MPS-enabled torch for macOS
|
||||
- `pyannote.audio` requires HuggingFace token (accept model license first): https://huggingface.co/pyannote/speaker-diarization-3.1
|
||||
|
||||
5. **Ollama models available on Mac** (per machine spec):
|
||||
- `qwen3:14b` — use for content analysis (Stage 6)
|
||||
- `nomic-embed-text` — for grepai, not needed for audio processing
|
||||
|
||||
6. **Output compatibility:** Keep the same output format (JSON with segments, timestamps, speaker labels) so the Linux workstation can consume the results after git pull.
|
||||
|
||||
### Architecture Reference
|
||||
|
||||
```
|
||||
Raw MP3 → 1. Transcribe (Whisper) → 2. Diarize (pyannote) → 3. Detect Segments
|
||||
→ 4. Remove Commercials → 5. Split Segments → 6. Analyze (Ollama)
|
||||
```
|
||||
|
||||
For now, only Stages 1-2 matter. Stages 3-6 can wait.
|
||||
|
||||
### Key Thresholds (from working Linux version)
|
||||
|
||||
- Whisper model: `large-v3`
|
||||
- Whisper language: `en`
|
||||
- Voice profile host match threshold: `0.83`
|
||||
- Min/max speakers for diarization: 1-6
|
||||
- WavLM model: `microsoft/wavlm-base-sv` (speaker verification, x-vector embeddings)
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. Training episodes are in `training-data/episodes/` (already in git, 151MB total)
|
||||
2. Voice profiles are in `voice-profiles/mike-swanson/` (already in git)
|
||||
3. Transcripts go to `training-data/transcripts/{episode-stem}/`
|
||||
4. After Mac completes transcription, commit + push to Gitea
|
||||
5. Linux workstation pulls results and continues with Stages 3-6
|
||||
|
||||
## Session Logs for Context
|
||||
|
||||
Read these for the full story of what was built and why:
|
||||
|
||||
- `session-logs/2026-03-21-session.md` — Voice profiling results, GPU errors, transcription attempts, diagnosis
|
||||
- `session-logs/2026-03-20-session.md` — Earlier session (may have additional audio processor context)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. All 8 remaining episodes transcribed with timestamps and segments
|
||||
2. Transcripts in the same JSON format as `training-data/transcripts/2010-10-02-hr1/transcript.json`
|
||||
3. Voice profiles load and produce reasonable similarity scores on Mac
|
||||
4. Results committed to Gitea so Linux workstation can pull them
|
||||
@@ -1,365 +0,0 @@
|
||||
# Radio Show Audio Processor
|
||||
|
||||
Automated pipeline for processing The Computer Guru Show recordings into podcast-ready audio, transcripts, and segmented clips.
|
||||
|
||||
## What It Does
|
||||
|
||||
```
|
||||
Raw MP3 (full broadcast with commercials)
|
||||
│
|
||||
├── 1. Transcribe (Whisper + GPU)
|
||||
│ └── Full transcript with timestamps
|
||||
│
|
||||
├── 2. Speaker Diarization (pyannote)
|
||||
│ └── Who said what (host vs. callers vs. guests)
|
||||
│
|
||||
├── 3. Segment Detection
|
||||
│ ├── Identify show segments vs. commercials
|
||||
│ ├── Detect music/jingles (known + discovered)
|
||||
│ └── Map segments to show prep structure
|
||||
│
|
||||
├── 4. Commercial Removal
|
||||
│ └── Clean episode MP3 (show content only)
|
||||
│
|
||||
├── 5. Segment Splitting
|
||||
│ ├── Individual segment MP3s (for social media)
|
||||
│ └── Chapter markers (for podcast players)
|
||||
│
|
||||
└── 6. Content Analysis (Ollama)
|
||||
├── Episode summary
|
||||
├── Topic extraction
|
||||
├── Key quotes
|
||||
└── Auto-populate post-show debrief
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Pipeline Stages
|
||||
|
||||
#### Stage 1: Transcription — `faster-whisper` (GPU)
|
||||
- **Model:** `large-v3` (best accuracy, ~3GB VRAM)
|
||||
- **Why faster-whisper:** CTranslate2 backend, 4x faster than OpenAI whisper, lower VRAM
|
||||
- **Output:** Word-level timestamps, language detection
|
||||
- **Hardware:** RTX 5070 Ti (12GB VRAM) — plenty for large-v3
|
||||
|
||||
#### Stage 2: Speaker Diarization — `pyannote.audio`
|
||||
- **Model:** `pyannote/speaker-diarization-3.1`
|
||||
- **Purpose:** Identify speaker turns (host, caller 1, caller 2, etc.)
|
||||
- **Voice enrollment:** Bootstrapped from archive (hundreds of hours of host speech)
|
||||
- **Output:** Speaker segments with timestamps
|
||||
|
||||
#### Stage 3: Segment Detection — Multi-Signal Classifier
|
||||
|
||||
Commercial and segment detection uses multiple signals combined, because not all show production elements are in the archive — bumpers, stingers, and jingles vary across stations and eras.
|
||||
|
||||
**Signal 1: Known element fingerprints (seed library)**
|
||||
- Fingerprint the production elements we DO have (intros, outros, bumpers from archive)
|
||||
- Match against episodes to detect known boundaries
|
||||
- Partial coverage — some elements won't match
|
||||
|
||||
**Signal 2: Unknown element discovery**
|
||||
- Detect short non-speech audio segments (music, jingles, produced audio) that don't match any known fingerprint
|
||||
- Cluster unknown elements across episodes — if the same 5-second clip appears in 30 episodes, it's a show element
|
||||
- Flag new clusters for host review and naming
|
||||
- Discovered elements get added to the fingerprint library automatically
|
||||
|
||||
**Signal 3: Speaker identity**
|
||||
- Host voice present = show content
|
||||
- Non-host voices with commercial audio characteristics (compressed, produced, different acoustic environment) = ads
|
||||
- Host voice absent for extended periods (>30s) = likely commercial break
|
||||
|
||||
**Signal 4: Audio characteristics**
|
||||
- Volume/loudness shifts (commercials often have different LUFS profiles)
|
||||
- Spectral characteristics (produced/compressed commercial audio vs. live studio mic)
|
||||
- Silence gaps (dead air between show and ads)
|
||||
- Audio environment changes (room tone, background noise differences)
|
||||
|
||||
**Signal 5: Learned break patterns (from archive)**
|
||||
- HR1/HR2 file boundaries = confirmed commercial break locations
|
||||
- Train a classifier on the audio features at these known boundaries
|
||||
- Generalize to detect similar patterns within single-file recordings
|
||||
|
||||
**Signal 6: Structural heuristics**
|
||||
- Commercial breaks are typically 2-5 minutes
|
||||
- Shows typically break every 12-20 minutes
|
||||
- Transition phrases in transcript ("We'll be right back", "Welcome back")
|
||||
|
||||
**Combined scoring:** Each signal contributes a confidence score. A segment is classified as commercial when the combined score exceeds a threshold. This is resilient to missing fingerprints — even without a known bumper match, the other signals can still identify breaks.
|
||||
|
||||
#### Stage 4: Commercial Removal — `ffmpeg`
|
||||
- Stitch show segments together with crossfades
|
||||
- Normalize audio levels (EBU R128 loudness standard)
|
||||
- Output clean podcast-ready MP3
|
||||
|
||||
#### Stage 5: Segment Splitting — `ffmpeg`
|
||||
- Export individual segments as separate MP3s
|
||||
- Apply fade in/out
|
||||
- Add ID3 tags (show name, segment title, date)
|
||||
- Generate chapter markers file (for podcast apps)
|
||||
|
||||
#### Stage 6: Content Analysis — `Ollama` (qwen3:14b or codestral)
|
||||
- Feed transcript + speaker labels to local LLM
|
||||
- Generate:
|
||||
- Episode summary (2-3 paragraphs)
|
||||
- Per-segment summaries
|
||||
- Key quotes with speaker attribution
|
||||
- Topic tags
|
||||
- Suggested blog post topics
|
||||
- Auto-filled post-show debrief template
|
||||
|
||||
### Audio Element Library
|
||||
|
||||
The element library is a **learning system**, not a static collection.
|
||||
|
||||
```
|
||||
element-library/
|
||||
fingerprints.db # SQLite database of audio fingerprints
|
||||
known/ # Source files we have
|
||||
intros/
|
||||
outros/
|
||||
bumpers/
|
||||
promos/
|
||||
discovered/ # Elements found by the discovery system
|
||||
cluster-001.mp3 # Unknown element, appears in 47 episodes
|
||||
cluster-002.mp3 # Unknown element, appears in 12 episodes
|
||||
...
|
||||
metadata.json # Names, categories, date ranges for each element
|
||||
```
|
||||
|
||||
**Lifecycle of a discovered element:**
|
||||
1. Processor detects non-speech audio that doesn't match any known fingerprint
|
||||
2. Audio clip is extracted and stored as a candidate
|
||||
3. Candidate is compared against all other candidates across episodes
|
||||
4. Matches are clustered — same audio in multiple episodes = confirmed element
|
||||
5. New element is fingerprinted and added to the library as "unnamed"
|
||||
6. Host reviews unnamed elements periodically and assigns names/categories
|
||||
7. Named elements improve future detection accuracy
|
||||
|
||||
**Element categories:**
|
||||
- `show-intro` — Full show opening
|
||||
- `show-outro` — Full show closing
|
||||
- `segment-bumper` — Music between show segments
|
||||
- `break-bumper` — Music going into/out of commercial breaks
|
||||
- `station-id` — Station identification (legal requirement, consistent per station)
|
||||
- `promo` — Show promo or cross-promotion
|
||||
- `stinger` — Short audio effect (sound effect, catchphrase)
|
||||
- `unknown` — Not yet categorized
|
||||
|
||||
### Voice Profile System
|
||||
|
||||
**Bootstrapped from 579-episode archive**, not a single enrollment sample.
|
||||
|
||||
```
|
||||
voice-profiles/
|
||||
host-mike-swanson/
|
||||
embedding-composite.npy # Average embedding across all eras
|
||||
embedding-2010.npy # Era-specific (voice changes over time)
|
||||
embedding-2014.npy
|
||||
embedding-2018.npy
|
||||
embedding-2026.npy
|
||||
metadata.json # Speaker name, role, episode count
|
||||
guests/
|
||||
[name].npy # Named guest embeddings (built over time)
|
||||
callers/
|
||||
regular-001.npy # Unnamed repeat caller
|
||||
regular-002.npy
|
||||
unknown/
|
||||
cluster-[id].npy # Voices that appear multiple times, not yet named
|
||||
```
|
||||
|
||||
**Bootstrap process:**
|
||||
1. Diarize 10 diverse archive episodes (different years)
|
||||
2. Dominant speaker in each = host (by far the most speaking time)
|
||||
3. Extract host-only segments, generate embeddings
|
||||
4. Create per-era profiles (voice may change over 8+ years)
|
||||
5. Composite embedding = average across all eras
|
||||
|
||||
**Continuous improvement:**
|
||||
- Each processed episode refines the host embedding
|
||||
- Repeat non-host voices are clustered across episodes
|
||||
- Host reviews clusters: "This voice appears in 47 episodes — who is this?"
|
||||
- Named profiles improve future speaker labeling
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
# System packages
|
||||
sudo pacman -S python-pip ffmpeg
|
||||
|
||||
# Python packages (in a venv)
|
||||
python3 -m venv ~/.local/share/radio-processor
|
||||
source ~/.local/share/radio-processor/bin/activate
|
||||
|
||||
pip install faster-whisper # Transcription (CTranslate2 + CUDA)
|
||||
pip install pyannote.audio # Speaker diarization
|
||||
pip install torch torchaudio # PyTorch (CUDA)
|
||||
pip install silero-vad # Voice activity detection
|
||||
pip install pydub # Audio manipulation
|
||||
pip install librosa # Audio analysis / spectral features
|
||||
pip install chromaprint # Audio fingerprinting (or use dejavu)
|
||||
pip install scikit-learn # Break pattern classifier
|
||||
pip install ollama # Local LLM API
|
||||
pip install rich # CLI progress display
|
||||
```
|
||||
|
||||
### pyannote.audio Access
|
||||
pyannote requires accepting the model license on HuggingFace:
|
||||
1. Create account at huggingface.co
|
||||
2. Accept license at https://huggingface.co/pyannote/speaker-diarization-3.1
|
||||
3. Generate access token
|
||||
4. `huggingface-cli login`
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Full pipeline (new episode)
|
||||
radio-process episode.mp3 --show-prep episodes/2026-03-21-who-controls-your-tech/show-prep.md
|
||||
|
||||
# Just transcribe
|
||||
radio-process episode.mp3 --transcribe-only
|
||||
|
||||
# Process archive episode (training mode — learns elements + voices)
|
||||
radio-process episode-hr1.mp3 episode-hr2.mp3 --archive-mode --date 2016-03-15
|
||||
|
||||
# Batch process archive for training
|
||||
radio-process --batch-train archive/2016/ --output training-data/
|
||||
|
||||
# Enroll host voice from archive (bootstrap)
|
||||
radio-process --bootstrap-voice archive/ --speaker-name "Mike Swanson" --role host
|
||||
|
||||
# Review discovered elements
|
||||
radio-process --review-elements
|
||||
|
||||
# Review unknown speaker clusters
|
||||
radio-process --review-speakers
|
||||
```
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
episodes/YYYY-MM-DD-topic/
|
||||
show-prep.md # Pre-show (existing)
|
||||
post-show-debrief.md # Auto-generated draft
|
||||
raw/
|
||||
full-broadcast.mp3 # Original recording
|
||||
processed/
|
||||
transcript.json # Full transcript with timestamps + speakers
|
||||
transcript.txt # Plain text transcript
|
||||
transcript.srt # Subtitle format
|
||||
podcast-episode.mp3 # Clean episode (commercials removed)
|
||||
chapters.json # Chapter markers
|
||||
detection-report.json # What was detected as commercial/show, confidence scores
|
||||
segments/
|
||||
00-intro.mp3
|
||||
01-the-week-that-was.mp3
|
||||
02-the-government-wants-in.mp3
|
||||
03-jensens-trillion-dollar-bet.mp3
|
||||
04-apple-gives-google-the-keys.mp3
|
||||
05-a-petabyte-of-your-data-gone.mp3
|
||||
06-right-to-repair.mp3
|
||||
07-outro.mp3
|
||||
generated/
|
||||
episode-post.md # For website
|
||||
forum-thread.md # For community forum
|
||||
blog-topic-1.md # Deep-dive article
|
||||
blog-topic-2.md # Deep-dive article
|
||||
analysis.json # LLM analysis output
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
show:
|
||||
name: "The Computer Guru Show"
|
||||
host: "Mike Swanson"
|
||||
typical_duration_minutes: 120 # 2-hour broadcast
|
||||
segment_count: 6
|
||||
has_commercials: true
|
||||
|
||||
audio:
|
||||
whisper_model: "large-v3"
|
||||
whisper_language: "en"
|
||||
output_format: "mp3"
|
||||
output_bitrate: "192k"
|
||||
normalize: true # EBU R128
|
||||
crossfade_ms: 500 # Between stitched segments
|
||||
|
||||
segment_detection:
|
||||
# Fingerprint matching
|
||||
fingerprint_db: "element-library/fingerprints.db"
|
||||
fingerprint_match_threshold: 0.85 # Minimum similarity for a match
|
||||
|
||||
# Element discovery
|
||||
discover_unknown_elements: true
|
||||
min_element_duration_s: 1.0 # Shortest element to detect
|
||||
max_element_duration_s: 30.0 # Longest (full intro might be 20-30s)
|
||||
cluster_similarity_threshold: 0.90 # How similar clips must be to cluster
|
||||
min_cluster_occurrences: 3 # Must appear in 3+ episodes to be an element
|
||||
|
||||
# Commercial classification
|
||||
min_break_duration_s: 30 # Minimum commercial break length
|
||||
max_break_duration_s: 300 # Maximum (5 min)
|
||||
silence_threshold_db: -40 # Silence detection threshold
|
||||
confidence_threshold: 0.70 # Combined score to classify as commercial
|
||||
|
||||
# Signal weights (tune based on accuracy)
|
||||
weights:
|
||||
fingerprint_match: 0.30 # Known element detected
|
||||
speaker_identity: 0.25 # Host voice absent
|
||||
audio_characteristics: 0.20 # Production style differs
|
||||
break_pattern: 0.15 # Matches trained break pattern
|
||||
structural_heuristic: 0.10 # Duration/timing rules
|
||||
|
||||
diarization:
|
||||
min_speakers: 1
|
||||
max_speakers: 6
|
||||
voice_profiles_dir: "voice-profiles/"
|
||||
host_match_threshold: 0.75 # Similarity to host embedding
|
||||
|
||||
llm:
|
||||
model: "qwen3:14b" # Ollama model for analysis
|
||||
ollama_host: "http://localhost:11434"
|
||||
|
||||
paths:
|
||||
episodes_dir: "episodes/"
|
||||
voice_profiles: "voice-profiles/"
|
||||
element_library: "element-library/"
|
||||
output_dir: "processed/"
|
||||
|
||||
archive:
|
||||
server: "172.16.3.10"
|
||||
path: "/home/gurushow/public_html/archive/"
|
||||
elements_path: "/home/gurushow/public_html/archive/Radio/Elements/"
|
||||
```
|
||||
|
||||
## Training Data: 579-Episode Archive
|
||||
|
||||
The archive on IX server (172.16.3.10) contains 579 MP3 files spanning 2010-2018:
|
||||
|
||||
| Year | Files | Size | Notes |
|
||||
|------|-------|------|-------|
|
||||
| 2010 | 43 | 664MB | Season 7 start |
|
||||
| 2011 | 200 | 1.9GB | Peak output |
|
||||
| 2012 | 98 | 1.2GB | |
|
||||
| 2014 | 81 | 783MB | Season 6 (new station) |
|
||||
| 2015 | 50 | 461MB | |
|
||||
| 2016 | 54 | 1.2GB | |
|
||||
| 2017 | 41 | 1.5GB | |
|
||||
| 2018 | 5 | 101MB | Final season 10 episodes |
|
||||
| Elements | 7 MP3 + 18 WAV | 203MB | Partial production library |
|
||||
|
||||
Episodes are split into HR 1 / HR 2 files. The HR boundary is a confirmed commercial break point — used for training the break detection classifier.
|
||||
|
||||
**Important:** Not all production elements are in the archive. Bumpers, stingers, and jingles varied across stations and time periods. The element discovery system handles this by detecting and clustering unknown elements across episodes.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Audiogram generator** — Create video clips with waveform animation + captions for social media
|
||||
2. **Highlight reel** — Auto-detect the most engaging 60-90 seconds (high energy, laughter, emphasis)
|
||||
3. **Show notes generator** — Generate timestamped show notes in podcast standard format
|
||||
4. **RSS feed integration** — Auto-publish processed episodes to podcast RSS feed
|
||||
5. **Sentiment analysis** — Track audience engagement topics over time
|
||||
6. **Topic continuity** — Link topics across episodes ("Last week we talked about X, this week...")
|
||||
7. **Live processing** — Real-time transcription during broadcast for immediate post-show turnaround
|
||||
8. **Cross-episode search** — Full-text search across all transcripts ("When did we talk about net neutrality?")
|
||||
@@ -1,206 +0,0 @@
|
||||
"""
|
||||
Batch-process the full archive: transcribe, diarize (with bumper filter +
|
||||
current profiles), extract intros and Q&A pairs. Resumable — skips any
|
||||
episode whose outputs already exist.
|
||||
|
||||
Output layout mirrors archive-data/episodes/ tree:
|
||||
archive-data/episodes/<year>/.../<stem>.mp3
|
||||
archive-data/transcripts/<year>/.../<stem>/{transcript,diarization,intros,qa}.json
|
||||
|
||||
Skips in-progress download files (modified within last 60s).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from src.gpu import ensure_cuda_libs
|
||||
ensure_cuda_libs()
|
||||
|
||||
import torch
|
||||
from src.config import load_config
|
||||
from src.diarizer import diarize, VoiceProfileStore
|
||||
from src.qa_extractor import (
|
||||
load_diarized_transcript, extract_qa_pairs, attach_caller_names,
|
||||
)
|
||||
from src.speaker_oracle import extract_intros
|
||||
from src.transcriber import transcribe as _transcribe
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
BASE = Path(__file__).parent
|
||||
EPISODES_ROOT = BASE / "archive-data" / "episodes"
|
||||
TRANSCRIPTS_ROOT = BASE / "archive-data" / "transcripts"
|
||||
|
||||
INPROGRESS_GRACE_S = 60.0 # skip files modified within this many seconds
|
||||
|
||||
if not EPISODES_ROOT.exists():
|
||||
console.print(f"[red]No episodes at {EPISODES_ROOT}[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
config = load_config()
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
console.print(f"[bold]Batch process[/bold] device={device} ({torch.cuda.get_device_name(0) if device=='cuda' else 'CPU'})")
|
||||
|
||||
voice_profiles = VoiceProfileStore(
|
||||
config.resolve_path(config.diarization.voice_profiles_dir)
|
||||
)
|
||||
|
||||
|
||||
def out_dir_for(mp3: Path) -> Path:
|
||||
rel = mp3.relative_to(EPISODES_ROOT)
|
||||
return TRANSCRIPTS_ROOT / rel.parent / rel.stem
|
||||
|
||||
|
||||
def is_inprogress(mp3: Path) -> bool:
|
||||
try:
|
||||
mtime = mp3.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
return (time.time() - mtime) < INPROGRESS_GRACE_S
|
||||
|
||||
|
||||
# Collect all MP3s
|
||||
all_mp3s = sorted(p for p in EPISODES_ROOT.rglob("*.mp3") if p.is_file())
|
||||
all_mp3s += sorted(p for p in EPISODES_ROOT.rglob("*.MP3") if p.is_file())
|
||||
all_mp3s = sorted(set(all_mp3s))
|
||||
console.print(f"Found {len(all_mp3s)} MP3 files in {EPISODES_ROOT}")
|
||||
|
||||
t0_total = time.monotonic()
|
||||
processed = 0
|
||||
skipped_done = 0
|
||||
skipped_inprogress = 0
|
||||
errors: list[str] = []
|
||||
|
||||
# Aggregate intros across all transcribed episodes
|
||||
all_intros: dict[str, dict] = {} # name -> {count, role_hints, episodes}
|
||||
|
||||
for idx, mp3 in enumerate(all_mp3s, 1):
|
||||
rel = mp3.relative_to(EPISODES_ROOT)
|
||||
|
||||
if is_inprogress(mp3):
|
||||
skipped_inprogress += 1
|
||||
continue
|
||||
|
||||
out_dir = out_dir_for(mp3)
|
||||
transcript_path = out_dir / "transcript.json"
|
||||
diarization_path = out_dir / "diarization.json"
|
||||
intros_path = out_dir / "intros.json"
|
||||
qa_path = out_dir / "qa.json"
|
||||
|
||||
needs_transcribe = not transcript_path.exists()
|
||||
needs_diarize = not diarization_path.exists()
|
||||
needs_intros = not intros_path.exists()
|
||||
needs_qa = not qa_path.exists()
|
||||
|
||||
if not (needs_transcribe or needs_diarize or needs_intros or needs_qa):
|
||||
skipped_done += 1
|
||||
continue
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
console.print(f"\n[{idx}/{len(all_mp3s)}] {rel}")
|
||||
t0_ep = time.monotonic()
|
||||
|
||||
try:
|
||||
if needs_transcribe:
|
||||
t0 = time.monotonic()
|
||||
console.print(" transcribing...")
|
||||
transcript = _transcribe(mp3, model_size="large-v3", device=device, batch_size=16)
|
||||
transcript.save(out_dir)
|
||||
wall = time.monotonic() - t0
|
||||
rtf = transcript.duration / wall if wall > 0 else 0
|
||||
console.print(f" [green]transcribed: {transcript.duration:.0f}s in {wall:.1f}s ({rtf:.1f}x)[/green]")
|
||||
|
||||
if needs_diarize:
|
||||
t0 = time.monotonic()
|
||||
console.print(" diarizing...")
|
||||
result = diarize(mp3, voice_profiles=voice_profiles,
|
||||
host_match_threshold=0.85,
|
||||
transcript_path=transcript_path)
|
||||
result.save(out_dir)
|
||||
wall = time.monotonic() - t0
|
||||
audio_dur = result.turns[-1].end if result.turns else 0
|
||||
rtf = audio_dur / wall if wall > 0 else 0
|
||||
console.print(f" [green]diarized: {len(result.turns)} turns in {wall:.1f}s ({rtf:.1f}x)[/green]")
|
||||
|
||||
if needs_intros:
|
||||
with open(transcript_path) as f:
|
||||
tdata = json.load(f)
|
||||
intros = extract_intros(tdata.get("segments", []))
|
||||
with open(intros_path, "w") as f:
|
||||
json.dump([
|
||||
{
|
||||
"name": i.name,
|
||||
"role_hint": i.role_hint,
|
||||
"intro_time": i.intro_time,
|
||||
"affiliation": i.affiliation,
|
||||
"fillin_for": i.fillin_for,
|
||||
"source_text": i.source_text[:200],
|
||||
} for i in intros
|
||||
], f, indent=2)
|
||||
for intro in intros:
|
||||
rec = all_intros.setdefault(intro.name, {
|
||||
"count": 0, "roles": set(), "episodes": set(),
|
||||
})
|
||||
rec["count"] += 1
|
||||
rec["roles"].add(intro.role_hint)
|
||||
rec["episodes"].add(str(rel))
|
||||
console.print(f" [green]intros: {len(intros)} extracted[/green]")
|
||||
|
||||
if needs_qa:
|
||||
with open(transcript_path) as f:
|
||||
tdata = json.load(f)
|
||||
segments = load_diarized_transcript(transcript_path, diarization_path)
|
||||
pairs = extract_qa_pairs(segments)
|
||||
attach_caller_names(pairs, tdata.get("segments", []))
|
||||
with open(qa_path, "w") as f:
|
||||
json.dump([p.to_dict() for p in pairs], f, indent=2)
|
||||
named = sum(1 for p in pairs if p.caller_name)
|
||||
console.print(f" [green]Q&A: {len(pairs)} pairs ({named} named)[/green]")
|
||||
|
||||
processed += 1
|
||||
except Exception as e:
|
||||
errors.append(f"{rel}: {e}")
|
||||
console.print(f" [red]ERROR: {e}[/red]")
|
||||
continue
|
||||
|
||||
if idx % 20 == 0:
|
||||
elapsed = time.monotonic() - t0_total
|
||||
console.print(f"\n[bold]progress[/bold] {idx}/{len(all_mp3s)} "
|
||||
f"({processed} processed, {skipped_done} cached, {skipped_inprogress} in-progress, "
|
||||
f"{len(errors)} errors) — {elapsed/60:.1f} min elapsed\n")
|
||||
|
||||
# Persist aggregated intro roster
|
||||
roster_path = TRANSCRIPTS_ROOT / "intro_roster.json"
|
||||
roster = {}
|
||||
for name, rec in all_intros.items():
|
||||
roster[name] = {
|
||||
"count": rec["count"],
|
||||
"roles": sorted(rec["roles"]),
|
||||
"episode_count": len(rec["episodes"]),
|
||||
"episodes": sorted(rec["episodes"])[:20], # cap to first 20 for readability
|
||||
}
|
||||
roster = dict(sorted(roster.items(), key=lambda x: -x[1]["count"]))
|
||||
roster_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(roster_path, "w") as f:
|
||||
json.dump(roster, f, indent=2)
|
||||
|
||||
elapsed = time.monotonic() - t0_total
|
||||
console.print(f"\n[bold green]=== Done ===[/bold green]")
|
||||
console.print(f" processed : {processed}")
|
||||
console.print(f" cached (skipped): {skipped_done}")
|
||||
console.print(f" in-progress : {skipped_inprogress}")
|
||||
console.print(f" errors : {len(errors)}")
|
||||
console.print(f" wall time : {elapsed/60:.1f} min")
|
||||
console.print(f" roster written : {roster_path} ({len(roster)} unique names)")
|
||||
|
||||
if errors:
|
||||
console.print("\n[yellow]Errors:[/yellow]")
|
||||
for e in errors[:20]:
|
||||
console.print(f" {e}")
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Batch transcription script for Mac M4 using mlx-whisper.
|
||||
Transcribes all pending episodes using Apple Silicon GPU acceleration.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
|
||||
import mlx_whisper
|
||||
from pydub import AudioSegment
|
||||
|
||||
|
||||
# Configuration
|
||||
EPISODES_DIR = Path("training-data/episodes")
|
||||
TRANSCRIPTS_DIR = Path("training-data/transcripts")
|
||||
MODEL = "mlx-community/whisper-large-v3-mlx"
|
||||
|
||||
# Episodes to transcribe (skip already completed ones)
|
||||
COMPLETED = {"2010-10-02-hr1"} # Already transcribed on Linux
|
||||
|
||||
|
||||
def format_timestamp(seconds: float) -> str:
|
||||
"""Format seconds as SRT timestamp (HH:MM:SS,mmm)."""
|
||||
td = timedelta(seconds=seconds)
|
||||
hours, remainder = divmod(td.seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
ms = td.microseconds // 1000
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{ms:03d}"
|
||||
|
||||
|
||||
def transcribe_episode(episode_path: Path) -> dict:
|
||||
"""Transcribe a single episode and return results."""
|
||||
print(f"[INFO] Transcribing {episode_path.name}...")
|
||||
start_time = time.time()
|
||||
|
||||
# Get audio duration
|
||||
audio = AudioSegment.from_mp3(str(episode_path))
|
||||
duration_seconds = len(audio) / 1000.0
|
||||
|
||||
# Transcribe with word timestamps
|
||||
result = mlx_whisper.transcribe(
|
||||
str(episode_path),
|
||||
path_or_hf_repo=MODEL,
|
||||
language="en",
|
||||
word_timestamps=True,
|
||||
)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
speed = duration_seconds / elapsed
|
||||
|
||||
print(f"[OK] Done in {elapsed:.1f}s ({speed:.1f}x realtime)")
|
||||
print(f"[INFO] Segments: {len(result.get('segments', []))}")
|
||||
|
||||
return result, duration_seconds
|
||||
|
||||
|
||||
def save_transcript(result: dict, duration: float, output_dir: Path):
|
||||
"""Save transcript in JSON, SRT, and TXT formats."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
segments = result.get("segments", [])
|
||||
|
||||
# Build output structure matching existing format
|
||||
output = {
|
||||
"language": result.get("language", "en"),
|
||||
"language_probability": 1.0,
|
||||
"duration": duration,
|
||||
"segments": []
|
||||
}
|
||||
|
||||
for i, seg in enumerate(segments):
|
||||
segment_data = {
|
||||
"id": i,
|
||||
"text": seg.get("text", "").strip(),
|
||||
"start": seg.get("start", 0),
|
||||
"end": seg.get("end", 0),
|
||||
"words": []
|
||||
}
|
||||
|
||||
# Add word-level data if available
|
||||
words = seg.get("words", [])
|
||||
for word_info in words:
|
||||
segment_data["words"].append({
|
||||
"word": word_info.get("word", ""),
|
||||
"start": word_info.get("start", 0),
|
||||
"end": word_info.get("end", 0),
|
||||
"probability": word_info.get("probability", 0)
|
||||
})
|
||||
|
||||
output["segments"].append(segment_data)
|
||||
|
||||
# Save JSON
|
||||
json_path = output_dir / "transcript.json"
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(output, f, indent=2)
|
||||
print(f"[OK] Saved {json_path}")
|
||||
|
||||
# Save SRT
|
||||
srt_path = output_dir / "transcript.srt"
|
||||
with open(srt_path, "w") as f:
|
||||
for i, seg in enumerate(segments, 1):
|
||||
start_ts = format_timestamp(seg.get("start", 0))
|
||||
end_ts = format_timestamp(seg.get("end", 0))
|
||||
text = seg.get("text", "").strip()
|
||||
f.write(f"{i}\n{start_ts} --> {end_ts}\n{text}\n\n")
|
||||
print(f"[OK] Saved {srt_path}")
|
||||
|
||||
# Save TXT
|
||||
txt_path = output_dir / "transcript.txt"
|
||||
with open(txt_path, "w") as f:
|
||||
for seg in segments:
|
||||
f.write(seg.get("text", "").strip() + "\n")
|
||||
print(f"[OK] Saved {txt_path}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Radio Show Batch Transcription - Mac M4 + mlx-whisper")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Find episodes to process
|
||||
episodes = sorted(EPISODES_DIR.glob("*.mp3"))
|
||||
pending = [ep for ep in episodes if ep.stem not in COMPLETED]
|
||||
|
||||
print(f"[INFO] Found {len(episodes)} episodes, {len(pending)} pending")
|
||||
print(f"[INFO] Model: {MODEL}")
|
||||
print()
|
||||
|
||||
if not pending:
|
||||
print("[OK] All episodes already transcribed!")
|
||||
return
|
||||
|
||||
total_start = time.time()
|
||||
completed = 0
|
||||
failed = []
|
||||
|
||||
for i, episode in enumerate(pending, 1):
|
||||
print(f"\n[{i}/{len(pending)}] {episode.name}")
|
||||
print("-" * 40)
|
||||
|
||||
try:
|
||||
result, duration = transcribe_episode(episode)
|
||||
output_dir = TRANSCRIPTS_DIR / episode.stem
|
||||
save_transcript(result, duration, output_dir)
|
||||
completed += 1
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed: {e}")
|
||||
failed.append(episode.name)
|
||||
|
||||
# Summary
|
||||
total_elapsed = time.time() - total_start
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("SUMMARY")
|
||||
print("=" * 60)
|
||||
print(f"[OK] Completed: {completed}/{len(pending)}")
|
||||
print(f"[INFO] Total time: {total_elapsed/60:.1f} minutes")
|
||||
|
||||
if failed:
|
||||
print(f"[WARNING] Failed: {', '.join(failed)}")
|
||||
|
||||
print()
|
||||
print("[SUCCESS] Batch transcription complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,226 +0,0 @@
|
||||
"""
|
||||
Benchmark: transcribe + diarize + Q&A extraction on the 6 test episodes.
|
||||
Reports per-episode and total realtime factors.
|
||||
Compare to DESKTOP-0O8A1RL (RTX 5070 Ti) baseline: 149.5x realtime for diarization.
|
||||
"""
|
||||
import sys, os, time
|
||||
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
|
||||
from pathlib import Path
|
||||
from src.gpu import ensure_cuda_libs
|
||||
ensure_cuda_libs()
|
||||
|
||||
import torch
|
||||
from src.config import load_config
|
||||
from src.diarizer import diarize, VoiceProfileStore
|
||||
from src.qa_extractor import load_diarized_transcript, extract_qa_pairs, attach_caller_names
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
BASELINE_RTX = "RTX 5070 Ti (DESKTOP-0O8A1RL)"
|
||||
BASELINE_RTF = 209.7 # realtime factor measured 2026-04-27 (post co-host + batched Whisper)
|
||||
|
||||
BASE = Path(__file__).parent
|
||||
EPISODES = sorted((BASE / "test-data" / "episodes").glob("*.mp3"))
|
||||
TRANS_DIR = BASE / "test-data" / "transcripts"
|
||||
|
||||
config = load_config()
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
if not EPISODES:
|
||||
console.print("[red]No test episodes found in test-data/episodes/ — run Step 4 in BENCH_SETUP.md[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
console.print(f"\n[bold]Computer Guru Show — Diarization Benchmark[/bold]")
|
||||
console.print(f"Device : {device}" + (f" ({torch.cuda.get_device_name(0)})" if device == "cuda" else ""))
|
||||
console.print(f"Baseline: {BASELINE_RTX} @ {BASELINE_RTF}x realtime")
|
||||
console.print(f"Episodes: {len(EPISODES)}\n")
|
||||
|
||||
voice_profiles = VoiceProfileStore(
|
||||
config.resolve_path(config.diarization.voice_profiles_dir)
|
||||
)
|
||||
if not voice_profiles.embeddings:
|
||||
console.print("[red]No voice profiles loaded — copy voice-profiles/ from DESKTOP-0O8A1RL (see BENCH_SETUP.md Step 3)[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
# ── Phase 1: Transcription ─────────────────────────────────────────────────
|
||||
|
||||
console.print("[bold]Phase 1: Transcription[/bold]")
|
||||
|
||||
trans_results = []
|
||||
trans_total_audio = 0.0
|
||||
trans_total_wall = 0.0
|
||||
|
||||
import json
|
||||
from src.transcriber import transcribe as _transcribe
|
||||
|
||||
for ep in EPISODES:
|
||||
trans_ep_dir = TRANS_DIR / ep.stem
|
||||
trans_ep_dir.mkdir(parents=True, exist_ok=True)
|
||||
transcript_path = trans_ep_dir / "transcript.json"
|
||||
|
||||
if transcript_path.exists():
|
||||
with open(transcript_path) as f:
|
||||
td = json.load(f)
|
||||
dur = td.get("duration", 0)
|
||||
console.print(f" [dim]{ep.stem}: already transcribed ({dur:.0f}s)[/dim]")
|
||||
trans_results.append((ep, transcript_path, dur, 0.0))
|
||||
continue
|
||||
|
||||
console.print(f" Transcribing {ep.name}...")
|
||||
t0 = time.monotonic()
|
||||
|
||||
transcript = _transcribe(ep, model_size="large-v3", device=device, batch_size=16)
|
||||
wall = time.monotonic() - t0
|
||||
rtf = transcript.duration / wall
|
||||
|
||||
transcript.save(trans_ep_dir)
|
||||
|
||||
console.print(f" [green]{ep.stem}: {transcript.duration:.0f}s audio in {wall:.1f}s = {rtf:.1f}x realtime[/green]")
|
||||
trans_results.append((ep, transcript_path, transcript.duration, wall))
|
||||
trans_total_audio += transcript.duration
|
||||
trans_total_wall += wall
|
||||
|
||||
if trans_total_wall > 0:
|
||||
console.print(f" Transcription total: {trans_total_audio:.0f}s audio in {trans_total_wall:.1f}s = {trans_total_audio/trans_total_wall:.1f}x realtime\n")
|
||||
|
||||
# ── Phase 2: Diarization ───────────────────────────────────────────────────
|
||||
|
||||
console.print("[bold]Phase 2: Diarization[/bold]")
|
||||
|
||||
diar_rows = []
|
||||
diar_total_audio = 0.0
|
||||
diar_total_wall = 0.0
|
||||
|
||||
for ep, transcript_path, audio_dur, _ in trans_results:
|
||||
trans_ep_dir = TRANS_DIR / ep.stem
|
||||
diarization_path = trans_ep_dir / "diarization.json"
|
||||
|
||||
if audio_dur == 0:
|
||||
import json
|
||||
with open(transcript_path) as f:
|
||||
audio_dur = json.load(f).get("duration", 0)
|
||||
|
||||
t0 = time.monotonic()
|
||||
result = diarize(ep, voice_profiles=voice_profiles, host_match_threshold=0.85,
|
||||
transcript_path=transcript_path)
|
||||
wall = time.monotonic() - t0
|
||||
rtf = audio_dur / wall if wall > 0 else 0
|
||||
|
||||
result.save(trans_ep_dir)
|
||||
|
||||
host_s = sum(t.end - t.start for t in result.turns if t.speaker == "HOST")
|
||||
caller_s = sum(t.end - t.start for t in result.turns if t.speaker == "CALLER")
|
||||
|
||||
diar_rows.append({
|
||||
"episode": ep.stem,
|
||||
"audio_s": audio_dur,
|
||||
"wall_s": wall,
|
||||
"rtf": rtf,
|
||||
"turns": len(result.turns),
|
||||
"host_s": host_s,
|
||||
"caller_s": caller_s,
|
||||
})
|
||||
diar_total_audio += audio_dur
|
||||
diar_total_wall += wall
|
||||
|
||||
console.print(
|
||||
f" {ep.stem}: {len(result.turns)} turns | "
|
||||
f"HOST {host_s:.0f}s / CALLER {caller_s:.0f}s "
|
||||
f"[{wall:.1f}s wall / {rtf:.1f}x realtime]"
|
||||
)
|
||||
|
||||
total_rtf = diar_total_audio / diar_total_wall if diar_total_wall > 0 else 0
|
||||
|
||||
# ── Phase 2.5: Speaker name resolution from transcript intros ───────────────
|
||||
|
||||
console.print("\n[bold]Phase 2.5: Name Resolution[/bold]")
|
||||
|
||||
from src.speaker_oracle import resolve_from_files, named_speaker_summary
|
||||
import json as _json
|
||||
|
||||
for ep, transcript_path, audio_dur, _ in trans_results:
|
||||
trans_ep_dir = TRANS_DIR / ep.stem
|
||||
diarization_path = trans_ep_dir / "diarization.json"
|
||||
if not diarization_path.exists():
|
||||
continue
|
||||
|
||||
with open(transcript_path) as f:
|
||||
td = _json.load(f)
|
||||
duration = td.get("duration", audio_dur or 0)
|
||||
|
||||
named = resolve_from_files(transcript_path, diarization_path)
|
||||
summary = named_speaker_summary(named, duration)
|
||||
|
||||
# Show only resolved names (caller/guest/fillin) — drop HOST/BUMPER/UNKNOWN
|
||||
resolved = {k: v for k, v in summary.items()
|
||||
if k.startswith(("caller:", "guest:", "fillin:"))}
|
||||
unresolved_caller = summary.get("CALLER", 0) + summary.get("CO-HOST", 0)
|
||||
|
||||
if resolved or unresolved_caller:
|
||||
names_str = ", ".join(f"{k.split(': ')[1]} ({v:.0f}s)" for k, v in resolved.items())
|
||||
console.print(
|
||||
f" {ep.stem}: {len(resolved)} named ({names_str or 'none'})"
|
||||
+ (f" [unresolved: {unresolved_caller:.0f}s]" if unresolved_caller else "")
|
||||
)
|
||||
|
||||
# ── Phase 3: Q&A extraction ────────────────────────────────────────────────
|
||||
|
||||
console.print("\n[bold]Phase 3: Q&A Extraction[/bold]")
|
||||
|
||||
qa_rows = []
|
||||
for ep, transcript_path, audio_dur, _ in trans_results:
|
||||
trans_ep_dir = TRANS_DIR / ep.stem
|
||||
diarization_path = trans_ep_dir / "diarization.json"
|
||||
segments = load_diarized_transcript(transcript_path, diarization_path)
|
||||
pairs = extract_qa_pairs(segments)
|
||||
|
||||
# Attach caller names from transcript intros
|
||||
with open(transcript_path) as f:
|
||||
td = _json.load(f)
|
||||
attach_caller_names(pairs, td.get("segments", []))
|
||||
|
||||
named = sum(1 for p in pairs if p.caller_name)
|
||||
name_str = ", ".join(p.caller_name for p in pairs if p.caller_name) or "—"
|
||||
qa_rows.append((ep.stem, len(pairs)))
|
||||
console.print(f" {ep.stem}: {len(pairs)} pairs ({named} named: {name_str})")
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────────────────
|
||||
|
||||
console.print()
|
||||
table = Table(title="Diarization Benchmark Results", show_footer=True)
|
||||
table.add_column("Episode", footer="TOTAL")
|
||||
table.add_column("Audio", footer=f"{diar_total_audio:.0f}s")
|
||||
table.add_column("Wall", footer=f"{diar_total_wall:.1f}s")
|
||||
table.add_column("RTF", footer=f"[bold]{total_rtf:.1f}x[/bold]")
|
||||
table.add_column("Turns")
|
||||
table.add_column("Q&A pairs")
|
||||
|
||||
for row, (ep_stem, qa_count) in zip(diar_rows, qa_rows):
|
||||
table.add_row(
|
||||
row["episode"],
|
||||
f"{row['audio_s']:.0f}s",
|
||||
f"{row['wall_s']:.1f}s",
|
||||
f"{row['rtf']:.1f}x",
|
||||
str(row["turns"]),
|
||||
str(qa_count),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
delta = total_rtf - BASELINE_RTF
|
||||
sign = "+" if delta >= 0 else ""
|
||||
console.print(
|
||||
f"\n[bold]vs {BASELINE_RTX}:[/bold] "
|
||||
f"{BASELINE_RTF:.1f}x -> {total_rtf:.1f}x "
|
||||
f"({sign}{delta:.1f}x, {sign}{delta/BASELINE_RTF*100:.1f}%)"
|
||||
)
|
||||
console.print(
|
||||
f"\nGPU: {torch.cuda.get_device_name(0) if device == 'cuda' else 'CPU'}"
|
||||
)
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
Build voice profile for Clay (Nerd Junkies — fill-in for Tara) from
|
||||
hand-picked windows in 2015-s7e19.
|
||||
|
||||
Adds a Mike-similarity filter (skip any chunk whose cosine vs Mike's
|
||||
composite is >= 0.85) so Mike's interjections during Clay's monologues
|
||||
don't contaminate Clay's profile.
|
||||
"""
|
||||
import os, sys
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from src.gpu import ensure_cuda_libs
|
||||
ensure_cuda_libs()
|
||||
|
||||
import torch
|
||||
from src.voice_profiler import VoiceProfiler, SpeakerProfile
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
BASE = Path(__file__).parent
|
||||
PROFILES_DIR = BASE / "voice-profiles"
|
||||
EPISODES_DIR = BASE / "test-data" / "episodes"
|
||||
|
||||
# Clay windows in 2015-s7e19 (transcript-vetted: Mike+Clay banter,
|
||||
# no callers in these ranges). Chunks matching Mike's profile will
|
||||
# be filtered out at build time.
|
||||
CLAY_WINDOWS = {
|
||||
"2015-s7e19.mp3": [
|
||||
(90, 150), # 01:30-02:30 — Clay introducing Nerd Junkies team
|
||||
(2520, 2640), # 42:00-44:00 — Clay's 2014 gaming year-in-review
|
||||
(2730, 2820), # 45:30-47:00 — Clay on VR/Oculus
|
||||
],
|
||||
}
|
||||
|
||||
COHOST_NAME = "Clay"
|
||||
# Mike-filter would drop everything (Mike's profile matches at 0.92+ on
|
||||
# any chunk in these windows because Mike is interjecting and his profile
|
||||
# is broad). Disabled — relying on cosine comparison at diarization time
|
||||
# to put Mike chunks in Mike's bucket and Clay chunks in Clay's.
|
||||
MIKE_FILTER_THRESHOLD = 1.01 # effectively disabled
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
console.print(f"Device: {device}")
|
||||
|
||||
profiler = VoiceProfiler(PROFILES_DIR, device=device)
|
||||
|
||||
mike = profiler.profiles.get("Mike Swanson")
|
||||
if mike is None or mike.composite_embedding is None:
|
||||
console.print("[red]Mike's profile not loaded — abort.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
if COHOST_NAME not in profiler.profiles:
|
||||
profiler.profiles[COHOST_NAME] = SpeakerProfile(
|
||||
name=COHOST_NAME,
|
||||
role="cohost",
|
||||
embeddings=[],
|
||||
source_episodes=[],
|
||||
)
|
||||
|
||||
profile = profiler.profiles[COHOST_NAME]
|
||||
console.print(f"\n[bold]Building voice profile: {COHOST_NAME}[/bold]")
|
||||
console.print(f" Mike-similarity filter @ >= {MIKE_FILTER_THRESHOLD}")
|
||||
|
||||
mike_norm = np.linalg.norm(mike.composite_embedding)
|
||||
|
||||
kept = 0
|
||||
skipped_mike = 0
|
||||
failed = 0
|
||||
|
||||
for ep_name, windows in CLAY_WINDOWS.items():
|
||||
ep_path = EPISODES_DIR / ep_name
|
||||
if not ep_path.exists():
|
||||
console.print(f"[yellow] Skipping {ep_name} — not found[/yellow]")
|
||||
continue
|
||||
|
||||
console.print(f"\n Loading {ep_name}...")
|
||||
audio = profiler._load_full_audio(ep_path)
|
||||
profiler._get_model()
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
chunk_s = 10.0
|
||||
chunk_samples = int(chunk_s * SAMPLE_RATE)
|
||||
|
||||
for win_start, win_end in windows:
|
||||
for chunk_start in range(win_start, win_end - int(chunk_s), int(chunk_s)):
|
||||
chunk_end = chunk_start + int(chunk_s)
|
||||
s = int(chunk_start * SAMPLE_RATE)
|
||||
e = s + chunk_samples
|
||||
if e > len(audio):
|
||||
break
|
||||
try:
|
||||
emb = profiler._embed_audio_np(audio[s:e])
|
||||
# Skip chunks that match Mike strongly (Mike interjections)
|
||||
mike_sim = float(np.dot(mike.composite_embedding, emb) /
|
||||
(mike_norm * np.linalg.norm(emb) + 1e-8))
|
||||
if mike_sim >= MIKE_FILTER_THRESHOLD:
|
||||
skipped_mike += 1
|
||||
console.print(f" [dim yellow]skip Mike @ {chunk_start}s "
|
||||
f"(sim={mike_sim:.2f})[/dim yellow]")
|
||||
continue
|
||||
profile.embeddings.append(emb)
|
||||
kept += 1
|
||||
console.print(f" [dim]+1 @ {chunk_start}s (mike={mike_sim:.2f})[/dim]")
|
||||
except Exception as ex:
|
||||
failed += 1
|
||||
console.print(f" [red]Failed @ {chunk_start}s: {ex}[/red]")
|
||||
|
||||
profile.source_episodes.append(ep_name)
|
||||
|
||||
if not profile.embeddings:
|
||||
console.print("[red]No embeddings collected — check windows / Mike threshold[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
profile.compute_composite()
|
||||
console.print(f"\n[green]{COHOST_NAME} profile built: {profile.num_samples} embeddings, "
|
||||
f"skipped {skipped_mike} as Mike, {failed} failed[/green]")
|
||||
|
||||
# Diagnostics
|
||||
mike_sim = float(np.dot(mike.composite_embedding, profile.composite_embedding) /
|
||||
(mike_norm * np.linalg.norm(profile.composite_embedding) + 1e-8))
|
||||
console.print(f"[bold]Clay vs Mike similarity:[/bold] {mike_sim:.3f} (lower is better separation)")
|
||||
|
||||
tara = profiler.profiles.get("Tara")
|
||||
if tara and tara.composite_embedding is not None:
|
||||
tara_sim = float(np.dot(tara.composite_embedding, profile.composite_embedding) /
|
||||
(np.linalg.norm(tara.composite_embedding) * np.linalg.norm(profile.composite_embedding) + 1e-8))
|
||||
console.print(f"[bold]Clay vs Tara similarity:[/bold] {tara_sim:.3f}")
|
||||
|
||||
profiler.save_profiles()
|
||||
console.print("[bold green]Profile saved.[/bold green]")
|
||||
@@ -1,115 +0,0 @@
|
||||
"""
|
||||
Build voice profile for Tara (co-host) from known co-host speech windows.
|
||||
|
||||
Uses CALLER-labeled windows from the first 60 min of co-host-era episodes,
|
||||
before any real callers would have called in.
|
||||
"""
|
||||
import os, sys
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from pathlib import Path
|
||||
import json
|
||||
import numpy as np
|
||||
from src.gpu import ensure_cuda_libs
|
||||
ensure_cuda_libs()
|
||||
|
||||
import torch
|
||||
from src.voice_profiler import VoiceProfiler, SpeakerProfile
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
BASE = Path(__file__).parent
|
||||
PROFILES_DIR = BASE / "voice-profiles"
|
||||
EPISODES_DIR = BASE / "test-data" / "episodes"
|
||||
TRANS_DIR = BASE / "test-data" / "transcripts"
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
console.print(f"Device: {device}")
|
||||
|
||||
profiler = VoiceProfiler(PROFILES_DIR, device=device)
|
||||
|
||||
# Tara's known speech windows per episode
|
||||
# CALLER turns from diarization that are in the first 60 min (before real callers)
|
||||
# Windows at 0-40s excluded (promo/jingle, not Tara's voice)
|
||||
TARA_WINDOWS = {
|
||||
"2014-s6e19.mp3": [
|
||||
(195, 260),
|
||||
(320, 425),
|
||||
(600, 650),
|
||||
(675, 710),
|
||||
],
|
||||
"2016-s8e43.mp3": [
|
||||
(100, 115),
|
||||
(135, 160),
|
||||
(270, 295),
|
||||
(575, 605),
|
||||
(1185, 1235),
|
||||
(1790, 1870),
|
||||
(2020, 2055),
|
||||
],
|
||||
}
|
||||
|
||||
COHOST_NAME = "Tara"
|
||||
|
||||
if COHOST_NAME not in profiler.profiles:
|
||||
profiler.profiles[COHOST_NAME] = SpeakerProfile(
|
||||
name=COHOST_NAME,
|
||||
role="cohost",
|
||||
embeddings=[],
|
||||
source_episodes=[],
|
||||
)
|
||||
|
||||
profile = profiler.profiles[COHOST_NAME]
|
||||
console.print(f"\n[bold]Building co-host profile for: {COHOST_NAME}[/bold]")
|
||||
|
||||
for ep_name, windows in TARA_WINDOWS.items():
|
||||
ep_path = EPISODES_DIR / ep_name
|
||||
if not ep_path.exists():
|
||||
console.print(f"[yellow] Skipping {ep_name} — not found[/yellow]")
|
||||
continue
|
||||
|
||||
console.print(f"\n Loading {ep_name}...")
|
||||
audio = profiler._load_full_audio(ep_path)
|
||||
profiler._get_model()
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
chunk_s = 10.0
|
||||
chunk_samples = int(chunk_s * SAMPLE_RATE)
|
||||
|
||||
for win_start, win_end in windows:
|
||||
for chunk_start in range(win_start, win_end - int(chunk_s), int(chunk_s)):
|
||||
chunk_end = chunk_start + int(chunk_s)
|
||||
s = int(chunk_start * SAMPLE_RATE)
|
||||
e = s + chunk_samples
|
||||
if e > len(audio):
|
||||
break
|
||||
try:
|
||||
emb = profiler._embed_audio_np(audio[s:e])
|
||||
profile.embeddings.append(emb)
|
||||
console.print(f" [dim]+1 embedding @ {chunk_start}s[/dim]")
|
||||
except Exception as ex:
|
||||
console.print(f" [red]Failed @ {chunk_start}s: {ex}[/red]")
|
||||
|
||||
profile.source_episodes.append(ep_name)
|
||||
|
||||
if not profile.embeddings:
|
||||
console.print("[red]No embeddings collected — check episode paths[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
profile.compute_composite()
|
||||
console.print(f"\n[green]Tara profile built: {profile.num_samples} embeddings "
|
||||
f"from {len(profile.source_episodes)} episodes[/green]")
|
||||
|
||||
# Verify: check cosine similarity vs Mike to ensure separation
|
||||
mike = profiler.profiles.get("Mike Swanson")
|
||||
if mike and mike.composite_embedding is not None and profile.composite_embedding is not None:
|
||||
sim = float(np.dot(mike.composite_embedding, profile.composite_embedding) /
|
||||
(np.linalg.norm(mike.composite_embedding) * np.linalg.norm(profile.composite_embedding) + 1e-8))
|
||||
console.print(f"Tara vs Mike similarity: {sim:.3f} (lower is better separation)")
|
||||
|
||||
profiler.save_profiles()
|
||||
console.print("[bold green]Profile saved.[/bold green]")
|
||||
@@ -1,84 +0,0 @@
|
||||
"""
|
||||
Quick diagnostic: print per-window WavLM similarity scores for one episode.
|
||||
Run before diarize_training.py to understand score distribution.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from src.gpu import ensure_cuda_libs
|
||||
ensure_cuda_libs()
|
||||
|
||||
from src.voice_profiler import VoiceProfiler
|
||||
from src.config import load_config
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
BASE = Path(__file__).parent
|
||||
config = load_config()
|
||||
profiles_dir = config.resolve_path(config.diarization.voice_profiles_dir)
|
||||
|
||||
import torch
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
console.print(f"Device: {device}")
|
||||
|
||||
profiler = VoiceProfiler(profiles_dir, device=device)
|
||||
|
||||
if not profiler.profiles:
|
||||
console.print("[red]No voice profiles loaded[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
# Use the first available episode
|
||||
episodes = sorted((BASE / "training-data" / "episodes").glob("*.mp3"))
|
||||
if not episodes:
|
||||
console.print("[red]No episodes found[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
ep = episodes[0]
|
||||
console.print(f"\nAnalyzing first 20 minutes of: {ep.name}")
|
||||
console.print("Format: [time] similarity_score label\n")
|
||||
|
||||
duration = profiler._get_duration(ep)
|
||||
# Scan 10-40 minutes — intro monologue usually ends before 10 min, callers appear after
|
||||
scan_start = min(600.0, duration * 0.15) # ~10 min in or 15%
|
||||
scan_end = min(duration, 2400.0) # up to 40 min
|
||||
|
||||
window_s = 10.0
|
||||
hop_s = 30.0 # coarse pass — one window per 30s for speed
|
||||
|
||||
scores = []
|
||||
for start in np.arange(scan_start, scan_end - window_s, hop_s):
|
||||
end = start + window_s
|
||||
try:
|
||||
emb = profiler.extract_embedding(ep, start, end)
|
||||
best_score = 0.0
|
||||
best_name = ""
|
||||
for name, profile in profiler.profiles.items():
|
||||
s = profile.similarity(emb)
|
||||
if s > best_score:
|
||||
best_score = s
|
||||
best_name = name
|
||||
|
||||
label = f"HOST ({best_name})" if best_score >= 0.85 else (
|
||||
f"CALLER (below 0.85)" if best_score >= 0.70 else "UNKNOWN"
|
||||
)
|
||||
console.print(f" [{start:6.0f}s-{end:.0f}s] {best_score:.4f} {label}")
|
||||
scores.append(best_score)
|
||||
except Exception as e:
|
||||
console.print(f" [{start:6.0f}s] ERROR: {e}")
|
||||
|
||||
if scores:
|
||||
console.print(f"\nScore distribution over first 20 min:")
|
||||
console.print(f" min={min(scores):.4f} max={max(scores):.4f} mean={np.mean(scores):.4f} median={np.median(scores):.4f}")
|
||||
buckets = [0.0, 0.6, 0.7, 0.75, 0.80, 0.85, 0.90, 0.95, 1.01]
|
||||
for lo, hi in zip(buckets, buckets[1:]):
|
||||
count = sum(1 for s in scores if lo <= s < hi)
|
||||
bar = "#" * count
|
||||
console.print(f" [{lo:.2f}-{hi:.2f}): {count:3d} {bar}")
|
||||
@@ -1,581 +0,0 @@
|
||||
"""
|
||||
Classify each row in qa_pairs for usefulness to listeners.
|
||||
|
||||
Walks the qa_pairs table and uses Ollama (qwen3:14b) to score each Q/A pair on:
|
||||
- usefulness_score : 1..5 (5 = solid tech help, 1 = pure banter)
|
||||
- topic_class : computer-help | banter | promo | off-topic | unclear
|
||||
- is_banter : 0 or 1
|
||||
|
||||
Idempotent by default: only processes rows where usefulness_score IS NULL.
|
||||
Pass --rebuild to re-classify every row, or --limit N to sample N rows.
|
||||
|
||||
Usage:
|
||||
py classify_qa_quality.py [--db PATH] [--limit N] [--rebuild] [--smoke]
|
||||
[--ollama URL] [--model NAME] [--batch N]
|
||||
|
||||
Environment:
|
||||
No env vars required. Ollama is auto-discovered from localhost or the
|
||||
GURU-BEAST-ROG Tailscale address.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
MofNCompleteColumn,
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TaskProgressColumn,
|
||||
TextColumn,
|
||||
TimeElapsedColumn,
|
||||
TimeRemainingColumn,
|
||||
)
|
||||
from rich.table import Table
|
||||
|
||||
BASE = Path(__file__).parent
|
||||
DEFAULT_DB = BASE / "archive-data" / "archive.db"
|
||||
DEFAULT_MODEL = "qwen3:14b"
|
||||
DEFAULT_BATCH_SIZE = 25
|
||||
DEFAULT_SMOKE_LIMIT = 10
|
||||
OLLAMA_HOSTS = ("http://localhost:11434", "http://100.92.127.64:11434")
|
||||
HTTP_TIMEOUT_SECONDS = 120 # qwen3:14b can take a while on cold load
|
||||
PING_TIMEOUT_SECONDS = 2
|
||||
|
||||
VALID_TOPIC_CLASSES = {
|
||||
"computer-help",
|
||||
"banter",
|
||||
"promo",
|
||||
"off-topic",
|
||||
"unclear",
|
||||
}
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You are a classifier for a tech radio show's Q&A archive. "
|
||||
"The host (Mike Swanson) takes calls about computer problems. "
|
||||
"Sometimes co-hosts or producers ask questions on-air too. "
|
||||
"Classify each Q/A pair on whether the answer is useful to listeners "
|
||||
"seeking computer help. Return strict JSON, no prose."
|
||||
)
|
||||
|
||||
USER_TEMPLATE = """Question: {question}
|
||||
Answer: {answer}
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"usefulness_score": <1-5>,
|
||||
"topic_class": <"computer-help" | "banter" | "promo" | "off-topic" | "unclear">,
|
||||
"is_banter": <true|false>,
|
||||
"reason": "<one short sentence>"
|
||||
}}
|
||||
|
||||
Scoring guide:
|
||||
5 = clear computer/tech question with substantive technical answer (Windows, Mac, hardware, software, network, security, etc.)
|
||||
4 = useful tech topic but lighter answer or partially off-topic
|
||||
3 = mixed / borderline / general advice
|
||||
2 = mostly banter, joke, or aside; minimal useful content
|
||||
1 = pure banter, social, ad-read, or unrelated chat
|
||||
|
||||
is_banter is true when the exchange is conversational filler, jokes, or social interaction without listener-actionable content - even if asked by a "caller". A producer asking a real tech question is NOT banter.
|
||||
"""
|
||||
|
||||
# Truncate Q/A text we send to the model. The classifier cares about the
|
||||
# nature of the exchange, not exhaustive content - so capping at ~1.2 KB Q +
|
||||
# ~2 KB A keeps prompts small and fast without losing classification signal.
|
||||
MAX_QUESTION_CHARS = 1200
|
||||
MAX_ANSWER_CHARS = 2000
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ollama discovery + chat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def find_ollama() -> str:
|
||||
"""Return the first reachable Ollama base URL.
|
||||
|
||||
Mirrors the project convention: try localhost, then the Tailscale address
|
||||
of the workstation that's expected to run the model. Raises if neither
|
||||
answers within PING_TIMEOUT_SECONDS.
|
||||
"""
|
||||
for url in OLLAMA_HOSTS:
|
||||
try:
|
||||
with urllib.request.urlopen(url + "/api/tags", timeout=PING_TIMEOUT_SECONDS):
|
||||
return url
|
||||
except Exception:
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"No Ollama instance reachable. Tried: " + ", ".join(OLLAMA_HOSTS)
|
||||
)
|
||||
|
||||
|
||||
def ollama_chat(base_url: str, model: str, messages: list[dict]) -> str:
|
||||
"""POST to /api/chat and return the assistant message content.
|
||||
|
||||
Uses temperature=0 + format='json' so the model is biased toward returning
|
||||
valid JSON. format='json' is an Ollama-native flag that constrains output
|
||||
to the JSON grammar.
|
||||
"""
|
||||
body = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": 0},
|
||||
}
|
||||
).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
base_url + "/api/chat",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SECONDS) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
return payload["message"]["content"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _extract_json_object(raw: str) -> dict:
|
||||
"""Locate the first '{...}' span in the response and json.loads it.
|
||||
|
||||
qwen3 with format='json' usually returns clean JSON, but tolerate stray
|
||||
whitespace, code fences, or thinking tags by clipping to the outermost
|
||||
braces before parsing.
|
||||
"""
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
raise ValueError("no JSON object found in model response")
|
||||
return json.loads(raw[start : end + 1])
|
||||
|
||||
|
||||
def _normalize(parsed: dict) -> dict:
|
||||
"""Validate + coerce a parsed response into the canonical shape.
|
||||
|
||||
Returns a dict with keys: usefulness_score (int 1..5), topic_class (str
|
||||
from VALID_TOPIC_CLASSES), is_banter (0|1), reason (str).
|
||||
Raises ValueError for any out-of-range or missing field.
|
||||
"""
|
||||
score = parsed.get("usefulness_score")
|
||||
if isinstance(score, str):
|
||||
score = score.strip()
|
||||
if score.isdigit():
|
||||
score = int(score)
|
||||
if not isinstance(score, int) or not (1 <= score <= 5):
|
||||
raise ValueError(f"usefulness_score out of range: {parsed.get('usefulness_score')!r}")
|
||||
|
||||
topic_class = parsed.get("topic_class")
|
||||
if not isinstance(topic_class, str):
|
||||
raise ValueError(f"topic_class not a string: {topic_class!r}")
|
||||
topic_class = topic_class.strip().lower()
|
||||
if topic_class not in VALID_TOPIC_CLASSES:
|
||||
raise ValueError(f"topic_class not recognized: {topic_class!r}")
|
||||
|
||||
is_banter_raw = parsed.get("is_banter")
|
||||
if isinstance(is_banter_raw, bool):
|
||||
is_banter = 1 if is_banter_raw else 0
|
||||
elif isinstance(is_banter_raw, (int, float)):
|
||||
is_banter = 1 if int(is_banter_raw) else 0
|
||||
elif isinstance(is_banter_raw, str):
|
||||
v = is_banter_raw.strip().lower()
|
||||
if v in ("true", "yes", "1"):
|
||||
is_banter = 1
|
||||
elif v in ("false", "no", "0"):
|
||||
is_banter = 0
|
||||
else:
|
||||
raise ValueError(f"is_banter not boolean-coercible: {is_banter_raw!r}")
|
||||
else:
|
||||
raise ValueError(f"is_banter missing or unrecognized: {is_banter_raw!r}")
|
||||
|
||||
reason = parsed.get("reason") or ""
|
||||
if not isinstance(reason, str):
|
||||
reason = str(reason)
|
||||
return {
|
||||
"usefulness_score": score,
|
||||
"topic_class": topic_class,
|
||||
"is_banter": is_banter,
|
||||
"reason": reason.strip(),
|
||||
}
|
||||
|
||||
|
||||
def classify_one(
|
||||
base_url: str,
|
||||
model: str,
|
||||
question: str,
|
||||
answer: str,
|
||||
) -> dict | None:
|
||||
"""Classify a single Q/A pair. Returns normalized dict or None on failure.
|
||||
|
||||
Retries once on JSON-parse failure with a corrective follow-up message.
|
||||
Network/HTTP errors propagate to the caller (treated as transient).
|
||||
"""
|
||||
q = (question or "")[:MAX_QUESTION_CHARS]
|
||||
a = (answer or "")[:MAX_ANSWER_CHARS]
|
||||
user_msg = USER_TEMPLATE.format(question=q, answer=a)
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_msg},
|
||||
]
|
||||
raw = ollama_chat(base_url, model, messages)
|
||||
try:
|
||||
return _normalize(_extract_json_object(raw))
|
||||
except (ValueError, json.JSONDecodeError) as first_err:
|
||||
# One retry: tell the model its previous reply was invalid and ask
|
||||
# for a clean JSON object.
|
||||
messages.append({"role": "assistant", "content": raw})
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Your last reply wasn't valid JSON in the required shape. "
|
||||
"Return ONLY a JSON object with keys: usefulness_score (1-5 integer), "
|
||||
'topic_class (one of "computer-help","banter","promo","off-topic","unclear"), '
|
||||
"is_banter (true/false), reason (string)."
|
||||
),
|
||||
}
|
||||
)
|
||||
try:
|
||||
raw2 = ollama_chat(base_url, model, messages)
|
||||
return _normalize(_extract_json_object(raw2))
|
||||
except (ValueError, json.JSONDecodeError) as second_err:
|
||||
console.print(
|
||||
f"[yellow][WARNING][/yellow] JSON parse failed twice "
|
||||
f"(first: {first_err}; second: {second_err}); skipping row"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_rows(
|
||||
conn: sqlite3.Connection,
|
||||
rebuild: bool,
|
||||
limit: int | None,
|
||||
) -> list[sqlite3.Row]:
|
||||
"""Pull rows that need classification, in deterministic id order."""
|
||||
sql = (
|
||||
"SELECT id, question_text, answer_text, caller_name "
|
||||
"FROM qa_pairs"
|
||||
)
|
||||
if not rebuild:
|
||||
sql += " WHERE usefulness_score IS NULL"
|
||||
sql += " ORDER BY id"
|
||||
if limit is not None and limit > 0:
|
||||
sql += f" LIMIT {int(limit)}"
|
||||
return conn.execute(sql).fetchall()
|
||||
|
||||
|
||||
def commit_batch(conn: sqlite3.Connection, updates: list[tuple]) -> None:
|
||||
"""Flush a batch of (score, topic_class, is_banter, id) tuples."""
|
||||
if not updates:
|
||||
return
|
||||
with conn:
|
||||
conn.executemany(
|
||||
"UPDATE qa_pairs "
|
||||
"SET usefulness_score = ?, topic_class = ?, is_banter = ? "
|
||||
"WHERE id = ?",
|
||||
updates,
|
||||
)
|
||||
|
||||
|
||||
def summarize(conn: sqlite3.Connection) -> None:
|
||||
"""Print final distribution tables to stdout."""
|
||||
total = conn.execute("SELECT COUNT(*) FROM qa_pairs").fetchone()[0]
|
||||
scored = conn.execute(
|
||||
"SELECT COUNT(*) FROM qa_pairs WHERE usefulness_score IS NOT NULL"
|
||||
).fetchone()[0]
|
||||
console.print()
|
||||
console.print(
|
||||
f"[bold]Coverage:[/bold] {scored} / {total} rows scored "
|
||||
f"({100 * scored / total:.1f}%)"
|
||||
)
|
||||
|
||||
by_class = conn.execute(
|
||||
"SELECT topic_class, COUNT(*) AS n FROM qa_pairs "
|
||||
"WHERE topic_class IS NOT NULL GROUP BY topic_class ORDER BY n DESC"
|
||||
).fetchall()
|
||||
if by_class:
|
||||
t = Table(title="Distribution by topic_class", show_lines=False)
|
||||
t.add_column("topic_class")
|
||||
t.add_column("count", justify="right")
|
||||
t.add_column("share", justify="right")
|
||||
for row in by_class:
|
||||
share = 100 * row[1] / scored if scored else 0
|
||||
t.add_row(row[0], str(row[1]), f"{share:.1f}%")
|
||||
console.print(t)
|
||||
|
||||
by_score = conn.execute(
|
||||
"SELECT usefulness_score, COUNT(*) AS n FROM qa_pairs "
|
||||
"WHERE usefulness_score IS NOT NULL GROUP BY usefulness_score "
|
||||
"ORDER BY usefulness_score DESC"
|
||||
).fetchall()
|
||||
if by_score:
|
||||
t = Table(title="Distribution by usefulness_score", show_lines=False)
|
||||
t.add_column("score", justify="right")
|
||||
t.add_column("count", justify="right")
|
||||
t.add_column("share", justify="right")
|
||||
for row in by_score:
|
||||
share = 100 * row[1] / scored if scored else 0
|
||||
t.add_row(str(row[0]), str(row[1]), f"{share:.1f}%")
|
||||
console.print(t)
|
||||
|
||||
banter_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM qa_pairs WHERE is_banter = 1"
|
||||
).fetchone()[0]
|
||||
console.print(
|
||||
f"[bold]Flagged as banter:[/bold] {banter_count} "
|
||||
f"({100 * banter_count / scored:.1f}% of scored)"
|
||||
if scored
|
||||
else "[bold]Flagged as banter:[/bold] 0"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Modes: smoke, full
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_smoke(
|
||||
conn: sqlite3.Connection,
|
||||
base_url: str,
|
||||
model: str,
|
||||
sample_size: int,
|
||||
) -> None:
|
||||
"""Print classifications for the first N rows. No DB writes."""
|
||||
rows = conn.execute(
|
||||
"SELECT id, question_text, answer_text, caller_name "
|
||||
"FROM qa_pairs ORDER BY id LIMIT ?",
|
||||
(sample_size,),
|
||||
).fetchall()
|
||||
console.print(
|
||||
f"[bold]Smoke test:[/bold] classifying first {len(rows)} rows "
|
||||
f"(no DB writes)\n"
|
||||
)
|
||||
for row in rows:
|
||||
qid, q_text, a_text, caller = row
|
||||
console.rule(f"[bold]Row {qid}[/bold] caller={caller or '-'}")
|
||||
q_excerpt = (q_text or "").strip().replace("\n", " ")[:240]
|
||||
a_excerpt = (a_text or "").strip().replace("\n", " ")[:240]
|
||||
console.print(f"[cyan]Q:[/cyan] {q_excerpt}")
|
||||
console.print(f"[cyan]A:[/cyan] {a_excerpt}")
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
result = classify_one(base_url, model, q_text or "", a_text or "")
|
||||
dt = time.monotonic() - t0
|
||||
except Exception as e:
|
||||
console.print(f"[red][ERROR][/red] {e}")
|
||||
continue
|
||||
if result is None:
|
||||
console.print("[yellow][WARNING][/yellow] classifier returned no result")
|
||||
continue
|
||||
console.print(
|
||||
f"[green]score={result['usefulness_score']}[/green] "
|
||||
f"class=[magenta]{result['topic_class']}[/magenta] "
|
||||
f"is_banter={result['is_banter']} "
|
||||
f"({dt:.1f}s)"
|
||||
)
|
||||
console.print(f" reason: {result['reason']}")
|
||||
|
||||
|
||||
def run_full(
|
||||
conn: sqlite3.Connection,
|
||||
base_url: str,
|
||||
model: str,
|
||||
rebuild: bool,
|
||||
limit: int | None,
|
||||
batch_size: int,
|
||||
) -> tuple[int, int, int]:
|
||||
"""Walk all eligible rows, classify, persist in batches.
|
||||
|
||||
Returns (n_processed, n_skipped, n_failed).
|
||||
"""
|
||||
rows = fetch_rows(conn, rebuild=rebuild, limit=limit)
|
||||
if not rows:
|
||||
console.print("[green][OK][/green] no rows to process - everything is already scored")
|
||||
return (0, 0, 0)
|
||||
|
||||
console.print(
|
||||
f"[bold]Classifying {len(rows)} rows[/bold] via {model} at {base_url} "
|
||||
f"(batch={batch_size}, rebuild={rebuild})"
|
||||
)
|
||||
|
||||
pending: list[tuple] = []
|
||||
n_processed = 0
|
||||
n_failed = 0
|
||||
|
||||
progress = Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[bold blue]classify[/bold blue]"),
|
||||
BarColumn(bar_width=40),
|
||||
MofNCompleteColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeElapsedColumn(),
|
||||
TimeRemainingColumn(),
|
||||
TextColumn("{task.fields[stat]}"),
|
||||
console=console,
|
||||
)
|
||||
with progress:
|
||||
task = progress.add_task("rows", total=len(rows), stat="")
|
||||
for row in rows:
|
||||
qid = row["id"]
|
||||
try:
|
||||
result = classify_one(
|
||||
base_url,
|
||||
model,
|
||||
row["question_text"] or "",
|
||||
row["answer_text"] or "",
|
||||
)
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as e:
|
||||
console.print(f"[red][ERROR][/red] row {qid}: network failure: {e}")
|
||||
result = None
|
||||
except Exception as e:
|
||||
console.print(
|
||||
f"[red][ERROR][/red] row {qid}: unexpected {type(e).__name__}: {e}"
|
||||
)
|
||||
result = None
|
||||
|
||||
if result is None:
|
||||
n_failed += 1
|
||||
else:
|
||||
pending.append(
|
||||
(
|
||||
result["usefulness_score"],
|
||||
result["topic_class"],
|
||||
result["is_banter"],
|
||||
qid,
|
||||
)
|
||||
)
|
||||
n_processed += 1
|
||||
|
||||
if len(pending) >= batch_size:
|
||||
commit_batch(conn, pending)
|
||||
pending.clear()
|
||||
|
||||
progress.update(
|
||||
task,
|
||||
advance=1,
|
||||
stat=f"ok={n_processed} fail={n_failed}",
|
||||
)
|
||||
|
||||
# Flush remainder.
|
||||
commit_batch(conn, pending)
|
||||
pending.clear()
|
||||
|
||||
return (n_processed, 0, n_failed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--db", default=str(DEFAULT_DB), help="Path to archive.db")
|
||||
ap.add_argument(
|
||||
"--model",
|
||||
default=DEFAULT_MODEL,
|
||||
help=f"Ollama model name (default: {DEFAULT_MODEL})",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--ollama",
|
||||
default=None,
|
||||
help="Ollama base URL (default: auto-discover localhost / Tailscale)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Process at most N rows (sampling mode)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--rebuild",
|
||||
action="store_true",
|
||||
help="Re-classify rows that already have a usefulness_score",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--smoke",
|
||||
action="store_true",
|
||||
help=f"Print {DEFAULT_SMOKE_LIMIT}-row sample without writing to DB",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--batch",
|
||||
type=int,
|
||||
default=DEFAULT_BATCH_SIZE,
|
||||
help=f"Commit every N rows (default {DEFAULT_BATCH_SIZE})",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
console.print(f"[red][ERROR][/red] DB not found: {db_path}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
base_url = args.ollama or find_ollama()
|
||||
except RuntimeError as e:
|
||||
console.print(f"[red][ERROR][/red] {e}")
|
||||
return 1
|
||||
console.print(f"[INFO] Ollama: {base_url} model: {args.model}")
|
||||
|
||||
# For smoke mode we open RO; full mode needs RW.
|
||||
if args.smoke:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
run_smoke(conn, base_url, args.model, DEFAULT_SMOKE_LIMIT)
|
||||
finally:
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
# Confirm migration has run before we try to write.
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(qa_pairs)").fetchall()}
|
||||
missing = {"usefulness_score", "topic_class", "is_banter"} - cols
|
||||
if missing:
|
||||
console.print(
|
||||
f"[red][ERROR][/red] qa_pairs is missing columns: {sorted(missing)}. "
|
||||
"Run import_to_sqlite.py first (it auto-migrates)."
|
||||
)
|
||||
conn.close()
|
||||
return 1
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
n_ok, _n_skip, n_fail = run_full(
|
||||
conn,
|
||||
base_url,
|
||||
args.model,
|
||||
rebuild=args.rebuild,
|
||||
limit=args.limit,
|
||||
batch_size=args.batch,
|
||||
)
|
||||
finally:
|
||||
elapsed = time.monotonic() - t0
|
||||
summarize(conn)
|
||||
conn.close()
|
||||
|
||||
console.print(
|
||||
f"\n[bold]Done in {elapsed:.1f}s[/bold] "
|
||||
f"processed={n_ok} failed={n_fail}"
|
||||
)
|
||||
if n_fail > 0:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,57 +0,0 @@
|
||||
show:
|
||||
name: "The Computer Guru Show"
|
||||
host: "Mike Swanson"
|
||||
typical_duration_minutes: 120
|
||||
segment_count: 6
|
||||
has_commercials: true
|
||||
|
||||
audio:
|
||||
whisper_model: "large-v3"
|
||||
whisper_language: "en"
|
||||
output_format: "mp3"
|
||||
output_bitrate: "192k"
|
||||
normalize: true
|
||||
crossfade_ms: 500
|
||||
|
||||
segment_detection:
|
||||
fingerprint_db: "element-library/fingerprints.db"
|
||||
fingerprint_match_threshold: 0.85
|
||||
|
||||
discover_unknown_elements: true
|
||||
min_element_duration_s: 1.0
|
||||
max_element_duration_s: 30.0
|
||||
cluster_similarity_threshold: 0.90
|
||||
min_cluster_occurrences: 3
|
||||
|
||||
min_break_duration_s: 30
|
||||
max_break_duration_s: 300
|
||||
silence_threshold_db: -40
|
||||
confidence_threshold: 0.70
|
||||
|
||||
weights:
|
||||
fingerprint_match: 0.30
|
||||
speaker_identity: 0.25
|
||||
audio_characteristics: 0.20
|
||||
break_pattern: 0.15
|
||||
structural_heuristic: 0.10
|
||||
|
||||
diarization:
|
||||
min_speakers: 1
|
||||
max_speakers: 6
|
||||
voice_profiles_dir: "voice-profiles/"
|
||||
host_match_threshold: 0.83
|
||||
|
||||
llm:
|
||||
model: "qwen3:14b"
|
||||
ollama_host: "http://localhost:11434"
|
||||
|
||||
paths:
|
||||
episodes_dir: "episodes/"
|
||||
voice_profiles: "voice-profiles/"
|
||||
element_library: "element-library/"
|
||||
output_dir: "processed/"
|
||||
|
||||
archive:
|
||||
server: "172.16.3.10"
|
||||
path: "/home/gurushow/public_html/archive/"
|
||||
elements_path: "/home/gurushow/public_html/archive/Radio/Elements/"
|
||||
@@ -1,161 +0,0 @@
|
||||
"""
|
||||
Re-diarize the two 2018 episodes that had stale diarization, then
|
||||
patch them into the existing archive DB. Also times the run to
|
||||
validate the audio-preload optimization.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
|
||||
from pathlib import Path
|
||||
from src.gpu import ensure_cuda_libs
|
||||
ensure_cuda_libs()
|
||||
|
||||
from src.config import load_config
|
||||
from src.diarizer import diarize, VoiceProfileStore
|
||||
from src.indexer import ArchiveIndex
|
||||
from src.qa_extractor import load_diarized_transcript, extract_qa_pairs, tag_qa_pairs_with_ollama
|
||||
from rich.console import Console
|
||||
import re, json
|
||||
|
||||
console = Console()
|
||||
|
||||
BASE = Path(__file__).parent
|
||||
EPISODES_DIR = BASE / "training-data" / "episodes"
|
||||
TRANSCRIPTS_DIR = BASE / "training-data" / "transcripts"
|
||||
DB_PATH = BASE / "archive" / "archive.db"
|
||||
|
||||
config = load_config()
|
||||
voice_profiles = VoiceProfileStore(
|
||||
config.resolve_path(config.diarization.voice_profiles_dir)
|
||||
)
|
||||
|
||||
targets = ["2018-s10e17", "2018-s10e21"]
|
||||
episodes = [EPISODES_DIR / f"{stem}.mp3" for stem in targets]
|
||||
|
||||
console.print("[bold]Re-diarizing 2018 episodes (optimized audio preload)[/bold]\n")
|
||||
|
||||
total_audio_s = 0
|
||||
total_wall_s = 0
|
||||
|
||||
for ep_path in episodes:
|
||||
if not ep_path.exists():
|
||||
console.print(f"[red]Missing: {ep_path.name}[/red]")
|
||||
continue
|
||||
|
||||
stem = ep_path.stem
|
||||
transcript_dir = TRANSCRIPTS_DIR / stem
|
||||
|
||||
t0 = time.monotonic()
|
||||
result = diarize(ep_path, voice_profiles=voice_profiles,
|
||||
host_match_threshold=0.85)
|
||||
wall = time.monotonic() - t0
|
||||
|
||||
result.save(transcript_dir)
|
||||
|
||||
audio_dur = result.turns[-1].end if result.turns else 0
|
||||
rtf = audio_dur / wall if wall > 0 else 0
|
||||
total_audio_s += audio_dur
|
||||
total_wall_s += wall
|
||||
|
||||
speakers = result.speakers_ranked()
|
||||
console.print(
|
||||
f" {stem}: {len(result.turns)} turns | "
|
||||
+ ", ".join(f"{s} ({t:.0f}s)" for s, t in speakers[:3])
|
||||
+ f" [{wall:.1f}s wall / {rtf:.1f}x realtime]"
|
||||
)
|
||||
|
||||
if total_wall_s > 0:
|
||||
console.print(
|
||||
f"\n[bold]Speed:[/bold] {total_audio_s:.0f}s audio in {total_wall_s:.1f}s "
|
||||
f"= {total_audio_s/total_wall_s:.1f}x realtime"
|
||||
)
|
||||
|
||||
# Patch just these two episodes into the existing DB
|
||||
console.print("\n[bold]Patching DB...[/bold]")
|
||||
|
||||
def episode_id(stem):
|
||||
return re.sub(r"-hr\d$", "", stem, flags=re.IGNORECASE)
|
||||
|
||||
with ArchiveIndex(DB_PATH) as idx:
|
||||
for ep_path in episodes:
|
||||
if not ep_path.exists():
|
||||
continue
|
||||
stem = ep_path.stem
|
||||
transcript_dir = TRANSCRIPTS_DIR / stem
|
||||
transcript_path = transcript_dir / "transcript.json"
|
||||
diarization_path = transcript_dir / "diarization.json"
|
||||
|
||||
if not transcript_path.exists():
|
||||
console.print(f"[yellow]No transcript: {stem}[/yellow]")
|
||||
continue
|
||||
|
||||
ep_id = episode_id(stem)
|
||||
|
||||
with open(transcript_path) as f:
|
||||
td = json.load(f)
|
||||
duration = td.get("duration")
|
||||
|
||||
date_m = re.search(r"(\d{4}-\d{2}-\d{2})", stem)
|
||||
date = date_m.group(1) if date_m else None
|
||||
|
||||
segments = load_diarized_transcript(transcript_path, diarization_path)
|
||||
|
||||
# Remove old rows then re-add. FTS5 content tables are rebuilt at the end.
|
||||
idx._conn.execute("DELETE FROM segments WHERE episode_id = ?", (ep_id,))
|
||||
idx._conn.execute("DELETE FROM qa_pairs WHERE episode_id = ?", (ep_id,))
|
||||
idx._conn.commit()
|
||||
|
||||
idx.add_episode(ep_id, ep_path, date=date, duration=duration)
|
||||
# Bypass add_segments guard (it skips if rows already exist)
|
||||
idx._conn.executemany(
|
||||
"INSERT INTO segments (episode_id, seg_index, start, end, speaker, text) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
(ep_id, i, s["start"], s["end"], s.get("speaker", "UNKNOWN"), s["text"])
|
||||
for i, s in enumerate(segments)
|
||||
]
|
||||
)
|
||||
idx._conn.commit()
|
||||
|
||||
host_segs = sum(1 for s in segments if s["speaker"] == "HOST")
|
||||
other_segs = len(segments) - host_segs
|
||||
console.print(f" {ep_id}: {len(segments)} segs (HOST={host_segs}, other={other_segs})")
|
||||
|
||||
pairs = extract_qa_pairs(segments)
|
||||
console.print(f" {len(pairs)} Q&A pairs", end="")
|
||||
|
||||
if pairs:
|
||||
console.print(f" — tagging with Ollama...", end="")
|
||||
pairs = tag_qa_pairs_with_ollama(
|
||||
pairs, ollama_host=config.llm.ollama_host, model=config.llm.model
|
||||
)
|
||||
|
||||
for pair in pairs:
|
||||
idx.add_qa_pair(
|
||||
ep_id,
|
||||
pair.question_start, pair.question_end,
|
||||
pair.answer_start, pair.answer_end,
|
||||
pair.question_text, pair.answer_text,
|
||||
topic=pair.topic, tags=pair.topic_tags,
|
||||
)
|
||||
console.print()
|
||||
|
||||
# Rebuild FTS indexes — required after manual DELETE/re-INSERT on content tables
|
||||
idx._conn.execute("INSERT INTO segments_fts(segments_fts) VALUES('rebuild')")
|
||||
idx._conn.execute("INSERT INTO qa_fts(qa_fts) VALUES('rebuild')")
|
||||
idx._conn.commit()
|
||||
|
||||
stats = idx.stats()
|
||||
|
||||
console.print(f"\n[bold green]Done.[/bold green] DB now: "
|
||||
f"{stats['episodes']} episodes | "
|
||||
f"{stats['segments']} segments | "
|
||||
f"{stats['qa_pairs']} Q&A pairs")
|
||||
@@ -1,148 +0,0 @@
|
||||
"""
|
||||
Diarize all training episodes, saving diarization.json next to each transcript.
|
||||
Then rebuild the archive DB with proper HOST/CALLER labels.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Force UTF-8 output on Windows so Rich's Braille spinner characters don't crash
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
# Prevent transformers from checking HuggingFace for model updates on every
|
||||
# from_pretrained() call — models are already cached locally.
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure CUDA libs before any torch imports
|
||||
from src.gpu import ensure_cuda_libs
|
||||
ensure_cuda_libs()
|
||||
|
||||
from src.config import load_config
|
||||
from src.diarizer import diarize, VoiceProfileStore
|
||||
from src.indexer import ArchiveIndex
|
||||
from src.qa_extractor import load_diarized_transcript, extract_qa_pairs, tag_qa_pairs_with_ollama
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
BASE = Path(__file__).parent
|
||||
EPISODES_DIR = BASE / "training-data" / "episodes"
|
||||
TRANSCRIPTS_DIR = BASE / "training-data" / "transcripts"
|
||||
DB_PATH = BASE / "archive" / "archive.db"
|
||||
|
||||
config = load_config()
|
||||
|
||||
# Load voice profiles
|
||||
voice_profiles = VoiceProfileStore(
|
||||
config.resolve_path(config.diarization.voice_profiles_dir)
|
||||
)
|
||||
|
||||
episodes = sorted(EPISODES_DIR.glob("*.mp3"))
|
||||
console.print(f"[bold]Diarizing {len(episodes)} training episodes[/bold]")
|
||||
|
||||
# ── Step 1: Diarize ───────────────────────────────────────────────────────────
|
||||
for i, ep_path in enumerate(episodes, 1):
|
||||
stem = ep_path.stem
|
||||
transcript_dir = TRANSCRIPTS_DIR / stem
|
||||
if not transcript_dir.exists():
|
||||
console.print(f"[{i}/{len(episodes)}] [yellow]No transcript dir: {stem} — skipping[/yellow]")
|
||||
continue
|
||||
|
||||
diarization_out = transcript_dir / "diarization.json"
|
||||
if diarization_out.exists():
|
||||
console.print(f"[{i}/{len(episodes)}] [dim]Already diarized: {stem}[/dim]")
|
||||
continue
|
||||
|
||||
console.print(f"\n[{i}/{len(episodes)}] Diarizing: {stem}")
|
||||
try:
|
||||
result = diarize(ep_path, voice_profiles=voice_profiles,
|
||||
min_speakers=config.diarization.min_speakers,
|
||||
max_speakers=config.diarization.max_speakers,
|
||||
host_match_threshold=0.85)
|
||||
result.save(transcript_dir)
|
||||
speakers = result.speakers_ranked()
|
||||
console.print(f" Done — {len(result.turns)} turns | top speakers: "
|
||||
+ ", ".join(f"{s} ({t:.0f}s)" for s, t in speakers[:3]))
|
||||
except Exception as e:
|
||||
console.print(f" [red]FAILED: {e}[/red]")
|
||||
import traceback; traceback.print_exc()
|
||||
|
||||
# ── Step 2: Rebuild DB ────────────────────────────────────────────────────────
|
||||
console.print("\n[bold]Rebuilding archive DB with diarization...[/bold]")
|
||||
|
||||
if DB_PATH.exists():
|
||||
DB_PATH.unlink()
|
||||
console.print("[dim]Cleared existing DB[/dim]")
|
||||
|
||||
import re, json
|
||||
|
||||
def episode_id(stem):
|
||||
return re.sub(r"-hr\d$", "", stem, flags=re.IGNORECASE)
|
||||
|
||||
with ArchiveIndex(DB_PATH) as idx:
|
||||
for ep_path in episodes:
|
||||
stem = ep_path.stem
|
||||
transcript_dir = TRANSCRIPTS_DIR / stem
|
||||
transcript_path = transcript_dir / "transcript.json"
|
||||
diarization_path = transcript_dir / "diarization.json"
|
||||
|
||||
if not transcript_path.exists():
|
||||
console.print(f"[yellow]No transcript: {stem} — skipping[/yellow]")
|
||||
continue
|
||||
|
||||
ep_id = episode_id(stem)
|
||||
date_m = re.search(r"(\d{4}-\d{2}-\d{2})", stem)
|
||||
date = date_m.group(1) if date_m else None
|
||||
|
||||
with open(transcript_path) as f:
|
||||
td = json.load(f)
|
||||
duration = td.get("duration")
|
||||
|
||||
segments = load_diarized_transcript(
|
||||
transcript_path,
|
||||
diarization_path if diarization_path.exists() else None
|
||||
)
|
||||
|
||||
idx.add_episode(ep_id, ep_path, date=date, duration=duration)
|
||||
idx.add_segments(ep_id, segments)
|
||||
|
||||
# Speaker breakdown
|
||||
host_segs = sum(1 for s in segments if s["speaker"] == "HOST")
|
||||
caller_segs = sum(1 for s in segments if s["speaker"] in ("CALLER", "UNKNOWN"))
|
||||
console.print(f" {ep_id}: {len(segments)} segs "
|
||||
f"(HOST={host_segs}, other={caller_segs})")
|
||||
|
||||
# Extract Q&A pairs
|
||||
pairs = extract_qa_pairs(segments)
|
||||
console.print(f" {len(pairs)} Q&A pairs", end="")
|
||||
|
||||
if pairs:
|
||||
console.print(f" — tagging with Ollama...", end="")
|
||||
pairs = tag_qa_pairs_with_ollama(
|
||||
pairs, ollama_host=config.llm.ollama_host, model=config.llm.model
|
||||
)
|
||||
|
||||
for pair in pairs:
|
||||
idx.add_qa_pair(
|
||||
ep_id,
|
||||
pair.question_start, pair.question_end,
|
||||
pair.answer_start, pair.answer_end,
|
||||
pair.question_text, pair.answer_text,
|
||||
topic=pair.topic, tags=pair.topic_tags,
|
||||
)
|
||||
|
||||
console.print()
|
||||
|
||||
stats = idx.stats()
|
||||
|
||||
console.print(f"\n[bold green]Done.[/bold green] "
|
||||
f"{stats['episodes']} episodes | "
|
||||
f"{stats['segments']} segments | "
|
||||
f"{stats['qa_pairs']} Q&A pairs")
|
||||
console.print(f"DB: {DB_PATH}")
|
||||
@@ -1,129 +0,0 @@
|
||||
"""
|
||||
Download the full Computer Guru Show archive from IX server (172.16.3.10).
|
||||
|
||||
Mirrors the year-based directory structure as-is to archive-data/episodes/.
|
||||
Resumable: skips files already present with matching size.
|
||||
Requires Tailscale.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import paramiko
|
||||
from pathlib import Path
|
||||
|
||||
password = os.environ.get("IX_PASSWORD")
|
||||
if not password:
|
||||
print("IX_PASSWORD env var not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
LOCAL_ROOT = Path(__file__).parent / "archive-data" / "episodes"
|
||||
LOCAL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
REMOTE_ROOT = "/home/gurushow/public_html/archive"
|
||||
YEARS = ["2010", "2011", "2012", "2014", "2015", "2016", "2017", "2018"]
|
||||
|
||||
def connect():
|
||||
c = paramiko.SSHClient()
|
||||
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
c.connect("172.16.3.10", username="root", password=password,
|
||||
look_for_keys=False, allow_agent=False,
|
||||
timeout=30, banner_timeout=30, auth_timeout=30)
|
||||
transport = c.get_transport()
|
||||
if transport is not None:
|
||||
transport.set_keepalive(30) # send keepalive every 30s
|
||||
s = c.open_sftp()
|
||||
s.get_channel().settimeout(120) # per-operation timeout
|
||||
return c, s
|
||||
|
||||
|
||||
print(f"Connecting to 172.16.3.10...", flush=True)
|
||||
client, sftp = connect()
|
||||
print("Connected.", flush=True)
|
||||
|
||||
|
||||
def list_remote_mp3s(year: str) -> list[str]:
|
||||
cmd = f"find '{REMOTE_ROOT}/{year}' -iname '*.mp3' 2>/dev/null"
|
||||
stdin, stdout, stderr = client.exec_command(cmd)
|
||||
return [line.strip() for line in stdout.read().decode().splitlines() if line.strip()]
|
||||
|
||||
|
||||
total_files = 0
|
||||
total_bytes = 0
|
||||
skipped_files = 0
|
||||
skipped_bytes = 0
|
||||
downloaded_files = 0
|
||||
downloaded_bytes = 0
|
||||
errors = []
|
||||
|
||||
t_start = time.monotonic()
|
||||
|
||||
for year in YEARS:
|
||||
print(f"\n=== {year} ===", flush=True)
|
||||
remote_paths = list_remote_mp3s(year)
|
||||
print(f" {len(remote_paths)} MP3 files found on remote", flush=True)
|
||||
|
||||
for remote in remote_paths:
|
||||
rel = remote[len(REMOTE_ROOT) + 1:]
|
||||
local = LOCAL_ROOT / rel
|
||||
local.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
remote_stat = sftp.stat(remote)
|
||||
remote_size = remote_stat.st_size
|
||||
except Exception as e:
|
||||
errors.append(f"stat {remote}: {e}")
|
||||
continue
|
||||
|
||||
total_files += 1
|
||||
total_bytes += remote_size
|
||||
|
||||
if local.exists() and local.stat().st_size == remote_size:
|
||||
skipped_files += 1
|
||||
skipped_bytes += remote_size
|
||||
continue
|
||||
|
||||
size_mb = remote_size / 1024 / 1024
|
||||
print(f" [{downloaded_files + 1:3d}] {rel} ({size_mb:.1f} MB)...", end="", flush=True)
|
||||
t0 = time.monotonic()
|
||||
|
||||
attempt = 0
|
||||
while True:
|
||||
attempt += 1
|
||||
try:
|
||||
sftp.get(remote, str(local))
|
||||
elapsed = time.monotonic() - t0
|
||||
mbps = size_mb / elapsed if elapsed > 0 else 0
|
||||
print(f" done ({elapsed:.1f}s, {mbps:.1f} MB/s)", flush=True)
|
||||
downloaded_files += 1
|
||||
downloaded_bytes += remote_size
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt >= 3:
|
||||
print(f" FAILED after {attempt} attempts: {e}", flush=True)
|
||||
errors.append(f"get {remote}: {e}")
|
||||
break
|
||||
print(f" retry {attempt} ({e})...", end="", flush=True)
|
||||
# Reconnect on failure
|
||||
try:
|
||||
sftp.close()
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(5)
|
||||
client, sftp = connect()
|
||||
|
||||
elapsed_total = time.monotonic() - t_start
|
||||
print(f"\n=== Summary ===", flush=True)
|
||||
print(f" Total remote files : {total_files}", flush=True)
|
||||
print(f" Total remote bytes : {total_bytes / 1024 / 1024 / 1024:.2f} GB", flush=True)
|
||||
print(f" Already present : {skipped_files} files / {skipped_bytes / 1024 / 1024 / 1024:.2f} GB", flush=True)
|
||||
print(f" Newly downloaded : {downloaded_files} files / {downloaded_bytes / 1024 / 1024 / 1024:.2f} GB", flush=True)
|
||||
print(f" Errors : {len(errors)}", flush=True)
|
||||
print(f" Wall time : {elapsed_total:.1f}s", flush=True)
|
||||
if errors:
|
||||
print(f"\n=== Errors ===", flush=True)
|
||||
for e in errors[:20]:
|
||||
print(f" {e}", flush=True)
|
||||
|
||||
sftp.close()
|
||||
client.close()
|
||||
@@ -1,41 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import paramiko
|
||||
|
||||
password = os.environ.get('IX_PASSWORD')
|
||||
if not password:
|
||||
print('IX_PASSWORD env var not set', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
client.connect('172.16.3.10', username='root', password=password,
|
||||
look_for_keys=False, allow_agent=False, timeout=30)
|
||||
sftp = client.open_sftp()
|
||||
|
||||
os.makedirs('test-data/episodes', exist_ok=True)
|
||||
|
||||
downloads = [
|
||||
('/home/gurushow/public_html/archive/2010/COMPUTER GURU 5-8-10 hour 1.mp3', 'test-data/episodes/2010-05-08-hr1.mp3'),
|
||||
('/home/gurushow/public_html/archive/2011/3-12-11 HR 1.mp3', 'test-data/episodes/2011-03-12-hr1.mp3'),
|
||||
('/home/gurushow/public_html/archive/2012/3 - March/3-10-12HR1.mp3', 'test-data/episodes/2012-03-10-hr1.mp3'),
|
||||
('/home/gurushow/public_html/archive/2012/6 - June/6-9-12-HR1.mp3', 'test-data/episodes/2012-06-09-hr1.mp3'),
|
||||
('/home/gurushow/public_html/archive/2014/06/s6e19.mp3', 'test-data/episodes/2014-s6e19.mp3'),
|
||||
('/home/gurushow/public_html/archive/2015/01/s7e19.mp3', 'test-data/episodes/2015-s7e19.mp3'),
|
||||
('/home/gurushow/public_html/archive/2016/06/s8e43.mp3', 'test-data/episodes/2016-s8e43.mp3'),
|
||||
('/home/gurushow/public_html/archive/2017/04/s9e30.mp3', 'test-data/episodes/2017-s9e30.mp3'),
|
||||
('/home/gurushow/public_html/archive/2018/01/s10e18.mp3', 'test-data/episodes/2018-s10e18.mp3'),
|
||||
]
|
||||
|
||||
for remote, local in downloads:
|
||||
if os.path.exists(local):
|
||||
print(f'[skip] {local} already exists')
|
||||
continue
|
||||
size_mb = sftp.stat(remote).st_size / 1024 / 1024
|
||||
print(f'Downloading {local} ({size_mb:.1f} MB)...', flush=True)
|
||||
sftp.get(remote, local)
|
||||
print(' done', flush=True)
|
||||
|
||||
sftp.close()
|
||||
client.close()
|
||||
print('All downloads complete.')
|
||||
@@ -1,294 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""GPU-monitored batch transcription with diagnostics.
|
||||
|
||||
Monitors GPU health before, during, and after each episode transcription.
|
||||
Logs temperature, power, utilization, and memory to detect what triggers
|
||||
the NVRM rpcSendMessage failure (status 0x00000062).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
import threading
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
LOG_DIR = Path("gpu-debug-logs")
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
LOG_FILE = LOG_DIR / f"gpu_monitor_{datetime.now():%Y%m%d_%H%M%S}.log"
|
||||
|
||||
# Episodes to transcribe (remaining ones)
|
||||
EPISODES = [
|
||||
"training-data/episodes/2011-06-04-hr1.mp3",
|
||||
"training-data/episodes/2011-09-10-hr1.mp3",
|
||||
"training-data/episodes/2014-s6e05.mp3",
|
||||
"training-data/episodes/2015-s7e30.mp3",
|
||||
"training-data/episodes/2016-s8e42.mp3",
|
||||
"training-data/episodes/2017-s9e26.mp3",
|
||||
"training-data/episodes/2018-s10e17.mp3",
|
||||
"training-data/episodes/2018-s10e21.mp3",
|
||||
]
|
||||
|
||||
stop_monitor = threading.Event()
|
||||
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
line = f"[{ts}] {msg}"
|
||||
print(line)
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
def gpu_query() -> dict | None:
|
||||
"""Query GPU stats via nvidia-smi. Returns None if GPU is in error state."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["nvidia-smi",
|
||||
"--query-gpu=temperature.gpu,power.draw,utilization.gpu,utilization.memory,"
|
||||
"memory.used,memory.total,clocks.current.sm,clocks.current.memory,"
|
||||
"pstate,fan.speed",
|
||||
"--format=csv,noheader,nounits"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
parts = [p.strip() for p in result.stdout.strip().split(",")]
|
||||
# Check for ERR! or [N/A] in any field
|
||||
if any("ERR" in p or "[N/A]" in p for p in parts[:4]):
|
||||
return {"error": True, "raw": result.stdout.strip()}
|
||||
return {
|
||||
"temp_c": parts[0],
|
||||
"power_w": parts[1],
|
||||
"gpu_util": parts[2],
|
||||
"mem_util": parts[3],
|
||||
"mem_used_mb": parts[4],
|
||||
"mem_total_mb": parts[5],
|
||||
"sm_clock_mhz": parts[6],
|
||||
"mem_clock_mhz": parts[7],
|
||||
"pstate": parts[8],
|
||||
"fan": parts[9],
|
||||
"error": False,
|
||||
}
|
||||
except (subprocess.TimeoutExpired, Exception) as e:
|
||||
return {"error": True, "raw": str(e)}
|
||||
|
||||
|
||||
def gpu_health_check() -> bool:
|
||||
"""Returns True if GPU is healthy."""
|
||||
stats = gpu_query()
|
||||
if stats is None or stats.get("error"):
|
||||
log(f"GPU ERROR: {stats}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def gpu_status_str(stats: dict) -> str:
|
||||
if stats.get("error"):
|
||||
return f"ERR! raw={stats.get('raw', 'unknown')}"
|
||||
return (f"T={stats['temp_c']}C P={stats['power_w']}W "
|
||||
f"GPU={stats['gpu_util']}% MEM={stats['mem_util']}% "
|
||||
f"VRAM={stats['mem_used_mb']}/{stats['mem_total_mb']}MB "
|
||||
f"SM={stats['sm_clock_mhz']}MHz MEMCLK={stats['mem_clock_mhz']}MHz "
|
||||
f"PState={stats['pstate']} Fan={stats['fan']}")
|
||||
|
||||
|
||||
def monitor_thread(interval: float = 2.0):
|
||||
"""Background thread that logs GPU stats at regular intervals."""
|
||||
while not stop_monitor.is_set():
|
||||
stats = gpu_query()
|
||||
if stats:
|
||||
log(f"MONITOR: {gpu_status_str(stats)}")
|
||||
if stats.get("error"):
|
||||
log("MONITOR: GPU ENTERED ERROR STATE!")
|
||||
# Check dmesg for the smoking gun
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "dmesg", "-T", "--level=err,warn"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
nvrm_lines = [l for l in result.stdout.splitlines()
|
||||
if "NVRM" in l or "nvidia" in l.lower()]
|
||||
for line in nvrm_lines[-5:]:
|
||||
log(f"DMESG: {line}")
|
||||
except Exception:
|
||||
pass
|
||||
stop_monitor.wait(interval)
|
||||
|
||||
|
||||
def check_runtime_d3():
|
||||
"""Check and log Runtime D3 power management status."""
|
||||
try:
|
||||
power_file = Path("/proc/driver/nvidia/gpus/0000:02:00.0/power")
|
||||
if power_file.exists():
|
||||
log(f"GPU Power Management:\n{power_file.read_text()}")
|
||||
|
||||
# Check if dynamic power management is enabled
|
||||
result = subprocess.run(
|
||||
["cat", "/sys/bus/pci/devices/0000:02:00.0/power/runtime_status"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
log(f"PCI runtime_status: {result.stdout.strip()}")
|
||||
|
||||
result = subprocess.run(
|
||||
["cat", "/sys/bus/pci/devices/0000:02:00.0/power/control"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
log(f"PCI power control: {result.stdout.strip()}")
|
||||
|
||||
result = subprocess.run(
|
||||
["cat", "/sys/bus/pci/devices/0000:02:00.0/power/runtime_enabled"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
log(f"PCI runtime_enabled: {result.stdout.strip()}")
|
||||
|
||||
except Exception as e:
|
||||
log(f"Power check error: {e}")
|
||||
|
||||
|
||||
def check_nvidia_persistence():
|
||||
"""Check persistence mode."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["nvidia-smi", "--query-gpu=persistence_mode", "--format=csv,noheader"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
log(f"Persistence mode: {result.stdout.strip()}")
|
||||
except Exception as e:
|
||||
log(f"Persistence check error: {e}")
|
||||
|
||||
|
||||
def transcribe_one(episode_path: str) -> bool:
|
||||
"""Transcribe a single episode with GPU health monitoring. Returns success."""
|
||||
name = Path(episode_path).stem
|
||||
output_dir = f"training-data/transcripts/{name}"
|
||||
|
||||
if Path(output_dir).exists() and (Path(output_dir) / "transcript.json").exists():
|
||||
log(f"SKIP: {name} already transcribed")
|
||||
return True
|
||||
|
||||
# Pre-flight GPU check
|
||||
log(f"PRE-FLIGHT: Checking GPU before {name}")
|
||||
stats = gpu_query()
|
||||
if not stats or stats.get("error"):
|
||||
log(f"PRE-FLIGHT FAIL: GPU already in error state! Stats: {stats}")
|
||||
return False
|
||||
log(f"PRE-FLIGHT: {gpu_status_str(stats)}")
|
||||
|
||||
# Quick CUDA test
|
||||
log("PRE-FLIGHT: Testing CUDA...")
|
||||
try:
|
||||
import torch
|
||||
if not torch.cuda.is_available():
|
||||
log("PRE-FLIGHT FAIL: torch.cuda.is_available() = False")
|
||||
return False
|
||||
# Small allocation test
|
||||
x = torch.randn(100, 100, device="cuda")
|
||||
y = x @ x
|
||||
del x, y
|
||||
torch.cuda.synchronize()
|
||||
torch.cuda.empty_cache()
|
||||
log(f"PRE-FLIGHT: CUDA OK, allocated={torch.cuda.memory_allocated() / 1024**2:.0f}MB")
|
||||
except Exception as e:
|
||||
log(f"PRE-FLIGHT FAIL: CUDA test error: {e}")
|
||||
return False
|
||||
|
||||
# Transcribe
|
||||
log(f"START: {name} ({episode_path})")
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
from src.transcriber import transcribe
|
||||
transcript = transcribe(episode_path)
|
||||
transcript.save(Path(output_dir))
|
||||
elapsed = time.time() - start_time
|
||||
log(f"DONE: {name} in {elapsed:.1f}s ({elapsed/60:.1f}min), "
|
||||
f"{len(transcript.segments)} segments")
|
||||
except Exception as e:
|
||||
elapsed = time.time() - start_time
|
||||
log(f"FAIL: {name} after {elapsed:.1f}s: {type(e).__name__}: {e}")
|
||||
|
||||
# Post-failure GPU check
|
||||
stats = gpu_query()
|
||||
log(f"POST-FAIL: {gpu_status_str(stats) if stats else 'query failed'}")
|
||||
return False
|
||||
|
||||
# Post-transcription GPU check
|
||||
stats = gpu_query()
|
||||
if stats and not stats.get("error"):
|
||||
log(f"POST: {gpu_status_str(stats)}")
|
||||
else:
|
||||
log(f"POST: GPU entered error state after transcription! {stats}")
|
||||
|
||||
# Cool-down: clear CUDA cache, let GPU idle briefly
|
||||
try:
|
||||
import torch
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.synchronize()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log("COOLDOWN: Waiting 10s between episodes...")
|
||||
time.sleep(10)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
log("=" * 60)
|
||||
log("GPU Debug Batch Transcription")
|
||||
log(f"Driver: {subprocess.getoutput('nvidia-smi --query-gpu=driver_version --format=csv,noheader')}")
|
||||
log(f"CUDA version: {subprocess.getoutput('nvidia-smi --query-gpu=cuda_version --format=csv,noheader 2>/dev/null') or 'N/A'}")
|
||||
log("=" * 60)
|
||||
|
||||
# Check power management
|
||||
check_runtime_d3()
|
||||
check_nvidia_persistence()
|
||||
|
||||
# Initial GPU state
|
||||
stats = gpu_query()
|
||||
if not stats or stats.get("error"):
|
||||
log(f"ABORT: GPU already in error state at startup: {stats}")
|
||||
sys.exit(1)
|
||||
log(f"INITIAL: {gpu_status_str(stats)}")
|
||||
|
||||
# Start background monitor (every 5 seconds during transcription)
|
||||
monitor = threading.Thread(target=monitor_thread, args=(5.0,), daemon=True)
|
||||
monitor.start()
|
||||
|
||||
# Filter to only episodes that need transcription
|
||||
remaining = []
|
||||
for ep in EPISODES:
|
||||
name = Path(ep).stem
|
||||
out = Path(f"training-data/transcripts/{name}/transcript.json")
|
||||
if out.exists():
|
||||
log(f"ALREADY DONE: {name}")
|
||||
else:
|
||||
remaining.append(ep)
|
||||
|
||||
log(f"QUEUE: {len(remaining)} episodes to transcribe")
|
||||
|
||||
completed = 0
|
||||
failed = 0
|
||||
for ep in remaining:
|
||||
success = transcribe_one(ep)
|
||||
if success:
|
||||
completed += 1
|
||||
else:
|
||||
failed += 1
|
||||
log(f"STOPPING: GPU failure detected after {completed} episodes, {failed} failed")
|
||||
# Log final state
|
||||
stats = gpu_query()
|
||||
log(f"FINAL: {gpu_status_str(stats) if stats else 'query failed'}")
|
||||
break
|
||||
|
||||
stop_monitor.set()
|
||||
log(f"SUMMARY: {completed} completed, {failed} failed, "
|
||||
f"{len(remaining) - completed - failed} remaining")
|
||||
log(f"Log saved to: {LOG_FILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,370 +0,0 @@
|
||||
"""
|
||||
Import per-episode pipeline outputs (transcript / diarization / intros / qa)
|
||||
into a single SQLite archive.db.
|
||||
|
||||
Idempotent: skips episodes whose transcript.json sha256 matches the recorded
|
||||
hash. Re-run after each batch_process pass to keep the DB current.
|
||||
|
||||
Usage:
|
||||
py import_to_sqlite.py [--db PATH] [--root PATH] [--rebuild] [--vacuum]
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
BASE = Path(__file__).parent
|
||||
DEFAULT_ROOT = BASE / "archive-data" / "transcripts"
|
||||
DEFAULT_DB = BASE / "archive-data" / "archive.db"
|
||||
|
||||
REQUIRED_FILES = ("transcript.json", "diarization.json", "intros.json", "qa.json")
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
rel_path TEXT NOT NULL UNIQUE,
|
||||
year INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
air_date TEXT,
|
||||
duration_sec REAL NOT NULL,
|
||||
language TEXT,
|
||||
language_probability REAL,
|
||||
num_speakers INTEGER,
|
||||
transcript_sha256 TEXT NOT NULL,
|
||||
processed_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_episodes_year ON episodes(year);
|
||||
CREATE INDEX IF NOT EXISTS idx_episodes_air_date ON episodes(air_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS segments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id INTEGER NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
seg_idx INTEGER NOT NULL,
|
||||
start_sec REAL,
|
||||
end_sec REAL,
|
||||
text TEXT NOT NULL,
|
||||
UNIQUE(episode_id, seg_idx)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_segments_episode ON segments(episode_id, start_sec);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS turns (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id INTEGER NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
speaker TEXT NOT NULL,
|
||||
start_sec REAL,
|
||||
end_sec REAL,
|
||||
confidence REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_turns_episode ON turns(episode_id, start_sec);
|
||||
CREATE INDEX IF NOT EXISTS idx_turns_speaker ON turns(episode_id, speaker);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS intros (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id INTEGER NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
role_hint TEXT,
|
||||
intro_time_sec REAL,
|
||||
affiliation TEXT,
|
||||
fillin_for TEXT,
|
||||
source_text TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_intros_episode ON intros(episode_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_intros_name ON intros(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qa_pairs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id INTEGER NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
question_start_sec REAL,
|
||||
question_end_sec REAL,
|
||||
answer_start_sec REAL,
|
||||
answer_end_sec REAL,
|
||||
question_text TEXT NOT NULL,
|
||||
answer_text TEXT NOT NULL,
|
||||
caller_name TEXT,
|
||||
caller_role TEXT,
|
||||
topic TEXT,
|
||||
topic_tags TEXT,
|
||||
usefulness_score INTEGER,
|
||||
topic_class TEXT,
|
||||
is_banter INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_qa_episode ON qa_pairs(episode_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_qa_caller ON qa_pairs(caller_name);
|
||||
-- Indexes on quality columns (usefulness_score, topic_class) are created by
|
||||
-- _migrate_qa_quality_columns() so they apply to both fresh and migrated DBs.
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS segments_fts USING fts5(
|
||||
text,
|
||||
content='segments', content_rowid='id',
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS qa_fts USING fts5(
|
||||
question_text, answer_text,
|
||||
content='qa_pairs', content_rowid='id',
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
"""
|
||||
|
||||
TRIGGERS = """
|
||||
CREATE TRIGGER IF NOT EXISTS segments_ai AFTER INSERT ON segments BEGIN
|
||||
INSERT INTO segments_fts(rowid, text) VALUES (new.id, new.text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS segments_ad AFTER DELETE ON segments BEGIN
|
||||
INSERT INTO segments_fts(segments_fts, rowid, text) VALUES('delete', old.id, old.text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS qa_ai AFTER INSERT ON qa_pairs BEGIN
|
||||
INSERT INTO qa_fts(rowid, question_text, answer_text)
|
||||
VALUES (new.id, new.question_text, new.answer_text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS qa_ad AFTER DELETE ON qa_pairs BEGIN
|
||||
INSERT INTO qa_fts(qa_fts, rowid, question_text, answer_text)
|
||||
VALUES('delete', old.id, old.question_text, old.answer_text);
|
||||
END;
|
||||
"""
|
||||
|
||||
# Most → least specific
|
||||
DATE_PATTERNS = [
|
||||
re.compile(r"(?P<y>20\d{2})[-_](?P<m>\d{1,2})[-_](?P<d>\d{1,2})"),
|
||||
re.compile(r"(?:^|[^\d])(?P<m>\d{1,2})-(?P<d>\d{1,2})-(?P<yy>\d{2})(?:[^\d]|$)"),
|
||||
]
|
||||
|
||||
|
||||
def init_schema(conn: sqlite3.Connection):
|
||||
conn.executescript(SCHEMA)
|
||||
conn.executescript(TRIGGERS)
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
_migrate_qa_quality_columns(conn)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# Columns added by the Q&A quality classifier (Track 1).
|
||||
# Defined here so the migration is idempotent and runs on every invocation:
|
||||
# the SCHEMA above creates them on a fresh DB; this block ALTERs an existing
|
||||
# qa_pairs that pre-dates them. Names + types must match SCHEMA exactly.
|
||||
QA_QUALITY_COLUMNS = (
|
||||
("usefulness_score", "INTEGER"),
|
||||
("topic_class", "TEXT"),
|
||||
("is_banter", "INTEGER"),
|
||||
)
|
||||
|
||||
|
||||
def _migrate_qa_quality_columns(conn: sqlite3.Connection) -> None:
|
||||
"""Add Q&A quality columns to qa_pairs if they're missing.
|
||||
|
||||
Idempotent: existing column values are untouched; new columns default NULL.
|
||||
Safe to call on fresh DBs (qa_pairs already has the columns from SCHEMA -
|
||||
PRAGMA table_info reflects that and we no-op).
|
||||
"""
|
||||
existing = {row[1] for row in conn.execute("PRAGMA table_info(qa_pairs)").fetchall()}
|
||||
if not existing:
|
||||
# qa_pairs hasn't been created yet (shouldn't happen post-SCHEMA, but be safe)
|
||||
return
|
||||
for col, col_type in QA_QUALITY_COLUMNS:
|
||||
if col not in existing:
|
||||
conn.execute(f"ALTER TABLE qa_pairs ADD COLUMN {col} {col_type}")
|
||||
# Indexes on the new columns (CREATE INDEX IF NOT EXISTS is already idempotent,
|
||||
# but on a brand-new DB they were created by SCHEMA; on a migrated DB they
|
||||
# weren't, so create them here too).
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_qa_usefulness ON qa_pairs(usefulness_score)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_qa_topic_class ON qa_pairs(topic_class)")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def parse_air_date(rel_dir: Path) -> str | None:
|
||||
s = rel_dir.as_posix()
|
||||
for pat in DATE_PATTERNS:
|
||||
m = pat.search(s)
|
||||
if not m:
|
||||
continue
|
||||
gd = m.groupdict()
|
||||
try:
|
||||
if gd.get("y"):
|
||||
y, mo, d = int(gd["y"]), int(gd["m"]), int(gd["d"])
|
||||
else:
|
||||
yy = int(gd["yy"])
|
||||
y = 2000 + yy if yy < 30 else 1900 + yy
|
||||
mo, d = int(gd["m"]), int(gd["d"])
|
||||
return datetime(y, mo, d).date().isoformat()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def import_episode(conn: sqlite3.Connection, ep_dir: Path, root: Path) -> str:
|
||||
rel_dir = ep_dir.relative_to(root)
|
||||
rel_path = rel_dir.as_posix() + ".mp3"
|
||||
year_str = rel_dir.parts[0]
|
||||
if not (year_str.isdigit() and len(year_str) == 4):
|
||||
return "skipped"
|
||||
year = int(year_str)
|
||||
title = rel_dir.name
|
||||
air_date = parse_air_date(rel_dir)
|
||||
|
||||
transcript_path = ep_dir / "transcript.json"
|
||||
sha = sha256_file(transcript_path)
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT id, transcript_sha256 FROM episodes WHERE rel_path = ?",
|
||||
(rel_path,),
|
||||
).fetchone()
|
||||
if row and row[1] == sha:
|
||||
return "skipped"
|
||||
|
||||
with open(transcript_path, encoding="utf-8") as f:
|
||||
transcript = json.load(f)
|
||||
with open(ep_dir / "diarization.json", encoding="utf-8") as f:
|
||||
diarization = json.load(f)
|
||||
with open(ep_dir / "intros.json", encoding="utf-8") as f:
|
||||
intros = json.load(f)
|
||||
with open(ep_dir / "qa.json", encoding="utf-8") as f:
|
||||
qa = json.load(f)
|
||||
|
||||
with conn:
|
||||
if row:
|
||||
conn.execute("DELETE FROM episodes WHERE id = ?", (row[0],))
|
||||
status = "updated"
|
||||
else:
|
||||
status = "inserted"
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO episodes
|
||||
(rel_path, year, title, air_date, duration_sec, language,
|
||||
language_probability, num_speakers, transcript_sha256, processed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
rel_path, year, title, air_date,
|
||||
transcript.get("duration", 0.0),
|
||||
transcript.get("language"),
|
||||
transcript.get("language_probability"),
|
||||
diarization.get("num_speakers"),
|
||||
sha,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
episode_id = cur.lastrowid
|
||||
|
||||
conn.executemany(
|
||||
"INSERT INTO segments (episode_id, seg_idx, start_sec, end_sec, text) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
(episode_id, s["id"], s.get("start"), s.get("end"), s["text"])
|
||||
for s in transcript.get("segments", [])
|
||||
],
|
||||
)
|
||||
|
||||
conn.executemany(
|
||||
"INSERT INTO turns (episode_id, speaker, start_sec, end_sec, confidence) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
(episode_id, t["speaker"], t.get("start"), t.get("end"), t.get("confidence"))
|
||||
for t in diarization.get("turns", [])
|
||||
],
|
||||
)
|
||||
|
||||
conn.executemany(
|
||||
"INSERT INTO intros "
|
||||
"(episode_id, name, role_hint, intro_time_sec, affiliation, fillin_for, source_text) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
(episode_id, i["name"], i.get("role_hint"), i.get("intro_time"),
|
||||
i.get("affiliation"), i.get("fillin_for"), i.get("source_text"))
|
||||
for i in intros
|
||||
],
|
||||
)
|
||||
|
||||
conn.executemany(
|
||||
"INSERT INTO qa_pairs "
|
||||
"(episode_id, question_start_sec, question_end_sec, "
|
||||
" answer_start_sec, answer_end_sec, "
|
||||
" question_text, answer_text, caller_name, caller_role, topic, topic_tags) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
(episode_id,
|
||||
p.get("question_start"), p.get("question_end"),
|
||||
p.get("answer_start"), p.get("answer_end"),
|
||||
p.get("question_text"), p.get("answer_text"),
|
||||
p.get("caller_name"), p.get("caller_role"),
|
||||
p.get("topic"), json.dumps(p.get("topic_tags") or []))
|
||||
for p in qa
|
||||
],
|
||||
)
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def find_episode_dirs(root: Path):
|
||||
for d in root.rglob("*"):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
if all((d / fn).exists() for fn in REQUIRED_FILES):
|
||||
yield d
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--root", default=str(DEFAULT_ROOT))
|
||||
ap.add_argument("--db", default=str(DEFAULT_DB))
|
||||
ap.add_argument("--rebuild", action="store_true",
|
||||
help="Delete the DB before import")
|
||||
ap.add_argument("--vacuum", action="store_true",
|
||||
help="VACUUM after import (slow on large DBs)")
|
||||
args = ap.parse_args()
|
||||
|
||||
root = Path(args.root)
|
||||
db = Path(args.db)
|
||||
|
||||
if args.rebuild and db.exists():
|
||||
db.unlink()
|
||||
print(f"deleted {db}")
|
||||
|
||||
db.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db)
|
||||
init_schema(conn)
|
||||
|
||||
n_inserted = n_updated = n_skipped = n_error = 0
|
||||
t0 = time.monotonic()
|
||||
|
||||
dirs = sorted(find_episode_dirs(root))
|
||||
print(f"Found {len(dirs)} complete episode directories under {root}")
|
||||
|
||||
for i, d in enumerate(dirs, 1):
|
||||
try:
|
||||
status = import_episode(conn, d, root)
|
||||
if status == "inserted": n_inserted += 1
|
||||
elif status == "updated": n_updated += 1
|
||||
elif status == "skipped": n_skipped += 1
|
||||
except Exception as e:
|
||||
n_error += 1
|
||||
print(f"ERROR {d.relative_to(root)}: {e}")
|
||||
if i % 50 == 0:
|
||||
print(f" {i}/{len(dirs)} ins={n_inserted} upd={n_updated} skip={n_skipped} err={n_error}")
|
||||
|
||||
conn.executescript("ANALYZE;")
|
||||
if args.vacuum:
|
||||
conn.executescript("VACUUM;")
|
||||
conn.close()
|
||||
|
||||
size_mb = db.stat().st_size / 1024 / 1024
|
||||
elapsed = time.monotonic() - t0
|
||||
print(f"\n=== Done in {elapsed:.1f}s ===")
|
||||
print(f" inserted : {n_inserted}")
|
||||
print(f" updated : {n_updated}")
|
||||
print(f" skipped : {n_skipped}")
|
||||
print(f" errors : {n_error}")
|
||||
print(f" db : {db} ({size_mb:.1f} MB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
Index the 6 test episodes into archive.db.
|
||||
Reads pre-computed transcripts + diarization from test-data/transcripts/.
|
||||
"""
|
||||
import os, sys, re
|
||||
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
from pathlib import Path
|
||||
from src.indexer import ArchiveIndex
|
||||
from src.qa_extractor import load_diarized_transcript, extract_qa_pairs
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
BASE = Path(__file__).parent
|
||||
TRANS_DIR = BASE / "test-data" / "transcripts"
|
||||
EP_DIR = BASE / "test-data" / "episodes"
|
||||
DB_PATH = BASE / "archive.db"
|
||||
|
||||
_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})")
|
||||
|
||||
|
||||
def parse_episode_meta(ep_id: str) -> tuple[str, int | None]:
|
||||
"""Return (date_str_or_year, hr) from episode directory name."""
|
||||
m = _DATE_RE.match(ep_id)
|
||||
if m:
|
||||
date = m.group(1)
|
||||
hr = int(ep_id[-1]) if ep_id.endswith(("-hr1", "-hr2")) else None
|
||||
return date, hr
|
||||
# season/episode format e.g. 2016-s8e43 — use year only
|
||||
year = ep_id[:4]
|
||||
return year, None
|
||||
|
||||
|
||||
console.print(f"\n[bold]Indexing test episodes into {DB_PATH.name}[/bold]")
|
||||
|
||||
with ArchiveIndex(DB_PATH) as idx:
|
||||
rows = []
|
||||
|
||||
for ep_dir in sorted(TRANS_DIR.iterdir()):
|
||||
t_path = ep_dir / "transcript.json"
|
||||
d_path = ep_dir / "diarization.json"
|
||||
if not t_path.exists():
|
||||
continue
|
||||
|
||||
ep_id = ep_dir.name
|
||||
date, hr = parse_episode_meta(ep_id)
|
||||
audio_path = EP_DIR / f"{ep_id}.mp3"
|
||||
|
||||
# Episode duration from transcript
|
||||
import json
|
||||
with open(t_path) as f:
|
||||
td = json.load(f)
|
||||
duration = td.get("duration", 0)
|
||||
|
||||
# Register episode
|
||||
idx.add_episode(
|
||||
episode_id=ep_id,
|
||||
audio_path=audio_path,
|
||||
date=date,
|
||||
duration=duration,
|
||||
hr=hr,
|
||||
)
|
||||
|
||||
# Load diarized segments and index
|
||||
segs = load_diarized_transcript(t_path, d_path if d_path.exists() else None)
|
||||
idx.add_segments(ep_id, segs)
|
||||
|
||||
# Extract and index Q&A pairs
|
||||
pairs = extract_qa_pairs(segs)
|
||||
for p in pairs:
|
||||
idx.add_qa_pair(
|
||||
episode_id=ep_id,
|
||||
q_start=p.question_start, q_end=p.question_end,
|
||||
a_start=p.answer_start, a_end=p.answer_end,
|
||||
question=p.question_text, answer=p.answer_text,
|
||||
topic=p.topic, tags=p.topic_tags,
|
||||
)
|
||||
|
||||
rows.append((ep_id, date, f"{duration:.0f}s", len(segs), len(pairs)))
|
||||
console.print(f" [green]{ep_id}[/green]: {len(segs)} segs, {len(pairs)} Q&A pairs")
|
||||
|
||||
stats = idx.stats()
|
||||
|
||||
table = Table(title="Index Summary")
|
||||
table.add_column("Episode")
|
||||
table.add_column("Date")
|
||||
table.add_column("Duration")
|
||||
table.add_column("Segments")
|
||||
table.add_column("Q&A")
|
||||
for ep_id, date, dur, segs, qa in rows:
|
||||
table.add_row(ep_id, date, dur, str(segs), str(qa))
|
||||
|
||||
console.print()
|
||||
console.print(table)
|
||||
console.print(f"\n[bold]DB totals:[/bold] {stats['episodes']} episodes, "
|
||||
f"{stats['segments']} segments, {stats['qa_pairs']} Q&A pairs")
|
||||
console.print(f"[dim]DB path: {DB_PATH}[/dim]")
|
||||
@@ -1,25 +0,0 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "radio-processor"
|
||||
version = "0.1.0"
|
||||
description = "Audio processor for The Computer Guru Show"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"faster-whisper",
|
||||
"pyannote.audio",
|
||||
"pydub",
|
||||
"librosa",
|
||||
"scikit-learn",
|
||||
"ollama",
|
||||
"rich",
|
||||
"pyyaml",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
radio-process = "src.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["src*"]
|
||||
@@ -1,18 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py /app/
|
||||
|
||||
ENV ARCHIVE_DB=/data/archive.db
|
||||
ENV PORT=8765
|
||||
EXPOSE 8765
|
||||
|
||||
CMD ["python", "-u", "main.py"]
|
||||
@@ -1,13 +0,0 @@
|
||||
services:
|
||||
radio-archive:
|
||||
image: radio-archive:latest
|
||||
container_name: radio-archive
|
||||
restart: unless-stopped
|
||||
build: .
|
||||
volumes:
|
||||
- /mnt/user/appdata/radio-archive/data:/data:ro
|
||||
ports:
|
||||
- "172.16.3.20:8765:8765"
|
||||
environment:
|
||||
ARCHIVE_DB: /data/archive.db
|
||||
PORT: "8765"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
@@ -1,189 +0,0 @@
|
||||
# Session Log — 2026-04-27 (continuation)
|
||||
|
||||
**Project:** The Computer Guru Show — Archive Mining System
|
||||
**Goal:** RTX 4090 perf comparison + run unseen test episodes through full pipeline (transcribe / diarize / Q&A)
|
||||
**Machine:** GURU-BEAST-ROG (RTX 4090, 24GB)
|
||||
**User:** Mike Swanson (mike)
|
||||
|
||||
Companion to:
|
||||
- `2026-04-27-diarization-pipeline.md` (DESKTOP-0O8A1RL, RTX 5070 Ti — initial diarization fixes)
|
||||
- `2026-04-27-qa-extraction-cohost-indexing.md` (DESKTOP-0O8A1RL — co-host profile, batched Whisper, Q&A overhaul)
|
||||
|
||||
This run uses the post-overhaul code (commit `e9ac607`): batched Whisper transcription, co-host-aware diarizer, revised Q&A extractor.
|
||||
|
||||
---
|
||||
|
||||
## Headline
|
||||
|
||||
| Metric | 5070 Ti baseline | RTX 4090 | Delta |
|
||||
|---|---|---|---|
|
||||
| Diarization | 209.7x realtime | **338.1x** | +128.4x (+61.2%) |
|
||||
| Transcription (batched, large-v3 int8_float16) | 63.8x | **94.8x** | +31.0x (+48.6%) |
|
||||
| Q&A pairs (6 test episodes) | 10 | 9 | within noise |
|
||||
|
||||
21,374s of audio (5h 56m) end-to-end on the 4090: **225.5s transcription + 63.2s diarization + Q&A extraction**.
|
||||
|
||||
---
|
||||
|
||||
## Co-host identity correction — Tara, not Tom
|
||||
|
||||
The 5070 Ti session fabricated a co-host named "Tom" — Mike confirmed there is no such person on the show. After listening to the source windows, Mike identified the voice in both 2014-s6e19 and 2016-s8e43 as **Tara** (a real co-host; the show has had multiple over the years).
|
||||
|
||||
Rename swept this session:
|
||||
- `voice-profiles/tom/` → `voice-profiles/tara/` (git mv, all 44 embeddings + composite preserved)
|
||||
- `voice-profiles/profiles.json`: `"Tom"` key → `"Tara"`
|
||||
- `build_cohost_profile.py`: docstring, `TOM_WINDOWS` → `TARA_WINDOWS`, `COHOST_NAME = "Tara"`, console output strings
|
||||
- `projects/radio-show/session-logs/2026-04-27-qa-extraction-cohost-indexing.md`: correction header added, all body references updated
|
||||
- `.claude/memory/radio_show_no_cohost_named_tom.md`: resolution recorded
|
||||
- Diarization re-run post-rename so `speaker_map` in each `diarization.json` emits `Cohost: Tara`
|
||||
|
||||
The 5070 Ti session log's claim of "Tom was the regular co-host roughly 2013-2016" carried two errors: the wrong name AND an unverified tenure window. The corrected log notes Tara appears in 2014-s6e19 and 2016-s8e43 only — generalizing to the full 2013-2016 era hasn't been confirmed.
|
||||
|
||||
---
|
||||
|
||||
## Setup notes (for next machine)
|
||||
|
||||
- ffmpeg/ffprobe is required on PATH — the voice profiler shells out to ffprobe for audio duration and the pipeline crashes on the first diarize call without it. Was missing on this machine; installed via `winget install Gyan.FFmpeg`. BENCH_SETUP.md updated to call this out as a Step-2 prereq.
|
||||
- `.gitignore` (added in `e9ac607`) excludes `episodes/`, `transcripts/`, `*.db`, `.venv`. The test MP3s + transcripts I committed earlier in `2c06e72` are still tracked from before the gitignore arrived; can be `git rm --cached`-ed in a follow-up cleanup.
|
||||
- All voice profiles, training data, and test MP3s were already on this machine via prior auto-sync.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Whisper Transcription (large-v3, batched, int8_float16, batch_size=16)
|
||||
|
||||
| Episode | Audio | Wall | RTF |
|
||||
|---|---|---|---|
|
||||
| 2011-03-12-hr1 | 2509s | 29.7s | 84.6x |
|
||||
| 2012-03-10-hr1 | 2634s | 30.3s | 87.0x |
|
||||
| 2012-06-09-hr1 | 2648s | 33.6s | 78.8x |
|
||||
| 2014-s6e19 | 2914s | 30.2s | 96.6x |
|
||||
| 2016-s8e43 | 5326s | 49.2s | 108.2x |
|
||||
| 2017-s9e30 | 5343s | 52.5s | 101.8x |
|
||||
| **Total** | **21374s** | **225.5s** | **94.8x** |
|
||||
|
||||
vs 5070 Ti's 63.8x: **+48.6%**.
|
||||
|
||||
Batching is doing real work here. The pre-batched code path on this same hardware (first benchmark run earlier today) was 14.8x — batching gave a 6.4× speedup on the 4090.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Diarization (with co-host profile applied)
|
||||
|
||||
| Episode | Audio | Wall | RTF | Turns | HOST | CALLER |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 2011-03-12-hr1 | 2509s | 9.1s | 275.0x | 25 | 2455s | 70s |
|
||||
| 2012-03-10-hr1 | 2634s | 7.6s | 348.3x | 22 | 2615s | 90s |
|
||||
| 2012-06-09-hr1 | 2648s | 7.7s | 343.1x | 13 | 2500s | 10s |
|
||||
| 2014-s6e19 | 2914s | 8.3s | 352.6x | 31 | 2625s | 30s |
|
||||
| 2016-s8e43 | 5326s | 15.1s | 353.6x | 134 | 4615s | 140s |
|
||||
| 2017-s9e30 | 5343s | 15.5s | 345.1x | 69 | 4945s | 350s |
|
||||
| **Total** | **21374s** | **63.2s** | **338.1x** | 294 | 19755s | 690s |
|
||||
|
||||
**vs 5070 Ti baseline: 209.7x → 338.1x (+61.2%).**
|
||||
|
||||
Per-episode RTFs cluster tightly at 343-354x for warm episodes (5/6); episode 1 carries the cold-start penalty at 275.0x. Apples-to-apples vs the 5070 Ti measurement which also includes a cold start.
|
||||
|
||||
Aggregate CALLER time dropped from 2665s (pre-co-host pipeline, run earlier today) to 690s. That ~2000s delta is the second-voice signal correctly being routed away from the CALLER bucket. The benchmark table only sums HOST + CALLER, so CO-HOST seconds aren't shown in the totals — present in the per-episode `diarization.json` files.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Q&A Extraction (post-overhaul: turn-based lookback, 4s CALLER preference, expanded promo signatures)
|
||||
|
||||
| Episode | 4090 Q&A pairs | 5070 Ti reference | Note |
|
||||
|---|---|---|---|
|
||||
| 2011-03-12-hr1 | 1 | 3 | -2 |
|
||||
| 2012-03-10-hr1 | 2 | 1 | +1 |
|
||||
| 2012-06-09-hr1 | 0 | 1 | -1 |
|
||||
| 2014-s6e19 | 0 | 0 | match (gaming, no callers) |
|
||||
| 2016-s8e43 | 2 | 2 | match (WiFi caller) |
|
||||
| 2017-s9e30 | 4 | 3 | +1 |
|
||||
| **Total** | **9** | **10** | **-1** |
|
||||
|
||||
Differences are within noise. Likely sources:
|
||||
- Whisper batched inference produces slightly different segment boundaries on identical audio under different GPU schedule orderings.
|
||||
- Sliding-window diarization midpoint resolution can put a borderline segment in either bucket on different runs.
|
||||
- Q&A extraction thresholds are sensitive to small boundary shifts.
|
||||
|
||||
**The two structural correctness signals match**: 2014 = 0 (no callers in gaming special) and 2016 = 2 (real WiFi caller, two-turn). That's the meaningful test. Aggregate ±1 across six episodes is acceptable run-to-run drift.
|
||||
|
||||
---
|
||||
|
||||
## Files written / modified
|
||||
|
||||
- `test-data/transcripts/<stem>/transcript.json` (6, regenerated with batched Whisper)
|
||||
- `test-data/transcripts/<stem>/diarization.json` (6, regenerated with co-host-aware diarizer)
|
||||
- `benchmark.py` line 27 — `BASELINE_RTF` updated 149.5 → 209.7
|
||||
- `BENCH_SETUP.md` — added ffmpeg prereq to Step 2
|
||||
- `.claude/memory/radio_show_no_cohost_named_tom.md` (new, project memory)
|
||||
- `.claude/memory/MEMORY.md` (index updated)
|
||||
|
||||
archive.db is not on this machine — index update happens on DESKTOP-0O8A1RL.
|
||||
|
||||
---
|
||||
|
||||
## Per-year test set (one episode per year, expanded)
|
||||
|
||||
Mike asked to expand from the original 6 to one episode per year. Added:
|
||||
- 2010: `2010-05-08-hr1.mp3` (May 2010, earliest available; avoids training's Oct 2)
|
||||
- 2015: `2015-s7e19.mp3` (Jan 2015; avoids training's s7e30)
|
||||
- 2018: `2018-s10e18.mp3` (only 3 non-training episodes exist for 2018)
|
||||
|
||||
Archive has no 2019 directory (years 2010-2018, no 2013 either). Rob's "2018/2019 appearances" are constrained to the 5 available 2018 episodes only.
|
||||
|
||||
### Diarization across all 9 episodes
|
||||
|
||||
| Year | Episode | Audio | Tara | % | HOST | CALLER (suspect) | Q&A |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 2010 | 05-08-hr1 | 42:57 | 0:30 | 1.2% | 2325s | **355s** | 4 |
|
||||
| 2011 | 03-12-hr1 | 41:49 | 2:20 | 5.6% | 2455s | 70s | 1 |
|
||||
| 2012 | 03-10-hr1 | 43:54 | 0:30 | 1.1% | 2615s | 90s | 2 |
|
||||
| 2012 | 06-09-hr1 | 44:08 | 5:40 | 12.8% | 2500s | 10s | 0 |
|
||||
| 2014 | s6e19 | 48:34 | 11:20 | 23.3% | 2625s | 30s | 0 |
|
||||
| 2015 | s7e19 | 47:13 | 4:40 | 9.9% | 2690s | 45s | 1 |
|
||||
| 2016 | s8e43 | 88:46 | 31:30 | 35.5% | 4615s | 140s | 2 |
|
||||
| 2017 | s9e30 | 89:03 | 10:10 | 11.4% | 4945s | 350s | 4 |
|
||||
| 2018 | s10e18 | 85:45 | 14:40 | 17.1% | 4745s | 230s | 3 |
|
||||
| **Total** | | **8h 52m** | **1h 21m** (15.3%) | | | **1320s** | **17** |
|
||||
|
||||
### Read on each row
|
||||
|
||||
| Episode | Tara reading |
|
||||
|---|---|
|
||||
| 2010-05-08-hr1 | likely false positive (30s); 2010 was pre-Tara; could be Randall or a producer |
|
||||
| 2011-03-12-hr1 | likely false positive; 2011 was pure call-in per Mike |
|
||||
| 2012-03-10-hr1 | likely false positive; 2012 was pure call-in per Mike |
|
||||
| 2012-06-09-hr1 | suspicious (5:40 is too much for noise); pending Mike spot-check |
|
||||
| 2014-s6e19 | confirmed Tara |
|
||||
| 2015-s7e19 | substantial (4:40) — plausibly Tara was on early 2015; Mike to confirm |
|
||||
| 2016-s8e43 | confirmed Tara |
|
||||
| 2017-s9e30 | plausible Tara (or another co-host); Mike to confirm |
|
||||
| 2018-s10e18 | **could be Rob, not Tara** — Mike flagged Rob for 2018/2019 appearances. The cosine threshold may be hitting because the two co-hosts have similar acoustic properties. Worth Mike sampling. |
|
||||
|
||||
### Q&A counts caveat
|
||||
|
||||
The Q&A column is still suspect because **every voice that isn't Mike-or-Tara is labeled CALLER**, including Randall, Rob, and any on-air producer (Andrew/Shannon/Ken/etc). The 2010 episode in particular shows 355s CALLER and 4 Q&A — but per Mike's roster, that CALLER bucket likely includes a co-host or producer, not real callers. Spot-check before treating early-years Q&A as ground truth.
|
||||
|
||||
**Mike's broader correction (2026-04-27):**
|
||||
- **Co-hosts** rotated through over the years. Confirmed: Tara, Randall (early years), Rob (early years + occasional 2018/2019).
|
||||
- **Producers / board ops** would sometimes go on-air. Named so far: Andrew, Shannon, Ken, plus "a couple more" Mike doesn't recall off-hand.
|
||||
|
||||
Of all these, only Tara has a voice profile. Every other co-host AND every producer-on-air moment in the archive is currently being labeled CALLER, which inflates Q&A false positives in those eras and episodes.
|
||||
|
||||
The small Tara percentages in 2011/2012 (1-13%) most likely reflect the 0.85 cosine threshold hitting on a similar-sounding speaker that isn't actually Tara — could be a producer (Andrew/Shannon/Ken/etc) or another early-years voice we haven't catalogued. Worth Mike sampling these short windows to identify before assuming false positive vs producer.
|
||||
|
||||
**Implication for full-archive runs:** before processing the 579-episode archive in earnest, build profiles for at least Randall, Rob, and the named producers. Otherwise the Q&A extraction across early-years and 2018/2019 episodes will inherit the same false-positive pattern that originally produced 12 bogus pairs in 2016-s8e43.
|
||||
|
||||
---
|
||||
|
||||
## Pending work (from 5070 Ti session, still unblocked)
|
||||
|
||||
1. **Resolve "Tom" identity** — Mike to confirm who the second voice is in 2014-s6e19 and 2016-s8e43. Then rename `voice-profiles/tom/`, update `profiles.json`, fix labels in code. Until then, voice-profile data is correct but mislabeled.
|
||||
2. **Full archive download** — 579 MP3s from IX server (~30-40GB). 4090 + Tailscale ready.
|
||||
3. **Full pipeline run on archive** — at 338x diarization + 95x transcription, total wall time for ~30h of audio extrapolates to roughly 19 minutes diarization + 19 minutes transcription. Disk I/O may dominate.
|
||||
|
||||
---
|
||||
|
||||
## Note for Mike
|
||||
|
||||
- "Tom" is wrong — see callout above. Tell me who that is and I'll do the rename in one pass (directory, profiles.json, build_cohost_profile.py, the 5070 Ti session log, and a fresh diarization pass to update `speaker_map`).
|
||||
- BENCH_SETUP.md got a one-paragraph ffmpeg prereq added at the top of Step 2.
|
||||
@@ -1,292 +0,0 @@
|
||||
# Session Log — 2026-04-27 (continuation #2)
|
||||
|
||||
**Project:** The Computer Guru Show — Archive Mining System
|
||||
**Goal:** Resume archive download + batch transcribe/diarize after machine restart, then design + build the SQLite archive database
|
||||
**Machine:** GURU-BEAST-ROG (RTX 4090, 24GB)
|
||||
**User:** Mike Swanson (mike)
|
||||
|
||||
Companion to:
|
||||
- `2026-04-27-diarization-pipeline.md` (DESKTOP-0O8A1RL — diarization fixes)
|
||||
- `2026-04-27-4090-benchmark-and-test-set.md` (GURU-BEAST-ROG — 4090 perf + per-year test set)
|
||||
|
||||
---
|
||||
|
||||
## User
|
||||
- **User:** Mike Swanson (mike)
|
||||
- **Machine:** GURU-BEAST-ROG
|
||||
- **Role:** admin
|
||||
|
||||
---
|
||||
|
||||
## Session Summary
|
||||
|
||||
The session focused on resuming interrupted archive processing and initiating the design of a SQLite database for the Computer Guru Radio Show. The machine had restarted during the execution of `download_full_archive.py` and `batch_process.py`, leaving the download partially complete and batch processing halted mid-year. The download had completed through 2015 with partial progress in 2016, and years 2017 and 2018 were entirely missing locally. batch_process had finished 2010 (43/43) and stopped at 21 of 200 episodes in 2011. Connectivity to the IX server was confirmed via Tailscale, the SOPS vault yielded the IX root password, and both jobs were restarted in the background.
|
||||
|
||||
The download successfully resumed via size-match skipping, then pulled the remaining 88 files (2.65 GB) covering late-2016 plus all of 2017 and 2018 in 30 minutes wall time, 0 errors. batch_process picked up at episode 65/519 of its file-list snapshot but immediately tripped a `'charmap' codec can't encode character '⁄'` error: `src/transcriber.py` opened `transcript.txt` and `transcript.srt` with Windows default cp1252 encoding, which cannot represent Whisper's U+2044 fraction-slash output. The fix was a one-line addition (`encoding="utf-8"`) on three `open()` calls. The partial output dir for episode 65 was deleted to force a clean redo, and batch_process was restarted.
|
||||
|
||||
The user then raised an architectural question about where the canonical archive database should live. Discussion converged on SQLite (over MariaDB) because the per-episode JSONs are the source of truth and the `.db` is rebuildable in seconds, and on Jupiter+Docker (over IX cPanel) because the use case is internal-only and Tailscale already provides access; public exposure can be added later via cloudflared. The schema (5 tables + 2 FTS5 virtual tables) was designed and `import_to_sqlite.py` was written and smoke-tested against 208 currently-complete episodes — 1.9 seconds for full rebuild, 20.5 MB DB, FTS queries on "wireless" and "virus" returning correct snippets.
|
||||
|
||||
By session end, the download was complete and batch_process was at episode 211/519 (147 episodes transcribed since the encoding-fix restart). One final batch_process re-run is needed after the current 519-snapshot finishes, to pick up the 53 newly-downloaded files that were not in the startup snapshot.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **SQLite over MariaDB**: per-episode JSONs are the source of truth, the `.db` is rebuildable in seconds. Can graduate to MariaDB later by re-importing from the same JSONs without losing anything.
|
||||
- **Jupiter (Unraid Docker) over IX cPanel**: use case is internal-only show-prep search. Tailscale already covers access. IX is the right place for public-facing show-site content but adds shared-hosting friction the v1 doesn't need.
|
||||
- **FTS5 with `porter unicode61` tokenizer**: porter stemming for English query expansion, unicode61 for case-folding and basic punctuation handling. External-content tables with content_rowid pointing back to `segments` and `qa_pairs` so the FTS index doesn't duplicate the text.
|
||||
- **Skip speaker-name resolution view in v1**: turns table holds role labels (HOST/CO-HOST/CALLER/BUMPER), intros and qa_pairs hold real names. A SQL view that joins them by time-window is cheap to add later and no data is lost by deferring.
|
||||
- **Keep BUMPER turns and promo-flagged segments raw**: filter at query time. Excluding them at insert loses signal that may matter for future analysis.
|
||||
- **sha256 of transcript.json as idempotency key**: importer skips an episode whose recorded hash matches the on-disk file. Re-run the importer after each batch_process pass; it only does work for changed files.
|
||||
- **Restart batch_process to fix encoding bug rather than --amend partial files**: the .json was correct (ensure_ascii=True default), but .txt and .srt were potentially truncated. Cleanest path was to delete the failed episode's whole output dir and let the pipeline regenerate everything with the encoding fix.
|
||||
|
||||
---
|
||||
|
||||
## Problems Encountered
|
||||
|
||||
- **`'charmap' codec` encoding error in transcriber.py**
|
||||
- Cause: `open(... "w")` defaulted to Windows cp1252; Whisper output contained U+2044 (fraction slash) which cp1252 cannot encode.
|
||||
- Fix: added `encoding="utf-8"` to the three open() calls at `src/transcriber.py:93,97,101`.
|
||||
- Audited the rest of the pipeline: `diarizer.py`, `batch_process.py`, and other JSON writers use `json.dump` default `ensure_ascii=True`, which escapes unicode to ASCII before encoding — safe under cp1252 even without explicit utf-8. Only `transcriber.py` writes raw unicode (transcript.txt, transcript.srt).
|
||||
- **Failed episode left inconsistent output**
|
||||
- Episode 65 (`2011/10 - October/10-15-11 HR 2`) had `transcript.json` written successfully but `transcript.txt` truncated mid-encode.
|
||||
- Fix: `rm -rf` the entire episode output dir; batch_process redoes it cleanly on next pass.
|
||||
- **Monitor refired the same error every poll**
|
||||
- Initial monitor used `grep -E "ERROR" $LOG | tail -1` each iteration, so a single historical error line emitted a notification every 60s.
|
||||
- Fix: track error count between polls; only emit when count grows. Same pattern applied to download FAILED counts.
|
||||
- **batch_process snapshot taken before download finished**
|
||||
- `all_mp3s = ...` is computed once at startup. The 53 newly-downloaded MP3s (late-2016, 2017, 2018) are not visible to the currently-running batch.
|
||||
- Mitigation: after current 519-snapshot run finishes, relaunch batch_process once. Resumability via existence-check makes the re-run only process the new files.
|
||||
|
||||
---
|
||||
|
||||
## Files Modified / Created
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `projects/radio-show/audio-processor/src/transcriber.py` | Added `encoding="utf-8"` to all three `open()` calls in `Transcript.save()` (lines 93, 97, 101) |
|
||||
| `projects/radio-show/audio-processor/import_to_sqlite.py` | NEW. Walks archive-data/transcripts, imports JSONs into archive.db with FTS5. sha256-keyed idempotency. |
|
||||
| `projects/radio-show/audio-processor/batch_process.py` | (already untracked from prior session — no edits this session) |
|
||||
| `projects/radio-show/audio-processor/archive-data/episodes/{2010..2018}/` | Filled in by download_full_archive.py — 88 new files |
|
||||
| `projects/radio-show/audio-processor/archive-data/transcripts/{2010,2011}/...` | Per-episode output dirs — written by batch_process |
|
||||
| `projects/radio-show/audio-processor/archive-data/archive.db` | NEW (smoke-test rebuild, 20.5 MB at 208 episodes) |
|
||||
| `projects/radio-show/audio-processor/logs/download.log` | Background download output |
|
||||
| `projects/radio-show/audio-processor/logs/batch_process.log` | Background batch output |
|
||||
|
||||
---
|
||||
|
||||
## SQLite Schema (full DDL)
|
||||
|
||||
```sql
|
||||
CREATE TABLE episodes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
rel_path TEXT NOT NULL UNIQUE,
|
||||
year INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
air_date TEXT,
|
||||
duration_sec REAL NOT NULL,
|
||||
language TEXT,
|
||||
language_probability REAL,
|
||||
num_speakers INTEGER,
|
||||
transcript_sha256 TEXT NOT NULL,
|
||||
processed_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_episodes_year ON episodes(year);
|
||||
CREATE INDEX idx_episodes_air_date ON episodes(air_date);
|
||||
|
||||
CREATE TABLE segments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id INTEGER NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
seg_idx INTEGER NOT NULL,
|
||||
start_sec REAL, end_sec REAL,
|
||||
text TEXT NOT NULL,
|
||||
UNIQUE(episode_id, seg_idx)
|
||||
);
|
||||
CREATE INDEX idx_segments_episode ON segments(episode_id, start_sec);
|
||||
|
||||
CREATE TABLE turns (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id INTEGER NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
speaker TEXT NOT NULL, -- HOST / CO-HOST / CALLER / BUMPER
|
||||
start_sec REAL, end_sec REAL,
|
||||
confidence REAL
|
||||
);
|
||||
CREATE INDEX idx_turns_episode ON turns(episode_id, start_sec);
|
||||
CREATE INDEX idx_turns_speaker ON turns(episode_id, speaker);
|
||||
|
||||
CREATE TABLE intros (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id INTEGER NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
role_hint TEXT, -- caller / cohost / fillin
|
||||
intro_time_sec REAL,
|
||||
affiliation TEXT, fillin_for TEXT,
|
||||
source_text TEXT
|
||||
);
|
||||
CREATE INDEX idx_intros_episode ON intros(episode_id);
|
||||
CREATE INDEX idx_intros_name ON intros(name);
|
||||
|
||||
CREATE TABLE qa_pairs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id INTEGER NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
question_start_sec REAL, question_end_sec REAL,
|
||||
answer_start_sec REAL, answer_end_sec REAL,
|
||||
question_text TEXT NOT NULL,
|
||||
answer_text TEXT NOT NULL,
|
||||
caller_name TEXT, caller_role TEXT,
|
||||
topic TEXT, topic_tags TEXT -- JSON array as TEXT
|
||||
);
|
||||
CREATE INDEX idx_qa_episode ON qa_pairs(episode_id);
|
||||
CREATE INDEX idx_qa_caller ON qa_pairs(caller_name);
|
||||
|
||||
CREATE VIRTUAL TABLE segments_fts USING fts5(
|
||||
text, content='segments', content_rowid='id',
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
CREATE VIRTUAL TABLE qa_fts USING fts5(
|
||||
question_text, answer_text,
|
||||
content='qa_pairs', content_rowid='id',
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
-- + standard ai/ad triggers to keep FTS in sync on insert/delete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Smoke-Test Results (post-import, mid-batch)
|
||||
|
||||
```
|
||||
Found 208 complete episode directories under archive-data/transcripts/
|
||||
|
||||
inserted : 208
|
||||
updated : 0
|
||||
skipped : 0
|
||||
errors : 0
|
||||
db : archive-data/archive.db (20.5 MB)
|
||||
wall : 1.9 seconds
|
||||
```
|
||||
|
||||
| Year | Episodes | Hours |
|
||||
|---|---|---|
|
||||
| 2010 | 43 | 32.1 |
|
||||
| 2011 | 165 | 122.2 |
|
||||
| **Total at smoke-test time** | **208** | **154.3** |
|
||||
|
||||
| Table | Rows |
|
||||
|---|---|
|
||||
| episodes | 208 |
|
||||
| segments | 19,745 |
|
||||
| turns | 7,233 |
|
||||
| intros | 1,117 |
|
||||
| qa_pairs | 566 |
|
||||
|
||||
Air-date parsed for 204/208 episodes (4 misses are season/episode-format filenames like `s7e30` with no calendar date — accepted).
|
||||
|
||||
FTS5 queries verified:
|
||||
- `segments MATCH 'wireless'` returned 3 hits with correct episode attribution and snippets
|
||||
- `qa MATCH 'virus'` returned 3 hits with correct episode attribution
|
||||
|
||||
---
|
||||
|
||||
## Download Run — Final Stats
|
||||
|
||||
```
|
||||
=== Summary ===
|
||||
Total remote files : 589
|
||||
Total remote bytes : 7.53 GB
|
||||
Already present : 501 files / 4.88 GB
|
||||
Newly downloaded : 88 files / 2.65 GB
|
||||
Errors : 0
|
||||
Wall time : 1799.3s
|
||||
```
|
||||
|
||||
| Year | Local MP3 count |
|
||||
|---|---|
|
||||
| 2010 | 43 |
|
||||
| 2011 | 200 |
|
||||
| 2012 | 98 |
|
||||
| 2014 | 81 |
|
||||
| 2015 | 50 |
|
||||
| 2016 | 54 |
|
||||
| 2017 | 41 |
|
||||
| 2018 | 5 |
|
||||
| **Total** | **572** |
|
||||
|
||||
(572 vs 589-remote-total: 17-file delta is case-variant duplicates `.MP3`/`.mp3` already counted under one local name, not missing files.)
|
||||
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
### IX Server (archive source)
|
||||
- **Vault path:** `infrastructure/ix-server.sops.yaml`
|
||||
- **Host:** 172.16.3.10 (Tailscale required)
|
||||
- **External:** ix.azcomputerguru.com / 72.194.62.5
|
||||
- **SSH port:** 22
|
||||
- **OS:** Rocky Linux (WHM/cPanel; WHM 2087, cPanel 2083)
|
||||
- **Username:** root
|
||||
- **Password:** `Gptf*77ttb!@#!@#`
|
||||
- **Notes:** Use paramiko with `look_for_keys=False, allow_agent=False, timeout=30, banner_timeout=30, auth_timeout=30`. Set `transport.set_keepalive(30)` and `sftp.get_channel().settimeout(120)` for long sessions. SSH from command line is blocked by key-agent interference on this machine.
|
||||
|
||||
### Jupiter (Unraid — planned destination for archive.db)
|
||||
- **Vault path:** `infrastructure/jupiter-unraid-primary.sops.yaml`
|
||||
- (Container setup pending — no work done yet, just architectural decision)
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure & Paths
|
||||
|
||||
| Resource | Value |
|
||||
|---|---|
|
||||
| Audio processor root | `c:\Users\guru\ClaudeTools\projects\radio-show\audio-processor\` |
|
||||
| Episodes root (local) | `archive-data/episodes/<year>/...` |
|
||||
| Transcripts root (local) | `archive-data/transcripts/<year>/.../<stem>/` |
|
||||
| Archive DB (local) | `archive-data/archive.db` |
|
||||
| Per-episode outputs | `transcript.json`, `transcript.txt`, `transcript.srt`, `diarization.json`, `intros.json`, `qa.json` |
|
||||
| Voice profiles | `voice-profiles/` (181 profiles loaded by current run) |
|
||||
| Background log dir | `logs/` (download.log, batch_process.log) |
|
||||
| Remote archive root | `/home/gurushow/public_html/archive/{2010-2018}/` on IX |
|
||||
| Planned Jupiter dir | `/mnt/user/appdata/radio-archive/` |
|
||||
|
||||
---
|
||||
|
||||
## Commands Run (key invocations)
|
||||
|
||||
```bash
|
||||
# Resume download (from audio-processor dir, in venv)
|
||||
IX_PASSWORD='Gptf*77ttb!@#!@#' .venv/Scripts/python.exe download_full_archive.py > logs/download.log 2>&1
|
||||
|
||||
# Resume batch transcribe + diarize (no env needed)
|
||||
.venv/Scripts/python.exe batch_process.py >> logs/batch_process.log 2>&1
|
||||
|
||||
# Initial DB build / smoke test
|
||||
.venv/Scripts/python.exe import_to_sqlite.py --rebuild
|
||||
|
||||
# Subsequent incremental imports (after each batch_process pass)
|
||||
.venv/Scripts/python.exe import_to_sqlite.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pending / Next Up
|
||||
|
||||
1. **Wait for current batch_process to finish** the 519-file snapshot (currently at 211/519, 147 transcribed since restart).
|
||||
2. **Re-launch batch_process once more** — picks up the 53 new MP3s downloaded after the snapshot was taken (5 late-2016 + 41 in 2017 + 5 in 2018 + 2 stragglers).
|
||||
3. **Re-run import_to_sqlite.py** (incremental, idempotent — only the new ones do real work).
|
||||
4. **Stand up the Jupiter Docker container**:
|
||||
- Create `/mnt/user/appdata/radio-archive/` on Jupiter
|
||||
- Define container (FastAPI + sqlite, ~50 lines) — read-only mount of `archive.db`
|
||||
- Expose only on Tailscale interface, not on the public IP
|
||||
- rsync `archive.db` from GURU-BEAST-ROG to Jupiter as the deploy step
|
||||
5. **Decide on speaker-name resolution view** once query patterns emerge.
|
||||
6. **(Future)** profile-build for Randall, Rob, and named producers (Andrew/Shannon/Ken) so non-Mike-non-Tara speakers stop falling into the CALLER bucket. Per the prior session log, this is what's inflating Q&A false-positive rates in early-years and 2018/2019 episodes.
|
||||
|
||||
---
|
||||
|
||||
## Reference Information
|
||||
|
||||
- **Encoding rule for Windows Python:** any `open(...)` that may write or read non-ASCII text (transcripts, captions, raw text dumps) must specify `encoding="utf-8"`. JSON writes via `json.dump` with default `ensure_ascii=True` are safe but defensive `encoding="utf-8"` doesn't hurt.
|
||||
- **batch_process resumability:** existence-check on all four output JSONs. To force a redo, delete the episode's output directory.
|
||||
- **Importer resumability:** sha256 of `transcript.json` recorded per episode. Hash mismatch → cascade-delete + reinsert in one transaction.
|
||||
- **FTS5 trigger pattern (external content):** `INSERT INTO fts(rowid, ...)` for ai trigger; `INSERT INTO fts(fts, rowid, ...) VALUES('delete', ...)` for ad trigger. Same column count for both.
|
||||
- **Per-year MP3 totals on IX:** 2010 (52), 2011 (200), 2012 (98), 2014 (81), 2015 (50), 2016 (54), 2017 (41), 2018 (5) — note 2013 directory does not exist on the source.
|
||||
@@ -1,135 +0,0 @@
|
||||
# Session Log — 2026-04-27
|
||||
|
||||
**Project:** The Computer Guru Show — Archive Mining System
|
||||
**Goal:** Build searchable transcript archive of 579 episodes (2010-2018) with caller Q&A extraction for "then vs now" show prep
|
||||
**Machine:** DESKTOP-0O8A1RL
|
||||
**User:** Mike Swanson (mike)
|
||||
|
||||
---
|
||||
|
||||
## Work Completed
|
||||
|
||||
### Critical Bug Fix — `voice_profiler.py` `identify_speakers()`
|
||||
|
||||
`identify_speakers()` was unconditionally labeling all windows as HOST regardless of similarity score. The host-role label assignment ran after the threshold check and overwrote it. Fixed by gating the "Host:" label inside the `best_score >= threshold` branch.
|
||||
|
||||
### Threshold Tuning
|
||||
|
||||
Raised similarity threshold from 0.70 to 0.85. Diagnostic run on `2010-10-02-hr1.mp3` confirmed clean separation:
|
||||
|
||||
- Mike's voice: scores 0.90-0.99
|
||||
- Caller windows: scores 0.46-0.83
|
||||
|
||||
### Audio Preload Optimization
|
||||
|
||||
`identify_speakers()` previously spawned approximately 500 ffmpeg subprocesses per episode (one per 10-second window). Rewrote to load full audio once via `_load_full_audio()` and slice in-memory numpy arrays per window.
|
||||
|
||||
**Result: 149.5x realtime on RTX 5070 Ti**
|
||||
Measured: 10,600 seconds of audio processed in 70.9 seconds.
|
||||
|
||||
### Promo/Bumper Filter — `qa_extractor.py`
|
||||
|
||||
Added `_is_promo_or_bumper()` with weighted signature scoring:
|
||||
|
||||
- Score 2 = highly distinctive phrase
|
||||
- Score 1 = semi-generic phrase
|
||||
- Threshold = 2
|
||||
|
||||
Filters show promos such as "Computer running slow? Has your machine somehow acquired a life of its own?" from Q&A pairs. Reduced false positives from 42 to 27 pairs across 9 training episodes.
|
||||
|
||||
### 2018 Episode Re-diarization
|
||||
|
||||
Episodes `2018-s10e17` and `2018-s10e21` had stale all-HOST diarization from an aborted earlier run. Re-diarized correctly:
|
||||
|
||||
- `2018-s10e17`: 49 turns / 775s caller
|
||||
- `2018-s10e21`: 110 turns / 1175s caller
|
||||
|
||||
### Text-Only Q&A Fallback
|
||||
|
||||
Added `_extract_qa_text_only()` to handle cases where diarization produces no CALLER labels. Uses question-pattern signals and caller-intro phrase detection. Automatically triggered when all segments are labeled HOST.
|
||||
|
||||
### TRANSFORMERS_OFFLINE=1
|
||||
|
||||
Set in `diarize_training.py` and `diarize_2018.py` to prevent HuggingFace freshness checks on the cached WavLM model.
|
||||
|
||||
### HuggingFace / Model Note
|
||||
|
||||
WavLM (`microsoft/wavlm-base-sv`) is ungated and sufficient for speaker verification. pyannote was evaluated but not needed.
|
||||
|
||||
---
|
||||
|
||||
## Key Files Modified
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/voice_profiler.py` | Threshold bug fix, audio preload optimization, `_embed_audio_np()`, `_load_full_audio()` |
|
||||
| `src/qa_extractor.py` | Promo filter (`_is_promo_or_bumper()`), text-only fallback (`_extract_qa_text_only()`) |
|
||||
| `src/diarizer.py` | Default threshold raised to 0.85 |
|
||||
| `diarize_training.py` | TRANSFORMERS_OFFLINE=1, threshold=0.85 |
|
||||
| `diarize_2018.py` | New targeted script for 2018 re-diarization and DB patch |
|
||||
| `check_scores.py` | Diagnostic script — keep for future threshold tuning |
|
||||
|
||||
---
|
||||
|
||||
## Training Set (archive/archive.db)
|
||||
|
||||
9 episodes, 17,555 segments, 27 Q&A pairs total.
|
||||
|
||||
| Episode ID | File | Duration | Caller Segs | Q&A Pairs |
|
||||
|---|---|---|---|---|
|
||||
| 2010-10-02 | 2010-10-02-hr1.mp3 | 44m36s | 79 | 5 |
|
||||
| 2011-06-04 | 2011-06-04-hr1.mp3 | 42m42s | 31 | 1 |
|
||||
| 2011-09-10 | 2011-09-10-hr1.mp3 | 41m46s | 4 | 0 |
|
||||
| 2014-s6e05 | 2014-s6e05.mp3 | 47m27s | 153 | 3 |
|
||||
| 2015-s7e30 | 2015-s7e30.mp3 | 45m21s | 105 | 5 |
|
||||
| 2016-s8e42 | 2016-s8e42.mp3 | 90m24s | 227 | 5 |
|
||||
| 2017-s9e26 | 2017-s9e26.mp3 | 89m25s | 374 | 5 |
|
||||
| 2018-s10e17 | 2018-s10e17.mp3 | 88m22s | 816 | 0 |
|
||||
| 2018-s10e21 | 2018-s10e21.mp3 | 88m20s | 454 | 3 |
|
||||
|
||||
---
|
||||
|
||||
## Test Set — Downloaded, Not Yet Transcribed
|
||||
|
||||
Files saved to `test-data/episodes/`.
|
||||
|
||||
| Local Filename | Source Path on IX (172.16.3.10) | Size | Notes |
|
||||
|---|---|---|---|
|
||||
| 2011-03-12-hr1.mp3 | /home/gurushow/public_html/archive/2011/3-12-11 HR 1.mp3 | 8.8MB | 2011 unseen date |
|
||||
| 2012-03-10-hr1.mp3 | /home/gurushow/public_html/archive/2012/3 - March/3-10-12HR1.mp3 | 11.7MB | 2012 — completely untrained year |
|
||||
| 2012-06-09-hr1.mp3 | /home/gurushow/public_html/archive/2012/6 - June/6-9-12-HR1.mp3 | 12.2MB | 2012 — completely untrained year |
|
||||
| 2014-s6e19.mp3 | /home/gurushow/public_html/archive/2014/06/s6e19.mp3 | 10.3MB | 2014 different episode |
|
||||
| 2016-s8e43.mp3 | /home/gurushow/public_html/archive/2016/06/s8e43.mp3 | 18.0MB | 2016 different episode |
|
||||
| 2017-s9e30.mp3 | /home/gurushow/public_html/archive/2017/04/s9e30.mp3 | 48.2MB | 2017 different episode |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Transcribe test episodes: `py src/cli.py batch --transcribe-only test-data/episodes/`
|
||||
2. Diarize test episodes: run diarize script targeting `test-data/episodes/`
|
||||
3. Extract Q&A pairs from test set
|
||||
4. Compare Q&A quality vs training set
|
||||
5. Performance comparison vs RTX 4090 (separate session on that machine)
|
||||
|
||||
---
|
||||
|
||||
## RTX 4090 Performance Comparison (Separate Machine)
|
||||
|
||||
The 4090 machine needs:
|
||||
|
||||
- Full repo clone from Gitea
|
||||
- `voice-profiles/` directory (contains mike-swanson composite + 180 embeddings)
|
||||
- The 6 test episode MP3s from `test-data/episodes/`
|
||||
- Run: `TRANSFORMERS_OFFLINE=1 py diarize_2018.py` against test episodes, record realtime factor
|
||||
- Compare to 5070 Ti baseline: **149.5x realtime** (10,600s audio in 70.9s)
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Notes
|
||||
|
||||
**Archive server:**
|
||||
- IX server: 172.16.3.10 (see vault: `infrastructure/ix-server.sops.yaml`)
|
||||
- SSH blocked from command line due to key agent interference — use Python paramiko with `look_for_keys=False, allow_agent=False`
|
||||
- Tailscale must be running for 172.16.3.x access
|
||||
- Full archive: 579 MP3 files across `/home/gurushow/public_html/archive/{2010,2011,2012,2014,2015,2016,2017,2018}/`
|
||||
@@ -1,322 +0,0 @@
|
||||
# Session Log — 2026-04-28
|
||||
|
||||
**Project:** The Computer Guru Show — Archive Mining System
|
||||
**Goal:** Complete the in-flight batch + sqlite import; bring DB to final state
|
||||
**Machine:** GURU-BEAST-ROG (RTX 4090, 24GB)
|
||||
**User:** Mike Swanson (mike)
|
||||
|
||||
Continuation of `2026-04-27-archive-batch-and-sqlite-import.md`. Short execution-only session — finished the runs that were started yesterday, no new architecture or code.
|
||||
|
||||
---
|
||||
|
||||
## User
|
||||
- **User:** Mike Swanson (mike)
|
||||
- **Machine:** GURU-BEAST-ROG
|
||||
- **Role:** admin
|
||||
|
||||
---
|
||||
|
||||
## Session Summary
|
||||
|
||||
The session completed the final processing steps for the Computer Guru Radio Show archive. `batch_process.py` finished its first 519-file snapshot — 449 processed, 68 cached, 0 errors, 4.6 hours wall — and was relaunched to pick up the 53 stragglers (late-2016 plus all of 2017/2018) that arrived after the original snapshot was taken. The second pass took 56.4 minutes with 0 errors. The encoding fix made yesterday in `src/transcriber.py` held across both passes — no charmap errors recurred.
|
||||
|
||||
`import_to_sqlite.py` was run once after the second batch pass, incrementally over all 572 complete episode directories. 364 newly inserted (the second-pass output plus everything done after the smoke test), 208 skipped via sha256 match, 0 errors, 3.4 seconds. The DB grew from 20.5 MB → 57.7 MB and now covers the entire downloadable archive: 572 episodes, 482.7 audio-hours, 60,917 transcript segments, 25,918 diarization turns, 3,043 intros, 1,407 Q&A pairs.
|
||||
|
||||
Both tasks (#1 download, #2 batch_process) are now complete. The next step is the deferred Jupiter Docker container deployment so the DB can be queried over Tailscale by Howard or me during show prep.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- No new architectural decisions this session — execution pass on the prior session's plan.
|
||||
|
||||
---
|
||||
|
||||
## Problems Encountered
|
||||
|
||||
- **None.** No errors during either batch pass or the import.
|
||||
- One pre-existing minor bug surfaced (not blocking, not fixed): `batch_process.py`'s `all_intros` dict is module-level and reset at each run start, so `intro_roster.json` is overwritten per-run rather than aggregated across runs. The second pass's roster shows only 89 unique names from its 53 episodes, not the 303+ cumulative across the full archive. The per-episode `intros.json` files remain authoritative and the DB import reads from those, so the canonical name list in the DB (`intros` table — 3,043 rows) is correct. Future cleanup: load+merge existing roster.json before populating, or just drop the aggregate JSON and rely on `SELECT DISTINCT name FROM intros` against the DB.
|
||||
|
||||
---
|
||||
|
||||
## Final DB State
|
||||
|
||||
```
|
||||
DB: archive-data/archive.db (57.7 MB)
|
||||
```
|
||||
|
||||
| Year | Episodes | Hours |
|
||||
|---|---|---|
|
||||
| 2010 | 43 | 32.1 |
|
||||
| 2011 | 200 | 147.8 |
|
||||
| 2012 | 98 | 70.4 |
|
||||
| 2014 | 81 | 64.5 |
|
||||
| 2015 | 50 | 38.4 |
|
||||
| 2016 | 54 | 61.7 |
|
||||
| 2017 | 41 | 60.5 |
|
||||
| 2018 | 5 | 7.3 |
|
||||
| **Total** | **572** | **482.7** |
|
||||
|
||||
(Source archive has no 2013 directory.)
|
||||
|
||||
| Table | Rows |
|
||||
|-----------|--------|
|
||||
| episodes | 572 |
|
||||
| segments | 60,917 |
|
||||
| turns | 25,918 |
|
||||
| intros | 3,043 |
|
||||
| qa_pairs | 1,407 |
|
||||
|
||||
**Top 10 recurring caller names (by Q&A count):**
|
||||
|
||||
| Caller | Pairs |
|
||||
|---|---|
|
||||
| Mark | 77 |
|
||||
| John | 52 |
|
||||
| Steve | 49 |
|
||||
| Tom | 46 |
|
||||
| Richard | 30 |
|
||||
| Paul | 29 |
|
||||
| Robert | 29 |
|
||||
| Bill | 27 |
|
||||
| Jeff | 27 |
|
||||
| Bob | 25 |
|
||||
|
||||
Note: the "Tom" here is unrelated to the prior session's co-host identity error. These `caller_name` values come from the host's spoken introductions in the transcript (e.g. *"Let's talk to Tom..."*), not from voice profiling. Real Tucson listeners named Tom calling in over 8 years.
|
||||
|
||||
---
|
||||
|
||||
## Run Stats (this session)
|
||||
|
||||
### batch_process.py — first pass (519-snapshot)
|
||||
```
|
||||
=== Done ===
|
||||
processed : 449
|
||||
cached (skipped): 68
|
||||
in-progress : 2
|
||||
errors : 0
|
||||
wall time : 276.9 min
|
||||
roster : 303 unique names
|
||||
```
|
||||
|
||||
### batch_process.py — second pass (53 stragglers)
|
||||
```
|
||||
=== Done ===
|
||||
processed : 53
|
||||
cached (skipped): 519
|
||||
in-progress : 0
|
||||
errors : 0
|
||||
wall time : 56.4 min
|
||||
roster : 89 unique names (per-run overwrite, see Problems)
|
||||
```
|
||||
|
||||
### import_to_sqlite.py — incremental (no flags)
|
||||
```
|
||||
Found 572 complete episode directories
|
||||
=== Done in 3.4s ===
|
||||
inserted : 364
|
||||
updated : 0
|
||||
skipped : 208
|
||||
errors : 0
|
||||
db : archive-data/archive.db (57.7 MB)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credentials
|
||||
|
||||
No new credentials this session. Reference from prior session log:
|
||||
|
||||
### IX Server (archive source — used yesterday, idle now)
|
||||
- **Vault path:** `infrastructure/ix-server.sops.yaml`
|
||||
- **Host:** 172.16.3.10 (Tailscale)
|
||||
- **External:** ix.azcomputerguru.com / 72.194.62.5
|
||||
- **SSH port:** 22
|
||||
- **Username:** root
|
||||
- **Password:** `Gptf*77ttb!@#!@#`
|
||||
|
||||
### Jupiter (Unraid — pending deploy target)
|
||||
- **Vault path:** `infrastructure/jupiter-unraid-primary.sops.yaml`
|
||||
- Container setup not yet started.
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure & Paths
|
||||
|
||||
| Resource | Value |
|
||||
|---|---|
|
||||
| Audio processor root | `c:\Users\guru\ClaudeTools\projects\radio-show\audio-processor\` |
|
||||
| Local DB | `archive-data/archive.db` (57.7 MB) |
|
||||
| Per-episode outputs | `archive-data/transcripts/<year>/.../<stem>/{transcript,diarization,intros,qa}.json` + `transcript.{txt,srt}` |
|
||||
| MP3s | `archive-data/episodes/<year>/...` (572 files / ~7.5 GB) |
|
||||
| Background logs | `logs/{download,batch_process}.log` |
|
||||
| Planned Jupiter mount | `/mnt/user/appdata/radio-archive/` |
|
||||
|
||||
---
|
||||
|
||||
## Commands Run
|
||||
|
||||
```bash
|
||||
# Second batch pass (after first 519-snapshot completed)
|
||||
.venv/Scripts/python.exe batch_process.py >> logs/batch_process.log 2>&1
|
||||
|
||||
# Final incremental import to bring DB to 572 episodes
|
||||
.venv/Scripts/python.exe import_to_sqlite.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pending / Next Up
|
||||
|
||||
1. **Stand up the Jupiter Docker container** (the only remaining work item from the original plan):
|
||||
- Create `/mnt/user/appdata/radio-archive/` on Jupiter
|
||||
- Container: FastAPI + sqlite (~50 lines), read-only mount of `archive.db`
|
||||
- Bind only on Tailscale interface, not the public IP
|
||||
- Deploy step: rsync `archive.db` from GURU-BEAST-ROG → Jupiter
|
||||
2. **(Followup) Fix the intro_roster.json aggregation bug** — load+merge existing roster on startup, or drop the aggregate JSON and use `SELECT DISTINCT name FROM intros` instead.
|
||||
3. **(Future, deferred from prior session)** Build voice profiles for Randall, Rob, and named producers (Andrew/Shannon/Ken) so non-Mike-non-Tara voices stop being labeled CALLER and inflating Q&A false positives in early-years and 2018/2019 episodes. The current 1,407 Q&A pairs include some unknown number of these false positives.
|
||||
4. **(Future) Speaker-name resolution view** — once query patterns emerge, decide whether to materialize a SQL view that joins `turns` (role labels) ↔ `intros` (real names) by time-window.
|
||||
|
||||
---
|
||||
|
||||
## Reference Information
|
||||
|
||||
- **Re-run the importer any time** new episodes are processed: `.venv/Scripts/python.exe import_to_sqlite.py`. Idempotent. Skip-by-sha256 means only changed/new episodes do real work. ~50 ms per skip-decision, ~5 ms per insert.
|
||||
- **Full rebuild:** `import_to_sqlite.py --rebuild` (drops and recreates the DB; ~3 seconds for the current 572-episode dataset).
|
||||
- **Schema and triggers** are in `import_to_sqlite.py` itself — `SCHEMA` and `TRIGGERS` constants at top of file.
|
||||
- **FTS query examples (verified working):**
|
||||
- `SELECT e.title, snippet(segments_fts, 0, '[', ']', '...', 12) FROM segments_fts JOIN segments s ON s.id=segments_fts.rowid JOIN episodes e ON e.id=s.episode_id WHERE segments_fts MATCH 'wireless'`
|
||||
- Same shape for `qa_fts` against `qa_pairs`.
|
||||
|
||||
---
|
||||
|
||||
## Update: 06:02
|
||||
|
||||
Continuation later the same day — the deferred Jupiter Docker container was built, deployed, and verified end-to-end.
|
||||
|
||||
### Update Summary
|
||||
|
||||
A small FastAPI + SQLite query server was built in `projects/radio-show/audio-processor/server/` and deployed to Jupiter (Unraid) as a Docker container. The container is bound to `172.16.3.20:8765`, runs with `restart: unless-stopped`, and serves a read-only mount of `archive.db`. End-to-end verification from `GURU-BEAST-ROG` over the LAN confirmed `GET /` (HTML index, 200 in 111 ms, 3,647 bytes), `/api/stats` (correct counts), `/api/search?q=BIOS&kind=qa` (Pete 2012-09-22 + Frank 2011-09-24, bm25-ranked, snippets with `<mark>` highlighting), and clean uvicorn logs.
|
||||
|
||||
The HTML index page uses fetch + vanilla JS with debounced search (250 ms) and a kind filter (both / Q&A only / transcript only). No build step. The SQLite connection uses `mode=ro` URI and `check_same_thread=False` so future multi-worker uvicorn deployments can share the connection safely. Source committed at `71ada13` on `main` after rebasing over an intervening commit (`b3da922`) from another machine.
|
||||
|
||||
Total work item is closed: the original architectural plan (sqlite-first, Jupiter Docker, internal-only) is fully realized and reachable at **http://172.16.3.20:8765/**.
|
||||
|
||||
### Key Decisions (this update)
|
||||
|
||||
- **LAN-only re-framing**: Jupiter is NOT on Tailscale (no daemon, not in tailnet status). The original "Tailscale-only" framing was tightened to "LAN-only via 172.16.3.0/22". Effective protection is the same — no public exposure — and any user on the office LAN or with subnet routing through Tailscale can reach the service.
|
||||
- **Explicit host bind `172.16.3.20:8765:8765`** in compose.yml instead of `0.0.0.0:8765:8765` or `8765:8765`. Pinning to a specific host IP eliminates accidental exposure if Jupiter ever gets a second IP (e.g. an Tailscale interface added later).
|
||||
- **Single-file HTML, no build step**: index served as a 3.6 KB string from a `@app.get("/")` handler. Trades polish for zero deploy complexity. If the UI grows, swap in a `static/` mount and a real frontend.
|
||||
- **`mode=ro` + `check_same_thread=False`**: read-only is enforced at the SQLite-URI layer (immune to a future bug in app-level read-only checks). `check_same_thread=False` is safe with `mode=ro` and lets uvicorn's worker model share connections without per-request reconnects.
|
||||
- **Build on Jupiter, not locally**: avoids `docker save | ssh ... docker load` round-trip and the cross-platform image-format edge cases. Only source files crossed the wire.
|
||||
|
||||
### Problems Encountered (this update)
|
||||
|
||||
- **No new problems**. Smoke test was clean; deploy was clean. Push hit the gitea pre-receive once because Howard/another machine had landed `b3da922` in the meantime — resolved with a clean `git pull --rebase` (single commit, no conflicts) and a re-push.
|
||||
|
||||
### Files Created (this update)
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `projects/radio-show/audio-processor/server/main.py` | FastAPI app — endpoints + HTML index |
|
||||
| `projects/radio-show/audio-processor/server/requirements.txt` | `fastapi==0.115.6`, `uvicorn[standard]==0.34.0` |
|
||||
| `projects/radio-show/audio-processor/server/Dockerfile` | python:3.12-slim base, pip install, copy main.py, default `python -u main.py` |
|
||||
| `projects/radio-show/audio-processor/server/compose.yml` | Service `radio-archive`, bind `172.16.3.20:8765`, mount `/mnt/user/appdata/radio-archive/data:/data:ro`, `restart: unless-stopped` |
|
||||
|
||||
### Endpoints (live on http://172.16.3.20:8765/)
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/` | HTML search UI (debounced, kind filter, snippet highlighting) |
|
||||
| GET | `/api/stats` | Counts per table + episodes/hours per year |
|
||||
| GET | `/api/episodes?year=YYYY&limit=N` | List, ordered by air_date then title |
|
||||
| GET | `/api/episodes/{id}` | Episode meta + intros + qa_pairs |
|
||||
| GET | `/api/episodes/{id}/transcript` | Segments + diarization turns (chronological) |
|
||||
| GET | `/api/search?q=...&kind=both\|segments\|qa&limit=N` | FTS5, bm25-ranked, snippet-formatted with `<mark>` |
|
||||
| GET | `/api/callers?limit=N` | Top recurring caller_names |
|
||||
|
||||
### Credentials (used this update)
|
||||
|
||||
#### Jupiter (Unraid Primary)
|
||||
- **Vault path:** `infrastructure/jupiter-unraid-primary.sops.yaml`
|
||||
- **Host:** 172.16.3.20 (LAN; office network 172.16.3.0/22)
|
||||
- **SSH port:** 22
|
||||
- **Username:** root
|
||||
- **Password:** `Th1nk3r^99##`
|
||||
- **iDRAC IP:** 172.16.1.73
|
||||
- **iDRAC user / pass:** root / `Window123!@#-idrac`
|
||||
- **OS / Docker:** Linux Jupiter 6.12.54-Unraid; Docker 27.5.1
|
||||
- **Notes:** Primary container host (Gitea, NPM, Seafile + many others). Tailscale daemon NOT installed on this host.
|
||||
|
||||
### Infrastructure & Paths (Jupiter)
|
||||
|
||||
| Resource | Value |
|
||||
|---|---|
|
||||
| App source dir on Jupiter | `/mnt/user/appdata/radio-archive/app/` |
|
||||
| DB dir on Jupiter | `/mnt/user/appdata/radio-archive/data/` |
|
||||
| DB file on Jupiter | `/mnt/user/appdata/radio-archive/data/archive.db` (60,465,152 bytes) |
|
||||
| Image | `radio-archive:latest` (sha256:9a04d06ba2c1…) |
|
||||
| Container name | `radio-archive` |
|
||||
| Bind | `172.16.3.20:8765 -> 8765/tcp` |
|
||||
| Restart policy | `unless-stopped` |
|
||||
| Service URL | http://172.16.3.20:8765/ |
|
||||
|
||||
### Commands Run
|
||||
|
||||
```bash
|
||||
# SSH (PuTTY plink — system OpenSSH password auth doesn't work without sshpass)
|
||||
echo y | "/c/Program Files/PuTTY/plink.exe" -ssh -pw '<jupiter pw>' root@172.16.3.20 "<cmd>"
|
||||
|
||||
# File transfer (PuTTY pscp)
|
||||
"/c/Program Files/PuTTY/pscp.exe" -batch -pw '<jupiter pw>' -scp \
|
||||
server/main.py server/requirements.txt server/Dockerfile server/compose.yml \
|
||||
root@172.16.3.20:/mnt/user/appdata/radio-archive/app/
|
||||
|
||||
"/c/Program Files/PuTTY/pscp.exe" -batch -pw '<jupiter pw>' -scp \
|
||||
archive-data/archive.db \
|
||||
root@172.16.3.20:/mnt/user/appdata/radio-archive/data/archive.db
|
||||
|
||||
# Build + run (on Jupiter via SSH)
|
||||
cd /mnt/user/appdata/radio-archive/app && docker compose build # ~70s
|
||||
cd /mnt/user/appdata/radio-archive/app && docker compose up -d # ~5s
|
||||
|
||||
# Verify (from GURU-BEAST-ROG)
|
||||
curl -s http://172.16.3.20:8765/api/stats
|
||||
curl -s "http://172.16.3.20:8765/api/search?q=BIOS&kind=qa"
|
||||
```
|
||||
|
||||
### Re-deploy Procedure (when DB updates)
|
||||
|
||||
```bash
|
||||
# 1. Regenerate DB on GURU-BEAST-ROG
|
||||
cd /c/Users/guru/ClaudeTools/projects/radio-show/audio-processor
|
||||
.venv/Scripts/python.exe import_to_sqlite.py # incremental
|
||||
# or for full rebuild:
|
||||
.venv/Scripts/python.exe import_to_sqlite.py --rebuild
|
||||
|
||||
# 2. Ship to Jupiter
|
||||
"/c/Program Files/PuTTY/pscp.exe" -pw '<pw>' -scp archive-data/archive.db \
|
||||
root@172.16.3.20:/mnt/user/appdata/radio-archive/data/archive.db
|
||||
|
||||
# 3. No container restart needed — read-only connection picks up fresh data on next request
|
||||
```
|
||||
|
||||
### Re-deploy Procedure (when source changes)
|
||||
|
||||
```bash
|
||||
# 1. ship updated source
|
||||
"/c/Program Files/PuTTY/pscp.exe" -pw '<pw>' -scp server/*.py server/Dockerfile \
|
||||
root@172.16.3.20:/mnt/user/appdata/radio-archive/app/
|
||||
|
||||
# 2. rebuild + restart on Jupiter
|
||||
echo y | "/c/Program Files/PuTTY/plink.exe" -ssh -pw '<pw>' root@172.16.3.20 \
|
||||
"cd /mnt/user/appdata/radio-archive/app && docker compose build && docker compose up -d"
|
||||
```
|
||||
|
||||
### Pending / Next Up (refreshed)
|
||||
|
||||
1. ~~**Stand up the Jupiter Docker container**~~ DONE (this update).
|
||||
2. **(Followup)** Fix the intro_roster.json aggregation bug — load+merge or drop-and-use-DB.
|
||||
3. **(Future)** Voice profiles for Randall, Rob, and producers (Andrew/Shannon/Ken). Currently every non-Mike-non-Tara voice falls into the CALLER bucket, inflating Q&A false positives in early-years and 2018+ episodes.
|
||||
4. **(Future)** Speaker-name resolution view — defer until query patterns emerge.
|
||||
5. **(Optional)** Add Tailscale to Jupiter (subnet router for 172.16.3.0/22, or direct daemon for `ts-` hostname access). Only needed if remote, off-LAN access becomes a requirement.
|
||||
@@ -1,145 +0,0 @@
|
||||
# Session Log — 2026-04-29 — Q/A Quality Classifier (Track 1)
|
||||
|
||||
**Project:** The Computer Guru Show — Archive Mining System
|
||||
**Goal:** Add LLM-based usefulness scoring to all 1,407 existing Q/A pairs so search can filter out banter/promo/off-topic without losing real listener questions
|
||||
**Machine:** GURU-BEAST-ROG (RTX 4090)
|
||||
**User:** Mike Swanson (mike)
|
||||
|
||||
---
|
||||
|
||||
## Session Summary
|
||||
|
||||
Mike asked whether voice profiles should be built for recurring guests/co-hosts to fix Q/A pair quality. The conversation pivoted to a more direct goal: usefulness of Q/A search results regardless of speaker role. A producer asking a real Windows question is useful Q&A; the same producer joking with the host is banter. The transcript text is rich enough to classify with an LLM.
|
||||
|
||||
Track 1 (LLM-based usefulness classifier) was delegated to the Coding Agent. The agent designed and wrote three deliverables: schema migration in `import_to_sqlite.py`, a new `classify_qa_quality.py` script, and `min_score` + `exclude_banter` query params on `/api/search`. The agent's full classifier run was kicked off but stalled at 0/1407 — the agent's Bash background subprocess timed out before the classifier could make progress.
|
||||
|
||||
Relaunched the classifier as a detached PowerShell process (PID 29796) so it would survive Bash subshell timeouts. Polled progress at 25-30 min intervals via `ScheduleWakeup`. The full run took **3.5 hours** for 1,407 rows on qwen3:14b — ~6.7 rows/min steady throughput. Final result: **1,405 succeeded, 2 failed (0.14%)**.
|
||||
|
||||
Commit `4268890` pushed to `main`. Jupiter Docker container has **NOT** been redeployed yet — Mike does that manually after reviewing.
|
||||
|
||||
---
|
||||
|
||||
## Final Distribution
|
||||
|
||||
```
|
||||
SCORE COUNT SHARE
|
||||
5 362 25.8% ← clear computer help
|
||||
4 161 11.5%
|
||||
3 309 22.0% ← borderline / general advice
|
||||
2 257 18.3%
|
||||
1 316 22.5% ← pure banter / promo
|
||||
|
||||
useful (4-5): 523 (37.3%)
|
||||
noise (1-2): 573 (40.8%)
|
||||
|
||||
TOPIC_CLASS:
|
||||
computer-help 928 66.0%
|
||||
banter 248 17.7%
|
||||
off-topic 147 10.5%
|
||||
promo 78 5.6%
|
||||
unclear 4 0.3%
|
||||
|
||||
is_banter=True: 606 (43.1%)
|
||||
```
|
||||
|
||||
About 40% of existing Q/A pairs are noise that can be filtered out at search time without losing any real listener questions.
|
||||
|
||||
---
|
||||
|
||||
## Spot Check (manual review of 6 rows)
|
||||
|
||||
**Score 1, banter (correctly flagged):**
|
||||
- "We've got some options... we're open until 6 today and Monday" — business-hours chitchat
|
||||
- "How are you? Why would you unfriend some people?" — Facebook social talk
|
||||
- "I've been looking for something... eradic[ate]..." — fragmentary
|
||||
|
||||
**Score 5, computer-help (correctly flagged):**
|
||||
- "It's all dependent on the actual hardware, right? The actual TV..." — TV/hardware Q
|
||||
- "But, you know, if you put it on a drive render, can you get my data off..." — data recovery
|
||||
- "AZ Computer Guru on there also..." — antivirus/Avast advice
|
||||
|
||||
Classifier output matches human judgment on these samples.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Deferred Track 2 (voice profile clustering)** — content classifier has higher leverage for search quality and is independent of voice work. Voice profiles still useful but lower priority.
|
||||
- **Detached classifier process** instead of agent-managed Bash background — Bash tool timeout (10 min) was killing the long-running classifier. PowerShell `Start-Process` with redirected output runs to completion regardless of session state.
|
||||
- **Default `min_score=0`** (no filter) on `/api/search` — keeps existing search clients working unchanged. UI can opt into `min_score=3` or `min_score=4` when ready.
|
||||
- **NULL handling** in the WHERE clause: `(usefulness_score IS NULL OR usefulness_score >= :min_score)` — unprocessed rows stay visible during incremental rollout.
|
||||
|
||||
---
|
||||
|
||||
## Problems Encountered
|
||||
|
||||
- **Coding Agent's classifier run stalled** — kicked off via Bash run_in_background, hit the 10-min Bash tool timeout, never made progress. Agent went idle waiting for a completion notification that wasn't coming. Resolved by relaunching the script as a detached PowerShell process and polling DB progress via scheduled wakeups.
|
||||
- **rich.Progress doesn't write clean log lines** — the per-batch progress UI uses ANSI escapes; the classify.log was sparse but the script DID write a clean final summary table on completion (visible via `Get-Content -Tail 20`). DB count poll was the reliable progress signal.
|
||||
- **2 rows failed classification** — the script logged them and skipped (left as NULL). Re-running `classify_qa_quality.py` (idempotent) will retry just those 2 rows.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `projects/radio-show/audio-processor/classify_qa_quality.py` | NEW — 19 KB Ollama-based classifier with --smoke / --rebuild / --limit flags, batch-25 commits |
|
||||
| `projects/radio-show/audio-processor/server/main.py` | +35 / -19 — added `min_score` and `exclude_banter` to /api/search; new fields in episode detail and search response |
|
||||
| `projects/radio-show/audio-processor/import_to_sqlite.py` | (per Coding Agent's diff — schema migration adds 3 columns to qa_pairs) |
|
||||
| `projects/radio-show/audio-processor/archive-data/archive.db` | Updated with all 1,405 classifications (NOT in git, ships separately to Jupiter) |
|
||||
| `projects/radio-show/audio-processor/logs/classify.log` | Final run output (rich progress + summary table) |
|
||||
|
||||
---
|
||||
|
||||
## Commands Used
|
||||
|
||||
```powershell
|
||||
# Detached classifier launch
|
||||
Start-Process -FilePath .venv\Scripts\python.exe `
|
||||
-ArgumentList "-u", "classify_qa_quality.py" `
|
||||
-WorkingDirectory <path> `
|
||||
-RedirectStandardOutput logs\classify.log `
|
||||
-RedirectStandardError logs\classify.err `
|
||||
-WindowStyle Hidden -PassThru
|
||||
```
|
||||
|
||||
```bash
|
||||
# Progress poll
|
||||
.venv/Scripts/python.exe -c "
|
||||
import sqlite3
|
||||
db = sqlite3.connect('archive-data/archive.db')
|
||||
print(db.execute('SELECT COUNT(*) FROM qa_pairs WHERE usefulness_score IS NOT NULL').fetchone())
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pending / Next
|
||||
|
||||
1. **Deploy to Jupiter** (manual — Mike's call):
|
||||
- `pscp archive-data/archive.db root@172.16.3.20:/mnt/user/appdata/radio-archive/data/archive.db`
|
||||
- `pscp server/main.py root@172.16.3.20:/mnt/user/appdata/radio-archive/app/`
|
||||
- `ssh root@172.16.3.20 "cd /mnt/user/appdata/radio-archive/app && docker compose build && docker compose up -d"`
|
||||
2. **UI update** (separate task) — the HTML index in `server/main.py` doesn't yet show usefulness_score badges or expose the `min_score` filter as a toggle. Backend is ready; UI work is next when desired.
|
||||
3. **Re-run the 2 failed rows** (one-liner: `classify_qa_quality.py` will retry NULLs automatically next invocation).
|
||||
4. **Track 2 (voice profile clustering)** — still deferred. Lower priority now that content quality is solved.
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
- **Search with quality filter:** `curl 'http://172.16.3.20:8765/api/search?q=BIOS&kind=qa&min_score=4'`
|
||||
- **Without banter:** `curl 'http://172.16.3.20:8765/api/search?q=ssd&kind=qa&exclude_banter=true'`
|
||||
- **Default behavior unchanged** when `min_score=0` (the default).
|
||||
- **Re-classify:** `classify_qa_quality.py --rebuild` reprocesses everything (don't run unless prompt changes).
|
||||
- **Spot check:** `classify_qa_quality.py --smoke` runs 10 sample rows and prints classifications without writing to DB.
|
||||
|
||||
---
|
||||
|
||||
## Status at session end
|
||||
|
||||
- Classifier: COMPLETE (1,405/1,407, 2 failed → NULL → will retry on next invocation)
|
||||
- API: read-ready (uvicorn imports cleanly, all routes intact)
|
||||
- DB: updated locally with all classifications
|
||||
- Git: commit `4268890` pushed to main
|
||||
- Jupiter: NOT redeployed (manual step pending Mike's review)
|
||||
@@ -1,358 +0,0 @@
|
||||
# Session Log — 2026-04-30 — Portable Laptop Bundle + /api/db.sqlite Deploy
|
||||
|
||||
**Project:** The Computer Guru Show — Archive Mining System
|
||||
**Goal:** Make the search service usable from a laptop next week, including offline; ship it as a separate repo and add a DB-fetch endpoint to the upstream container
|
||||
**Machine:** GURU-BEAST-ROG (RTX 4090)
|
||||
**User:** Mike Swanson (mike)
|
||||
**Continues from:** `2026-04-29-qa-quality-classifier.md` (which covered the 3.5h qwen3:14b classifier run that produced the 1,405-row scored DB)
|
||||
|
||||
---
|
||||
|
||||
## User
|
||||
- **User:** Mike Swanson (mike)
|
||||
- **Machine:** GURU-BEAST-ROG
|
||||
- **Role:** admin
|
||||
|
||||
---
|
||||
|
||||
## Session Summary
|
||||
|
||||
The radio-archive search service needed to become portable so Mike could use it from a laptop next week, including offline scenarios on a plane or in a conference room. Three options were proposed: (1) install Tailscale on Jupiter, (2) use existing Tailscale subnet routing on the office router, (3) ship a self-contained laptop copy. Mike clarified Tailscale was already running on the office router covering Jupiter's subnet, then asked to "box up the offline version" for the Dell 5070 — its own repo if needed.
|
||||
|
||||
Verified Tailscale state from `tailscale status --json` — pfsense-2 (`100.119.153.74`) advertises `172.16.0.0/22` as PRIMARY ROUTE, mike's macbook-air and acg-guru-5070 are both existing tailnet members. No subnet configuration changes needed. The existing container's bind to `172.16.3.20:8765` already accepts subnet-routed traffic without modification.
|
||||
|
||||
Built a new private Gitea repo `azcomputerguru/radio-archive-portable` with eight files: server code (identical to upstream), a `sync-db.sh` that curl-fetches `archive.db` from a new `/api/db.sqlite` endpoint, a `run.sh` that creates a venv on first invocation and starts uvicorn on `localhost:8765`, plus README, .env.example, .gitignore, archive-data placeholder. The DB itself is gitignored (60 MB; fetched on demand, never committed). Repo created via Gitea API, initial commit pushed.
|
||||
|
||||
Added the `/api/db.sqlite` endpoint to the upstream `server/main.py` using FastAPI `FileResponse`. Disclosure equivalence: anyone who can reach `/api/search` already has full transcript access, so exposing the SQLite blob adds nothing meaningful. This avoided needing SSH keys or stored credentials on the laptop side. Deployed to Jupiter (pscp'd `main.py` + classified `archive.db`, then `docker compose up -d --build`). Verified end-to-end: `GET /api/db.sqlite` returns 200 with 60,583,936 bytes; the fetched DB contains all 1,405 classifier rows intact; `GET /api/search?min_score=4` filters correctly with the new fields in the response.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- **Subnet routing already in place** — confirmed via `tailscale status --json` that pfsense-2 advertises `172.16.0.0/22` as primary route. No new daemons or routing changes required. Container bind to `172.16.3.20:8765` is sufficient because Tailscale traffic destined for that IP arrives via the router's LAN egress and hits the existing listener.
|
||||
- **`/api/db.sqlite` over HTTP instead of SSH/SCP for the DB sync** — keeps everything on the same Tailscale-routed port, no SSH key management, no stored passwords on the laptop. Disclosure equivalence with `/api/search` (which already returns every transcript) means no auth was added to either.
|
||||
- **Separate repo for the portable bundle** — keeps the laptop install-flow simple (clone + run two scripts) and avoids cloning the 100+ GB ClaudeTools monorepo on a travel laptop. Repo lives at `git.azcomputerguru.com/azcomputerguru/radio-archive-portable` (private, under the user namespace).
|
||||
- **DB excluded from the repo via gitignore** — the 60 MB blob is fetched via `sync-db.sh` on first run. Repo stays at ~15 KB. The fetch is idempotent and atomic (download to `.partial`, validate size, rename into place).
|
||||
- **Used `docker compose up -d --build` (combined) instead of separate `build` then `up`** — separate commands chained through plink either silently buffered or failed to trigger a rebuild on a previous attempt; container kept running 2-hour-old code. Combined form was reliable.
|
||||
- **Stripped API token from `.git/config` after push** — token had been embedded in the origin URL for the initial push; replaced with the bare HTTPS URL afterward so it doesn't sit in plain text. Future pushes will go via Gitea credential helper or interactive prompt.
|
||||
|
||||
---
|
||||
|
||||
## Problems Encountered
|
||||
|
||||
- **First deploy attempt landed but rebuild didn't happen** — chained `docker compose build && docker compose up -d` via plink completed exit-code-0 but the container kept running yesterday's code (verified via `docker exec radio-archive grep db.sqlite /app/main.py` returning nothing). Likely BuildKit output buffering or plink session quirks. Resolved by using `docker compose up -d --build` as a single foreground command.
|
||||
- **Bash background-task output capture flaky on long plink runs** — early deploy attempts went into the Bash tool's `run_in_background` mode but the output file stayed empty for minutes despite the underlying SSH session completing. Worked around by running shorter commands synchronously.
|
||||
- **`/tmp` path clash between git-bash and Windows Python** — a smoke-test command tried to fetch the DB via curl (using `/tmp/test-db.sqlite`) and then read it with `python -c` (also writing `/tmp/...`). Different tools resolved `/tmp` differently on Windows. Switched to a project-local `test-fetched.db` path to avoid the issue.
|
||||
- **Gitea API at `/api/v1/orgs/azcomputerguru/repos` returned 404** — `azcomputerguru` is a USER, not an org. Repo creation succeeded via `/api/v1/user/repos` instead. (The token's owner is `azcomputerguru`, so user-namespace creation worked.)
|
||||
- **`HEAD /api/db.sqlite` returns 405 Method Not Allowed** — FastAPI's default routing only registers GET. A `HEAD` is fine to fail because the sync script uses `GET`. Documented behavior, not a bug.
|
||||
|
||||
---
|
||||
|
||||
## Credentials Used
|
||||
|
||||
### Jupiter (Unraid Primary)
|
||||
- **Vault path:** `infrastructure/jupiter-unraid-primary.sops.yaml`
|
||||
- **Host:** 172.16.3.20
|
||||
- **User:** root
|
||||
- **Password:** `Th1nk3r^99##`
|
||||
- **iDRAC IP:** 172.16.1.73 / root / `Window123!@#-idrac`
|
||||
|
||||
### Gitea
|
||||
- **Vault path:** `services/gitea.sops.yaml`
|
||||
- **URL:** https://git.azcomputerguru.com
|
||||
- **Username:** `azcomputerguru`
|
||||
- **Password:** `Gptf*77ttb123!@#-git` (alt: `Window123!@#-git`)
|
||||
- **API token (used this session):** `9b1da4b79a38ef782268341d25a4b6880572063f`
|
||||
- **SSH:** `ssh://git@172.16.3.20:2222`
|
||||
|
||||
### New Repo
|
||||
- **Clone URL:** https://git.azcomputerguru.com/azcomputerguru/radio-archive-portable.git
|
||||
- **SSH URL:** `git@172.16.3.21:azcomputerguru/radio-archive-portable.git`
|
||||
- **Visibility:** private
|
||||
- **Default branch:** main
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Touched
|
||||
|
||||
| Host | IP | Role | Action |
|
||||
|---|---|---|---|
|
||||
| Jupiter (Unraid Primary) | 172.16.3.20 | Hypervisor + Docker host | pscp'd updated `main.py` + `archive.db`; `docker compose up -d --build` |
|
||||
| Radio-archive container | container on Jupiter, bind `172.16.3.20:8765` | FastAPI + SQLite | Rebuilt with new endpoint; restarted with classifier-populated DB |
|
||||
| Gitea (on Jupiter, port 3000) | git.azcomputerguru.com | Source hosting | New repo created via API |
|
||||
| pfsense-2 router | (Tailscale `100.119.153.74`) | Subnet router | No changes — verified existing 172.16.0.0/22 advertisement |
|
||||
|
||||
### Tailscale state at session time
|
||||
|
||||
```
|
||||
100.101.122.4 guru-beast-rog (this machine, online)
|
||||
100.65.158.123 mikes-macbook-air (last seen 4m before check)
|
||||
100.95.216.79 acg-guru-5070 (offline 30d ago — boot it up next week)
|
||||
100.119.153.74 pfsense-2 (active; advertises 172.16.0.0/22 as PRIMARY)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Created / Modified
|
||||
|
||||
### New repo: `radio-archive-portable/`
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `README.md` | Quick-start, refresh procedure, architecture diagram |
|
||||
| `server/main.py` | Identical to deployed upstream (with `/api/db.sqlite`) |
|
||||
| `server/requirements.txt` | `fastapi==0.115.6`, `uvicorn[standard]==0.34.0` |
|
||||
| `sync-db.sh` | `curl -fSL -o archive-data/archive.db.partial $URL && mv` (atomic) |
|
||||
| `run.sh` | Creates `.venv` on first run, then `uvicorn server.main:app --host 127.0.0.1 --port 8765` |
|
||||
| `.env.example` | `ARCHIVE_HOST=172.16.3.20:8765`, `ARCHIVE_DB=archive-data/archive.db`, `PORT=8765` |
|
||||
| `.gitignore` | Excludes `archive-data/archive.db`, `.venv/`, `.env`, etc. |
|
||||
| `archive-data/.gitkeep` | Placeholder so the dir exists in git but the DB file doesn't |
|
||||
|
||||
### ClaudeTools (upstream)
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `projects/radio-show/audio-processor/server/main.py` | +18 / -1 — added `from fastapi.responses import FileResponse` and the `/api/db.sqlite` GET endpoint |
|
||||
|
||||
### Jupiter (deployed state)
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `/mnt/user/appdata/radio-archive/app/main.py` | Replaced (now matches `5e3b1a2`) |
|
||||
| `/mnt/user/appdata/radio-archive/data/archive.db` | Replaced with classifier-populated copy (60,583,936 bytes, 1,405/1,407 scored) |
|
||||
| Container `radio-archive` | Rebuilt to image `radio-archive:latest` (`sha256:dbb5ad62bdb1...`), running |
|
||||
|
||||
---
|
||||
|
||||
## Commands Run
|
||||
|
||||
### Tailscale verification (local)
|
||||
```bash
|
||||
tailscale status --json | grep -E "advertis|route|172\.|primary"
|
||||
# Confirmed 172.16.0.0/22 listed under PrimaryRoutes
|
||||
```
|
||||
|
||||
### New repo creation
|
||||
```bash
|
||||
curl -X POST "https://git.azcomputerguru.com/api/v1/user/repos" \
|
||||
-H "Authorization: token 9b1da4b79a38ef782268341d25a4b6880572063f" \
|
||||
-d '{"name":"radio-archive-portable","private":true,"default_branch":"main"}'
|
||||
# HTTP 201, repo id 12
|
||||
|
||||
cd /c/Users/guru/radio-archive-portable
|
||||
git init -b main
|
||||
git config user.name "Mike Swanson"
|
||||
git config user.email "mike@azcomputerguru.com"
|
||||
git add -A && git commit
|
||||
git remote add origin https://azcomputerguru:<token>@git.azcomputerguru.com/azcomputerguru/radio-archive-portable.git
|
||||
git push -u origin main
|
||||
git remote set-url origin https://git.azcomputerguru.com/azcomputerguru/radio-archive-portable.git # strip token
|
||||
```
|
||||
|
||||
### Jupiter deploy
|
||||
```bash
|
||||
"/c/Program Files/PuTTY/pscp.exe" -batch -pw "$PW" -scp \
|
||||
c:/Users/guru/ClaudeTools/projects/radio-show/audio-processor/server/main.py \
|
||||
root@172.16.3.20:/mnt/user/appdata/radio-archive/app/main.py
|
||||
|
||||
"/c/Program Files/PuTTY/pscp.exe" -batch -pw "$PW" -scp \
|
||||
c:/Users/guru/ClaudeTools/projects/radio-show/audio-processor/archive-data/archive.db \
|
||||
root@172.16.3.20:/mnt/user/appdata/radio-archive/data/archive.db
|
||||
# 60.5 MB at ~580 KB/s = ~100 seconds
|
||||
|
||||
"/c/Program Files/PuTTY/plink.exe" -batch -ssh -pw "$PW" root@172.16.3.20 \
|
||||
"cd /mnt/user/appdata/radio-archive/app && docker compose up -d --build"
|
||||
# Built radio-archive:latest sha256:dbb5ad62bdb1..., container Running
|
||||
```
|
||||
|
||||
### Live verification
|
||||
```bash
|
||||
curl -sS http://172.16.3.20:8765/api/stats
|
||||
# {"counts":{"episodes":572,"segments":60917,...},"by_year":[{"year":2010,...
|
||||
|
||||
curl -sS -o test-fetched.db -w "HTTP %{http_code} | dl=%{size_download}B\n" \
|
||||
http://172.16.3.20:8765/api/db.sqlite
|
||||
# HTTP 200 | dl=60583936B
|
||||
|
||||
.venv/Scripts/python.exe -c "
|
||||
import sqlite3
|
||||
db = sqlite3.connect('test-fetched.db')
|
||||
print(db.execute('SELECT COUNT(*) FROM qa_pairs WHERE usefulness_score IS NOT NULL').fetchone())
|
||||
"
|
||||
# (1405,)
|
||||
|
||||
curl -sS 'http://172.16.3.20:8765/api/search?q=BIOS&kind=qa&min_score=4&limit=2'
|
||||
# returns 2 hits, each with usefulness_score=5, topic_class='computer-help'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pending / Next
|
||||
|
||||
1. **Test the laptop install end-to-end** when the 5070 boots up next week — confirm sync-db.sh + run.sh work cleanly on Linux. Currently untested on the actual target machine.
|
||||
2. **HTML index UI update** — backend supports `min_score` and `exclude_banter` query params, but the search UI on `/` doesn't expose them as toggles or show the score/topic_class on each hit. Backend is ready when the UI is.
|
||||
3. **Re-run the 2 failed classifier rows** — `classify_qa_quality.py` re-invocation will retry the NULL-scored rows; one-line cleanup.
|
||||
4. **Track 2 (voice profile clustering)** — still deferred. Lower priority since content-quality filter solved most of the search-quality problem.
|
||||
5. **Track 3 (speaker oracle wiring through to search UI)** — still deferred. `speaker_oracle.py` resolves names from intros but the search results still show "CALLER" rather than the resolved name.
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
### Endpoints (all live on http://172.16.3.20:8765/ as of this commit)
|
||||
|
||||
| Method | Path | Notes |
|
||||
|---|---|---|
|
||||
| GET | `/` | Search UI (no min_score toggle yet — query string works manually) |
|
||||
| GET | `/api/stats` | Counts and per-year breakdown |
|
||||
| GET | `/api/episodes?year=YYYY&limit=N` | Episode list |
|
||||
| GET | `/api/episodes/{id}` | Detail with intros + qa_pairs (now includes usefulness_score, topic_class, is_banter) |
|
||||
| GET | `/api/episodes/{id}/transcript` | Chronological merged segments + turns |
|
||||
| GET | `/api/search?q=...&kind=both\|segments\|qa&min_score=N&exclude_banter=true&limit=N` | FTS5 |
|
||||
| GET | `/api/callers?limit=N` | Top recurring caller_names |
|
||||
| GET | `/api/db.sqlite` | **NEW** — streams the read-only DB blob (60 MB) |
|
||||
|
||||
### Laptop next-week recipe (5070 / Linux)
|
||||
|
||||
```bash
|
||||
# Tailscale already enabled on the laptop and on pfsense-2
|
||||
git clone https://git.azcomputerguru.com/azcomputerguru/radio-archive-portable.git
|
||||
cd radio-archive-portable
|
||||
./sync-db.sh # pulls from 172.16.3.20:8765/api/db.sqlite
|
||||
./run.sh # creates .venv, starts uvicorn on localhost:8765
|
||||
xdg-open http://localhost:8765/
|
||||
```
|
||||
|
||||
Refreshing: `./sync-db.sh` any time. Atomic — partial download won't corrupt existing DB.
|
||||
|
||||
### macOS variant (mikes-macbook-air, if used)
|
||||
Same recipe. `python3 -m venv` works on Mac. `xdg-open` → `open`.
|
||||
|
||||
### Jupiter redeploy procedure (when source or DB changes)
|
||||
```bash
|
||||
# Source change:
|
||||
"/c/Program Files/PuTTY/pscp.exe" -pw <pw> -scp server/main.py \
|
||||
root@172.16.3.20:/mnt/user/appdata/radio-archive/app/
|
||||
"/c/Program Files/PuTTY/plink.exe" -ssh -pw <pw> root@172.16.3.20 \
|
||||
"cd /mnt/user/appdata/radio-archive/app && docker compose up -d --build"
|
||||
|
||||
# DB-only change (no container restart needed):
|
||||
"/c/Program Files/PuTTY/pscp.exe" -pw <pw> -scp archive-data/archive.db \
|
||||
root@172.16.3.20:/mnt/user/appdata/radio-archive/data/archive.db
|
||||
```
|
||||
|
||||
The SQLite connection on the container side is `mode=ro` URI — picks up fresh DB on next request without restart.
|
||||
|
||||
---
|
||||
|
||||
## Status at session end
|
||||
|
||||
- **Upstream container** rebuilt + running with `/api/db.sqlite` endpoint live
|
||||
- **Classified DB** deployed to Jupiter (1,405/1,407 scored)
|
||||
- **Portable repo** created and pushed to `git.azcomputerguru.com/azcomputerguru/radio-archive-portable`
|
||||
- **Laptop install** is a clone + 2 shell scripts; untested on the actual 5070 (will validate next week)
|
||||
- **ClaudeTools commits:** `5e3b1a2` (this session's main.py change)
|
||||
- **Untested edge cases:** offline behavior (planes, no Tailscale), curl with HTTP/2 to /api/db.sqlite (was tested with HTTP/1.1)
|
||||
|
||||
---
|
||||
|
||||
## Update: 06:05 — Index UI exposes classifier filters
|
||||
|
||||
User asked to wire the new classifier fields into the search UI. The
|
||||
backend already supported `min_score` and `exclude_banter` query params
|
||||
(commit `5e3b1a2`); this update brings them into the HTML index and adds
|
||||
visible quality indicators on Q/A hits.
|
||||
|
||||
### Update Summary
|
||||
|
||||
Edited `INDEX_HTML` in `server/main.py` to add two filter controls and
|
||||
score badges. Verified locally via `uvicorn` on `127.0.0.1:8866` against
|
||||
the classifier-populated DB (no-filter, `min_score=4`, and
|
||||
`exclude_banter=true` modes all behaved correctly). Hit an unexpected
|
||||
`No space left on device` from `pscp` despite Jupiter having 37 TB free
|
||||
on `/mnt/user`; bypassed by streaming the file through plink stdin
|
||||
(`plink ... "cat > /path" < local_file`). md5 verified byte-identical.
|
||||
Container rebuilt via `docker compose up -d --build`. Synced the same
|
||||
`main.py` to the portable repo so the laptop UI stays in sync.
|
||||
|
||||
### What changed in the UI
|
||||
|
||||
- **`min score` select** — values: any, 2+, 3+, 4+, 5. Default `any` to
|
||||
preserve old search behavior. Filters surface 1,096 mid-and-above
|
||||
pairs at `3+` or 523 useful pairs at `4+`.
|
||||
- **`hide banter` checkbox** — when checked, drops the 606 rows with
|
||||
`is_banter=1`.
|
||||
- **Score badge per Q/A hit** — small color-coded number (1=red, 5=green)
|
||||
next to each hit's metadata line. Title attribute shows
|
||||
`usefulness N/5` on hover.
|
||||
- **Topic class tag** — small gray pill showing `computer-help`,
|
||||
`banter`, `off-topic`, `promo`, or `unclear`.
|
||||
- **Dimmed rendering** — hits with score 1-2 or `is_banter=true` render
|
||||
at 55% opacity. Visible but visually de-emphasized so good hits stand
|
||||
out at a glance.
|
||||
- **`escapeHtml` helper** — defensive XSS guard on `caller_name` and
|
||||
`title` (transcript-derived strings).
|
||||
|
||||
### Key Decisions (this update)
|
||||
|
||||
- **Default filter "any"** — preserves prior search habits and saved
|
||||
URLs. Mike opts into filtering when needed rather than being forced
|
||||
into a curated view.
|
||||
- **`URLSearchParams` instead of string concat** — only emits
|
||||
`min_score=` / `exclude_banter=` when non-default, keeping URL bar
|
||||
clean for the common case.
|
||||
- **Color-coded badge with both score AND topic tag** — score is
|
||||
numeric/comparable; topic tag is categorical and explains *why* a
|
||||
score is what it is. Both together make the classifier's reasoning
|
||||
visible at a glance without forcing a click.
|
||||
- **Dim instead of hide for low-quality hits** — keeps everything
|
||||
visible by default; the filter controls are the explicit "hide" lever.
|
||||
- **Used `plink "cat > path"` instead of pscp** for the deploy when
|
||||
pscp failed — faster than diagnosing the underlying scp/shfs issue
|
||||
and gets the job done deterministically.
|
||||
|
||||
### Problems Encountered (this update)
|
||||
|
||||
- **pscp ENOSPC despite 37 TB free** — `pscp main.py` failed with
|
||||
`No space left on device` on two retries. df showed 37 TB free on
|
||||
`/mnt/user`, df -i showed inodes fine. Workaround:
|
||||
`plink ... "cat > /path/main.py" < local_main.py`. md5sum confirmed
|
||||
byte-identical post-transfer. Likely Unraid shfs cache-pool churn or
|
||||
an issue with overwriting an in-use file from inside a container's
|
||||
mount. Worth understanding eventually but didn't block the deploy.
|
||||
- **plink output buffering on chained docker commands** — long
|
||||
`docker compose up -d --build` runs hung from Bash's run_in_background
|
||||
view (output file stayed empty for minutes). Foreground sync run with
|
||||
the same command worked instantly. Same pattern observed yesterday.
|
||||
Workaround: don't background long plink runs; just block.
|
||||
|
||||
### Files Changed (this update)
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `projects/radio-show/audio-processor/server/main.py` | +51 / -4 — INDEX_HTML gained controls + badge styles + topic tag + escapeHtml + dim-class JS rendering |
|
||||
| `c:/Users/guru/radio-archive-portable/server/main.py` | Same diff, synced from upstream |
|
||||
|
||||
### Commits (this update)
|
||||
|
||||
| Repo | SHA | Branch |
|
||||
|---|---|---|
|
||||
| ClaudeTools | `b9af34f` | main |
|
||||
| radio-archive-portable | `1d6c795` | main |
|
||||
|
||||
### Live verification
|
||||
|
||||
```
|
||||
$ curl -s http://172.16.3.20:8765/ | grep -cE "min_score|exclude_banter|badge.s5|topic_class"
|
||||
10
|
||||
$ curl -so /dev/null -w "%{size_download}\n" http://172.16.3.20:8765/
|
||||
5757 # was 4040 before
|
||||
$ curl -s 'http://172.16.3.20:8765/api/search?q=BIOS&kind=qa&min_score=4&limit=2' \
|
||||
| python -c "import sys,json; d=json.load(sys.stdin); print('hits:', len(d['qa']))"
|
||||
hits: 2 # both score=5, topic_class='computer-help'
|
||||
```
|
||||
|
||||
### Status at update end
|
||||
|
||||
- UI controls live on http://172.16.3.20:8765/ and on the portable repo
|
||||
- Backend filters working (verified end-to-end)
|
||||
- Untouched: HTML still has no per-hit deep-link to `/api/episodes/{id}`
|
||||
(clicking a hit doesn't navigate). Future enhancement.
|
||||
- Pending: laptop validation (still next week's task)
|
||||
@@ -1,187 +0,0 @@
|
||||
"""Stage 6: Content analysis using Ollama for summary, topics, and post-show debrief."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpisodeAnalysis:
|
||||
summary: str
|
||||
segment_summaries: list[dict] # [{title, summary, key_points}]
|
||||
key_quotes: list[dict] # [{quote, speaker, timestamp}]
|
||||
topics: list[str]
|
||||
tags: list[str]
|
||||
blog_post_candidates: list[dict] # [{title, angle, why}]
|
||||
debrief_draft: str # Markdown debrief template
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"summary": self.summary,
|
||||
"segment_summaries": self.segment_summaries,
|
||||
"key_quotes": self.key_quotes,
|
||||
"topics": self.topics,
|
||||
"tags": self.tags,
|
||||
"blog_post_candidates": self.blog_post_candidates,
|
||||
}
|
||||
|
||||
def save(self, output_dir: Path):
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_dir / "analysis.json", "w") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
with open(output_dir / "post-show-debrief.md", "w") as f:
|
||||
f.write(self.debrief_draft)
|
||||
|
||||
console.print(f"[green]Analysis saved to {output_dir}[/green]")
|
||||
|
||||
|
||||
def analyze_episode(transcript_text: str, diarization_data: dict | None = None,
|
||||
show_prep: str | None = None, segments: list | None = None,
|
||||
model: str = "qwen3:14b",
|
||||
ollama_host: str = "http://localhost:11434") -> EpisodeAnalysis:
|
||||
"""Analyze a transcribed episode using a local LLM."""
|
||||
import ollama as ollama_client
|
||||
|
||||
console.print(f"[bold]Analyzing episode with {model}[/bold]")
|
||||
|
||||
client = ollama_client.Client(host=ollama_host)
|
||||
|
||||
# Build context for the LLM
|
||||
context_parts = []
|
||||
|
||||
if show_prep:
|
||||
context_parts.append(f"## Show Prep (planned topics)\n\n{show_prep[:3000]}")
|
||||
|
||||
context_parts.append(f"## Transcript\n\n{transcript_text[:12000]}")
|
||||
|
||||
if diarization_data:
|
||||
speakers = diarization_data.get("speaker_map", {})
|
||||
if speakers:
|
||||
speaker_info = "\n".join(f"- {v}" for v in speakers.values())
|
||||
context_parts.append(f"## Speakers Identified\n\n{speaker_info}")
|
||||
|
||||
context = "\n\n---\n\n".join(context_parts)
|
||||
|
||||
# Query 1: Episode summary and segment summaries
|
||||
summary_prompt = f"""You are analyzing a radio show episode transcript.
|
||||
Provide a JSON response with:
|
||||
|
||||
1. "summary": A 2-3 paragraph episode summary suitable for a podcast episode page.
|
||||
Write in third person. Be specific about topics discussed.
|
||||
|
||||
2. "segment_summaries": An array of objects, each with:
|
||||
- "title": A compelling segment title
|
||||
- "summary": 3-5 sentence summary
|
||||
- "key_points": Array of key takeaway bullet points
|
||||
|
||||
3. "topics": Array of main topics discussed (short phrases)
|
||||
|
||||
4. "tags": Array of SEO-friendly tags (lowercase, hyphenated)
|
||||
|
||||
5. "key_quotes": Array of notable quotes, each with:
|
||||
- "quote": The quote text
|
||||
- "speaker": Who said it (if identifiable)
|
||||
- "context": Brief context
|
||||
|
||||
6. "blog_post_candidates": Array of topics worth expanding into blog posts, each with:
|
||||
- "title": Proposed blog post title
|
||||
- "angle": The specific angle or thesis
|
||||
- "why": Why this topic deserves expansion
|
||||
|
||||
Respond ONLY with valid JSON, no markdown fencing.
|
||||
|
||||
{context}"""
|
||||
|
||||
console.print("[dim]Generating episode analysis...[/dim]")
|
||||
|
||||
response = client.chat(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": summary_prompt}],
|
||||
options={"temperature": 0.3, "num_ctx": 16384},
|
||||
)
|
||||
|
||||
# Parse LLM response
|
||||
response_text = response["message"]["content"]
|
||||
|
||||
# Strip markdown code fences if present
|
||||
if "```json" in response_text:
|
||||
response_text = response_text.split("```json", 1)[1]
|
||||
response_text = response_text.split("```", 1)[0]
|
||||
elif "```" in response_text:
|
||||
response_text = response_text.split("```", 1)[1]
|
||||
response_text = response_text.split("```", 1)[0]
|
||||
|
||||
try:
|
||||
analysis_data = json.loads(response_text.strip())
|
||||
except json.JSONDecodeError:
|
||||
console.print("[yellow]LLM response was not valid JSON, using raw text[/yellow]")
|
||||
analysis_data = {
|
||||
"summary": response_text,
|
||||
"segment_summaries": [],
|
||||
"topics": [],
|
||||
"tags": [],
|
||||
"key_quotes": [],
|
||||
"blog_post_candidates": [],
|
||||
}
|
||||
|
||||
# Query 2: Generate debrief draft
|
||||
debrief_prompt = f"""Based on this radio show transcript, generate a post-show debrief
|
||||
in markdown format. Compare what was discussed against the show prep (planned topics)
|
||||
to identify what made it in, what was cut, and what was added.
|
||||
|
||||
Format:
|
||||
|
||||
# Post-Show Debrief
|
||||
## Episode: [derive title from content]
|
||||
## Air Date: [today's date if not clear]
|
||||
|
||||
### What Made It In
|
||||
[For each planned segment, note: Used / Modified / Cut]
|
||||
|
||||
### What Changed Live
|
||||
[Topics expanded, cut short, or reordered vs. prep]
|
||||
|
||||
### Caller/Audience Interaction
|
||||
[Any caller topics or audience engagement noted in transcript]
|
||||
|
||||
### Unplanned Additions
|
||||
[Topics not in prep that came up]
|
||||
|
||||
### Best Moments
|
||||
[Most compelling segments or quotes]
|
||||
|
||||
### Topics That Deserve More
|
||||
[Topics that were rushed or generated high interest]
|
||||
|
||||
### Suggested Blog Posts
|
||||
[2-3 specific blog post ideas with proposed titles and angles]
|
||||
|
||||
{context}"""
|
||||
|
||||
console.print("[dim]Generating debrief draft...[/dim]")
|
||||
|
||||
debrief_response = client.chat(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": debrief_prompt}],
|
||||
options={"temperature": 0.4, "num_ctx": 16384},
|
||||
)
|
||||
|
||||
debrief_text = debrief_response["message"]["content"]
|
||||
|
||||
console.print("[green]Analysis complete[/green]")
|
||||
|
||||
return EpisodeAnalysis(
|
||||
summary=analysis_data.get("summary", ""),
|
||||
segment_summaries=analysis_data.get("segment_summaries", []),
|
||||
key_quotes=analysis_data.get("key_quotes", []),
|
||||
topics=analysis_data.get("topics", []),
|
||||
tags=analysis_data.get("tags", []),
|
||||
blog_post_candidates=analysis_data.get("blog_post_candidates", []),
|
||||
debrief_draft=debrief_text,
|
||||
)
|
||||
@@ -1,199 +0,0 @@
|
||||
"""Stage 4 & 5: Commercial removal and segment splitting using ffmpeg."""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress
|
||||
|
||||
from .segment_detector import SegmentType, DetectedSegment
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chapter:
|
||||
title: str
|
||||
start: float
|
||||
end: float
|
||||
|
||||
|
||||
def remove_commercials(audio_path: Path, segments: list[DetectedSegment],
|
||||
output_path: Path, crossfade_ms: int = 500,
|
||||
bitrate: str = "192k", normalize: bool = True):
|
||||
"""Stitch show segments together, removing commercials."""
|
||||
show_segments = [s for s in segments
|
||||
if s.segment_type in (SegmentType.SHOW_CONTENT,
|
||||
SegmentType.SHOW_ELEMENT)]
|
||||
|
||||
if not show_segments:
|
||||
console.print("[red]No show segments found![/red]")
|
||||
return
|
||||
|
||||
console.print(f"[bold]Removing commercials:[/bold] {len(segments)} segments "
|
||||
f"-> {len(show_segments)} show segments")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_dir = output_path.parent / ".temp_segments"
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
try:
|
||||
# Extract each show segment
|
||||
segment_files = []
|
||||
with Progress(console=console) as progress:
|
||||
task = progress.add_task("Extracting segments...",
|
||||
total=len(show_segments))
|
||||
|
||||
for i, seg in enumerate(show_segments):
|
||||
temp_file = temp_dir / f"seg_{i:04d}.mp3"
|
||||
_extract_segment(audio_path, seg.start, seg.end,
|
||||
temp_file, bitrate)
|
||||
segment_files.append(temp_file)
|
||||
progress.update(task, advance=1)
|
||||
|
||||
# Create concat file for ffmpeg
|
||||
concat_file = temp_dir / "concat.txt"
|
||||
with open(concat_file, "w") as f:
|
||||
for sf in segment_files:
|
||||
f.write(f"file '{sf}'\n")
|
||||
|
||||
# Concatenate with crossfade
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
||||
"-i", str(concat_file),
|
||||
"-b:a", bitrate,
|
||||
]
|
||||
|
||||
if normalize:
|
||||
# EBU R128 loudness normalization
|
||||
cmd.extend([
|
||||
"-af", "loudnorm=I=-16:TP=-1.5:LRA=11",
|
||||
])
|
||||
|
||||
cmd.append(str(output_path))
|
||||
|
||||
subprocess.run(cmd, capture_output=True, check=True, timeout=600)
|
||||
|
||||
# Get output duration
|
||||
duration = _get_duration(output_path)
|
||||
console.print(f"[green]Clean episode saved: {output_path.name} "
|
||||
f"({duration / 60:.1f} min)[/green]")
|
||||
|
||||
finally:
|
||||
# Cleanup temp files
|
||||
import shutil
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def split_segments(audio_path: Path, segments: list[DetectedSegment],
|
||||
output_dir: Path, bitrate: str = "192k"):
|
||||
"""Export individual show segments as separate MP3 files."""
|
||||
show_segments = [s for s in segments
|
||||
if s.segment_type in (SegmentType.SHOW_CONTENT,
|
||||
SegmentType.SHOW_ELEMENT)]
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
console.print(f"[bold]Splitting into {len(show_segments)} segments[/bold]")
|
||||
|
||||
exported = []
|
||||
for i, seg in enumerate(show_segments):
|
||||
slug = _slugify(seg.label) if seg.label else f"segment-{i:02d}"
|
||||
filename = f"{i:02d}-{slug}.mp3"
|
||||
output_file = output_dir / filename
|
||||
|
||||
_extract_segment(audio_path, seg.start, seg.end, output_file, bitrate,
|
||||
fade_in_ms=200, fade_out_ms=500)
|
||||
|
||||
duration = seg.duration
|
||||
console.print(f" [green]{filename}[/green] ({duration:.0f}s)")
|
||||
exported.append({
|
||||
"file": filename,
|
||||
"label": seg.label,
|
||||
"start": seg.start,
|
||||
"end": seg.end,
|
||||
"duration": duration,
|
||||
})
|
||||
|
||||
# Save manifest
|
||||
with open(output_dir / "segments.json", "w") as f:
|
||||
json.dump(exported, f, indent=2)
|
||||
|
||||
return exported
|
||||
|
||||
|
||||
def generate_chapters(segments: list[DetectedSegment],
|
||||
output_path: Path) -> list[Chapter]:
|
||||
"""Generate chapter markers from show segments."""
|
||||
show_segments = [s for s in segments
|
||||
if s.segment_type in (SegmentType.SHOW_CONTENT,
|
||||
SegmentType.SHOW_ELEMENT)]
|
||||
|
||||
chapters = []
|
||||
cumulative_time = 0.0
|
||||
|
||||
for seg in show_segments:
|
||||
chapters.append(Chapter(
|
||||
title=seg.label or f"Segment",
|
||||
start=cumulative_time,
|
||||
end=cumulative_time + seg.duration,
|
||||
))
|
||||
cumulative_time += seg.duration
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(
|
||||
[{"title": c.title, "start": c.start, "end": c.end}
|
||||
for c in chapters],
|
||||
f, indent=2,
|
||||
)
|
||||
|
||||
console.print(f"[green]Chapter markers saved: {len(chapters)} chapters[/green]")
|
||||
return chapters
|
||||
|
||||
|
||||
def _extract_segment(audio_path: Path, start: float, end: float,
|
||||
output_path: Path, bitrate: str = "192k",
|
||||
fade_in_ms: int = 0, fade_out_ms: int = 0):
|
||||
"""Extract a segment from an audio file using ffmpeg."""
|
||||
duration = end - start
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", str(start),
|
||||
"-t", str(duration),
|
||||
"-i", str(audio_path),
|
||||
"-b:a", bitrate,
|
||||
]
|
||||
|
||||
filters = []
|
||||
if fade_in_ms > 0:
|
||||
filters.append(f"afade=t=in:d={fade_in_ms / 1000}")
|
||||
if fade_out_ms > 0:
|
||||
filters.append(f"afade=t=out:st={duration - fade_out_ms / 1000}:d={fade_out_ms / 1000}")
|
||||
|
||||
if filters:
|
||||
cmd.extend(["-af", ",".join(filters)])
|
||||
|
||||
cmd.append(str(output_path))
|
||||
subprocess.run(cmd, capture_output=True, check=True, timeout=120)
|
||||
|
||||
|
||||
def _get_duration(audio_path: Path) -> float:
|
||||
"""Get audio file duration in seconds."""
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", str(audio_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""Convert text to a filename-safe slug."""
|
||||
import re
|
||||
text = text.lower().strip()
|
||||
text = re.sub(r'[^\w\s-]', '', text)
|
||||
text = re.sub(r'[\s_]+', '-', text)
|
||||
text = re.sub(r'-+', '-', text)
|
||||
return text[:50].strip('-')
|
||||
@@ -1,399 +0,0 @@
|
||||
"""CLI entry point for the radio show audio processor."""
|
||||
|
||||
# Must set CUDA paths before any torch/ctranslate2 imports
|
||||
from .gpu import ensure_cuda_libs
|
||||
ensure_cuda_libs()
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from .config import load_config
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Radio Show Audio Processor — The Computer Guru Show",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s process episode.mp3
|
||||
%(prog)s process episode.mp3 --show-prep show-prep.md
|
||||
%(prog)s process hr1.mp3 hr2.mp3 --archive-mode --date 2016-03-15
|
||||
%(prog)s transcribe episode.mp3
|
||||
%(prog)s bootstrap-voice archive/
|
||||
%(prog)s review-elements
|
||||
%(prog)s review-speakers
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--config", type=str, default=None,
|
||||
help="Path to config.yaml")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# === process ===
|
||||
p_process = subparsers.add_parser("process", help="Full pipeline")
|
||||
p_process.add_argument("audio", nargs="+", type=str,
|
||||
help="Audio file(s) to process")
|
||||
p_process.add_argument("--show-prep", type=str, default=None,
|
||||
help="Path to show prep markdown file")
|
||||
p_process.add_argument("--output", type=str, default=None,
|
||||
help="Output directory")
|
||||
p_process.add_argument("--archive-mode", action="store_true",
|
||||
help="Archive mode: learn elements and voices")
|
||||
p_process.add_argument("--date", type=str, default=None,
|
||||
help="Episode date (for archive mode)")
|
||||
p_process.add_argument("--skip-transcribe", action="store_true",
|
||||
help="Skip transcription (use existing transcript)")
|
||||
p_process.add_argument("--skip-diarize", action="store_true",
|
||||
help="Skip diarization")
|
||||
p_process.add_argument("--skip-analysis", action="store_true",
|
||||
help="Skip LLM analysis")
|
||||
|
||||
# === transcribe ===
|
||||
p_transcribe = subparsers.add_parser("transcribe", help="Transcribe only")
|
||||
p_transcribe.add_argument("audio", type=str, help="Audio file")
|
||||
p_transcribe.add_argument("--output", type=str, default=None)
|
||||
p_transcribe.add_argument("--model", type=str, default=None,
|
||||
help="Whisper model size")
|
||||
|
||||
# === diarize ===
|
||||
p_diarize = subparsers.add_parser("diarize", help="Diarize only")
|
||||
p_diarize.add_argument("audio", type=str, help="Audio file")
|
||||
p_diarize.add_argument("--output", type=str, default=None)
|
||||
|
||||
# === detect ===
|
||||
p_detect = subparsers.add_parser("detect", help="Detect segments only")
|
||||
p_detect.add_argument("audio", type=str, help="Audio file")
|
||||
p_detect.add_argument("--output", type=str, default=None)
|
||||
p_detect.add_argument("--show-prep", type=str, default=None)
|
||||
|
||||
# === split ===
|
||||
p_split = subparsers.add_parser("split", help="Split into segments")
|
||||
p_split.add_argument("audio", type=str, help="Audio file")
|
||||
p_split.add_argument("--detection-report", type=str, required=True,
|
||||
help="Path to detection-report.json")
|
||||
p_split.add_argument("--output", type=str, default=None)
|
||||
|
||||
# === bootstrap-voice ===
|
||||
p_voice = subparsers.add_parser("bootstrap-voice",
|
||||
help="Bootstrap host voice profile from archive")
|
||||
p_voice.add_argument("archive_dir", type=str,
|
||||
help="Directory containing archive MP3s")
|
||||
p_voice.add_argument("--speaker-name", type=str, default="Mike Swanson")
|
||||
p_voice.add_argument("--sample-count", type=int, default=10,
|
||||
help="Number of episodes to sample")
|
||||
|
||||
# === review-elements ===
|
||||
subparsers.add_parser("review-elements",
|
||||
help="Review discovered audio elements")
|
||||
|
||||
# === review-speakers ===
|
||||
subparsers.add_parser("review-speakers",
|
||||
help="Review unknown speaker clusters")
|
||||
|
||||
args = parser.parse_args()
|
||||
config = load_config(args.config)
|
||||
|
||||
console.print(Panel.fit(
|
||||
"[bold]Radio Show Audio Processor[/bold]\n"
|
||||
f"[dim]The Computer Guru Show[/dim]",
|
||||
border_style="blue",
|
||||
))
|
||||
|
||||
if args.command == "process":
|
||||
_cmd_process(args, config)
|
||||
elif args.command == "transcribe":
|
||||
_cmd_transcribe(args, config)
|
||||
elif args.command == "diarize":
|
||||
_cmd_diarize(args, config)
|
||||
elif args.command == "detect":
|
||||
_cmd_detect(args, config)
|
||||
elif args.command == "split":
|
||||
_cmd_split(args, config)
|
||||
elif args.command == "bootstrap-voice":
|
||||
_cmd_bootstrap_voice(args, config)
|
||||
elif args.command == "review-elements":
|
||||
_cmd_review_elements(args, config)
|
||||
elif args.command == "review-speakers":
|
||||
_cmd_review_speakers(args, config)
|
||||
|
||||
|
||||
def _cmd_process(args, config):
|
||||
"""Full processing pipeline."""
|
||||
from .transcriber import transcribe
|
||||
from .diarizer import diarize, VoiceProfileStore
|
||||
from .segment_detector import SegmentDetector
|
||||
from .audio_editor import remove_commercials, split_segments, generate_chapters
|
||||
from .analyzer import analyze_episode
|
||||
|
||||
audio_files = [Path(f) for f in args.audio]
|
||||
audio_path = audio_files[0] # Primary file
|
||||
|
||||
# If multiple files (HR1 + HR2), concatenate first
|
||||
if len(audio_files) > 1:
|
||||
audio_path = _concatenate_audio(audio_files, config)
|
||||
|
||||
output_dir = Path(args.output) if args.output else audio_path.parent / "processed"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load show prep if provided
|
||||
show_prep = None
|
||||
if args.show_prep:
|
||||
show_prep = Path(args.show_prep).read_text()
|
||||
|
||||
# Stage 1: Transcribe
|
||||
transcript = None
|
||||
if not args.skip_transcribe:
|
||||
transcript = transcribe(
|
||||
audio_path,
|
||||
model_size=config.audio.whisper_model,
|
||||
language=config.audio.whisper_language,
|
||||
)
|
||||
transcript.save(output_dir)
|
||||
else:
|
||||
console.print("[dim]Skipping transcription[/dim]")
|
||||
# Try to load existing transcript
|
||||
transcript_file = output_dir / "transcript.json"
|
||||
if transcript_file.exists():
|
||||
from .transcriber import Transcript, TranscriptSegment, TranscriptWord
|
||||
import json
|
||||
with open(transcript_file) as f:
|
||||
data = json.load(f)
|
||||
transcript = Transcript(
|
||||
segments=[
|
||||
TranscriptSegment(
|
||||
id=s["id"], text=s["text"],
|
||||
start=s["start"], end=s["end"],
|
||||
words=[TranscriptWord(**w) for w in s.get("words", [])],
|
||||
)
|
||||
for s in data["segments"]
|
||||
],
|
||||
language=data["language"],
|
||||
language_probability=data["language_probability"],
|
||||
duration=data["duration"],
|
||||
)
|
||||
|
||||
# Stage 2: Diarize
|
||||
diarization = None
|
||||
if not args.skip_diarize:
|
||||
voice_profiles = VoiceProfileStore(
|
||||
config.resolve_path(config.diarization.voice_profiles_dir)
|
||||
)
|
||||
diarization = diarize(
|
||||
audio_path,
|
||||
voice_profiles=voice_profiles,
|
||||
min_speakers=config.diarization.min_speakers,
|
||||
max_speakers=config.diarization.max_speakers,
|
||||
)
|
||||
diarization.save(output_dir)
|
||||
else:
|
||||
console.print("[dim]Skipping diarization[/dim]")
|
||||
|
||||
# Stage 3: Detect segments
|
||||
detector = SegmentDetector(config)
|
||||
detection = detector.detect(
|
||||
audio_path,
|
||||
transcript=transcript,
|
||||
diarization=diarization,
|
||||
show_prep=show_prep,
|
||||
)
|
||||
detection.save(output_dir)
|
||||
|
||||
# Stage 4: Remove commercials
|
||||
clean_path = output_dir / f"podcast-episode.{config.audio.output_format}"
|
||||
remove_commercials(
|
||||
audio_path, detection.segments, clean_path,
|
||||
crossfade_ms=config.audio.crossfade_ms,
|
||||
bitrate=config.audio.output_bitrate,
|
||||
normalize=config.audio.normalize,
|
||||
)
|
||||
|
||||
# Stage 5: Split segments
|
||||
segments_dir = output_dir / "segments"
|
||||
split_segments(
|
||||
audio_path, detection.segments, segments_dir,
|
||||
bitrate=config.audio.output_bitrate,
|
||||
)
|
||||
|
||||
# Generate chapters
|
||||
generate_chapters(detection.segments, output_dir / "chapters.json")
|
||||
|
||||
# Stage 6: Analyze
|
||||
if not args.skip_analysis and transcript:
|
||||
analysis = analyze_episode(
|
||||
transcript_text=transcript.full_text,
|
||||
diarization_data=diarization.to_dict() if diarization else None,
|
||||
show_prep=show_prep,
|
||||
segments=detection.segments,
|
||||
model=config.llm.model,
|
||||
ollama_host=config.llm.ollama_host,
|
||||
)
|
||||
generated_dir = output_dir.parent / "generated"
|
||||
analysis.save(generated_dir)
|
||||
|
||||
console.print("\n[bold green]Processing complete![/bold green]")
|
||||
console.print(f"Output: {output_dir}")
|
||||
|
||||
|
||||
def _cmd_transcribe(args, config):
|
||||
"""Transcribe only."""
|
||||
from .transcriber import transcribe
|
||||
|
||||
audio_path = Path(args.audio)
|
||||
output_dir = Path(args.output) if args.output else audio_path.parent / "processed"
|
||||
model = args.model or config.audio.whisper_model
|
||||
|
||||
transcript = transcribe(audio_path, model_size=model)
|
||||
transcript.save(output_dir)
|
||||
|
||||
|
||||
def _cmd_diarize(args, config):
|
||||
"""Diarize only."""
|
||||
from .diarizer import diarize, VoiceProfileStore
|
||||
|
||||
audio_path = Path(args.audio)
|
||||
output_dir = Path(args.output) if args.output else audio_path.parent / "processed"
|
||||
|
||||
voice_profiles = VoiceProfileStore(
|
||||
config.resolve_path(config.diarization.voice_profiles_dir)
|
||||
)
|
||||
result = diarize(audio_path, voice_profiles=voice_profiles)
|
||||
result.save(output_dir)
|
||||
|
||||
|
||||
def _cmd_detect(args, config):
|
||||
"""Segment detection only."""
|
||||
from .segment_detector import SegmentDetector
|
||||
|
||||
audio_path = Path(args.audio)
|
||||
output_dir = Path(args.output) if args.output else audio_path.parent / "processed"
|
||||
|
||||
show_prep = None
|
||||
if args.show_prep:
|
||||
show_prep = Path(args.show_prep).read_text()
|
||||
|
||||
# Load existing transcript if available
|
||||
transcript = None
|
||||
transcript_file = output_dir / "transcript.json"
|
||||
if transcript_file.exists():
|
||||
from .transcriber import Transcript, TranscriptSegment, TranscriptWord
|
||||
import json
|
||||
console.print(f"[dim]Loading transcript from {transcript_file}[/dim]")
|
||||
with open(transcript_file) as f:
|
||||
data = json.load(f)
|
||||
transcript = Transcript(
|
||||
segments=[
|
||||
TranscriptSegment(
|
||||
id=s["id"], text=s["text"],
|
||||
start=s["start"], end=s["end"],
|
||||
words=[TranscriptWord(**w) for w in s.get("words", [])],
|
||||
)
|
||||
for s in data["segments"]
|
||||
],
|
||||
language=data["language"],
|
||||
language_probability=data["language_probability"],
|
||||
duration=data["duration"],
|
||||
)
|
||||
|
||||
detector = SegmentDetector(config)
|
||||
result = detector.detect(audio_path, transcript=transcript, show_prep=show_prep)
|
||||
result.save(output_dir)
|
||||
|
||||
|
||||
def _cmd_split(args, config):
|
||||
"""Split using existing detection report."""
|
||||
from .audio_editor import split_segments, generate_chapters
|
||||
from .segment_detector import DetectedSegment, SegmentType
|
||||
import json
|
||||
|
||||
audio_path = Path(args.audio)
|
||||
output_dir = Path(args.output) if args.output else audio_path.parent / "segments"
|
||||
|
||||
with open(args.detection_report) as f:
|
||||
report = json.load(f)
|
||||
|
||||
segments = [
|
||||
DetectedSegment(
|
||||
start=s["start"], end=s["end"],
|
||||
segment_type=SegmentType(s["type"]),
|
||||
confidence=s["confidence"],
|
||||
label=s.get("label", ""),
|
||||
)
|
||||
for s in report["segments"]
|
||||
]
|
||||
|
||||
split_segments(audio_path, segments, output_dir, config.audio.output_bitrate)
|
||||
generate_chapters(segments, output_dir.parent / "chapters.json")
|
||||
|
||||
|
||||
def _cmd_bootstrap_voice(args, config):
|
||||
"""Bootstrap host voice profile from archive episodes."""
|
||||
from .voice_profiler import VoiceProfiler
|
||||
|
||||
archive_dir = Path(args.archive_dir)
|
||||
profiler = VoiceProfiler(
|
||||
config.resolve_path(config.paths.voice_profiles),
|
||||
device="cuda",
|
||||
)
|
||||
|
||||
# Find MP3 files in archive directory
|
||||
mp3_files = sorted(archive_dir.glob("**/*.mp3"))
|
||||
if not mp3_files:
|
||||
console.print(f"[red]No MP3 files found in {archive_dir}[/red]")
|
||||
return
|
||||
|
||||
# Sample if we have more than requested
|
||||
if len(mp3_files) > args.sample_count:
|
||||
step = len(mp3_files) // args.sample_count
|
||||
mp3_files = [mp3_files[i * step] for i in range(args.sample_count)]
|
||||
|
||||
console.print(f"[dim]Found {len(mp3_files)} episodes to process[/dim]")
|
||||
|
||||
profiler.bootstrap_host_from_episodes(mp3_files, host_name=args.speaker_name)
|
||||
profiler.print_profiles()
|
||||
|
||||
|
||||
def _cmd_review_elements(args, config):
|
||||
"""Review discovered audio elements."""
|
||||
console.print("[bold]Reviewing discovered elements[/bold]")
|
||||
# TODO: Implement element review UI
|
||||
console.print("[yellow]Not yet implemented[/yellow]")
|
||||
|
||||
|
||||
def _cmd_review_speakers(args, config):
|
||||
"""Review unknown speaker clusters."""
|
||||
console.print("[bold]Reviewing unknown speakers[/bold]")
|
||||
# TODO: Implement speaker review UI
|
||||
console.print("[yellow]Not yet implemented[/yellow]")
|
||||
|
||||
|
||||
def _concatenate_audio(files: list[Path], config) -> Path:
|
||||
"""Concatenate multiple audio files (e.g., HR1 + HR2)."""
|
||||
import subprocess
|
||||
|
||||
output = files[0].parent / f"combined_{files[0].stem}.mp3"
|
||||
concat_file = files[0].parent / ".concat_list.txt"
|
||||
|
||||
with open(concat_file, "w") as f:
|
||||
for audio_file in files:
|
||||
f.write(f"file '{audio_file}'\n")
|
||||
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-f", "concat", "-safe", "0",
|
||||
"-i", str(concat_file), "-c", "copy", str(output)],
|
||||
capture_output=True, check=True,
|
||||
)
|
||||
concat_file.unlink()
|
||||
|
||||
console.print(f"[dim]Concatenated {len(files)} files -> {output.name}[/dim]")
|
||||
return output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,105 +0,0 @@
|
||||
"""
|
||||
Audio clip extraction using ffmpeg.
|
||||
Cuts clips from original broadcast MP3s for use in Audition/Audacity.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def extract_clip(
|
||||
source_path: Path,
|
||||
start: float,
|
||||
end: float,
|
||||
output_path: Path,
|
||||
padding: float = 1.5,
|
||||
fade_ms: int = 200,
|
||||
) -> Path:
|
||||
"""
|
||||
Extract a clip from source_path between start and end seconds.
|
||||
Adds padding on both sides and applies fade in/out.
|
||||
Returns the output path.
|
||||
"""
|
||||
source_path = Path(source_path)
|
||||
output_path = Path(output_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
clip_start = max(0.0, start - padding)
|
||||
clip_end = end + padding
|
||||
duration = clip_end - clip_start
|
||||
|
||||
fade_s = fade_ms / 1000.0
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-ss", f"{clip_start:.3f}",
|
||||
"-i", str(source_path),
|
||||
"-t", f"{duration:.3f}",
|
||||
"-af", f"afade=t=in:st=0:d={fade_s},afade=t=out:st={duration - fade_s:.3f}:d={fade_s}",
|
||||
"-q:a", "2",
|
||||
str(output_path),
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed: {result.stderr[-500:]}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def extract_clips_for_results(results, output_dir: Path, padding: float = 1.5) -> dict[int, Path]:
|
||||
"""
|
||||
Extract clips for a list of QAResult or SearchResult objects.
|
||||
Returns {index: clip_path}.
|
||||
"""
|
||||
output_dir = Path(output_dir)
|
||||
clip_paths = {}
|
||||
|
||||
for i, result in enumerate(results):
|
||||
episode = result.episode_id
|
||||
audio_path = Path(result.audio_path)
|
||||
|
||||
if not audio_path.exists():
|
||||
console.print(f"[yellow]Audio not found: {audio_path}[/yellow]")
|
||||
continue
|
||||
|
||||
# Determine time range
|
||||
if hasattr(result, "question_start"):
|
||||
# QAResult
|
||||
start = result.question_start
|
||||
end = result.answer_end
|
||||
else:
|
||||
# SearchResult
|
||||
start = result.start
|
||||
end = result.end
|
||||
|
||||
def fmt(s):
|
||||
m, sec = divmod(int(s), 60)
|
||||
h, m = divmod(m, 60)
|
||||
return f"{h}h{m:02d}m{sec:02d}s" if h else f"{m}m{sec:02d}s"
|
||||
|
||||
clip_name = f"{episode}_{fmt(start)}.mp3"
|
||||
clip_path = output_dir / clip_name
|
||||
|
||||
try:
|
||||
extract_clip(audio_path, start, end, clip_path, padding=padding)
|
||||
clip_paths[i] = clip_path
|
||||
console.print(f"[green]Clip {i+1}:[/green] {clip_name}")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Clip {i+1} failed:[/red] {e}")
|
||||
|
||||
return clip_paths
|
||||
|
||||
|
||||
def format_timestamp(seconds: float) -> str:
|
||||
"""Format seconds as H:MM:SS or M:SS."""
|
||||
h = int(seconds // 3600)
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = int(seconds % 60)
|
||||
if h:
|
||||
return f"{h}:{m:02d}:{s:02d}"
|
||||
return f"{m}:{s:02d}"
|
||||
@@ -1,126 +0,0 @@
|
||||
"""Configuration loader for the radio show audio processor."""
|
||||
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShowConfig:
|
||||
name: str = "The Computer Guru Show"
|
||||
host: str = "Mike Swanson"
|
||||
typical_duration_minutes: int = 120
|
||||
segment_count: int = 6
|
||||
has_commercials: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioConfig:
|
||||
whisper_model: str = "large-v3"
|
||||
whisper_language: str = "en"
|
||||
output_format: str = "mp3"
|
||||
output_bitrate: str = "192k"
|
||||
normalize: bool = True
|
||||
crossfade_ms: int = 500
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionWeights:
|
||||
fingerprint_match: float = 0.30
|
||||
speaker_identity: float = 0.25
|
||||
audio_characteristics: float = 0.20
|
||||
break_pattern: float = 0.15
|
||||
structural_heuristic: float = 0.10
|
||||
|
||||
|
||||
@dataclass
|
||||
class SegmentDetectionConfig:
|
||||
fingerprint_db: str = "element-library/fingerprints.db"
|
||||
fingerprint_match_threshold: float = 0.85
|
||||
discover_unknown_elements: bool = True
|
||||
min_element_duration_s: float = 1.0
|
||||
max_element_duration_s: float = 30.0
|
||||
cluster_similarity_threshold: float = 0.90
|
||||
min_cluster_occurrences: int = 3
|
||||
min_break_duration_s: int = 30
|
||||
max_break_duration_s: int = 300
|
||||
silence_threshold_db: int = -40
|
||||
confidence_threshold: float = 0.70
|
||||
weights: DetectionWeights = field(default_factory=DetectionWeights)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiarizationConfig:
|
||||
min_speakers: int = 1
|
||||
max_speakers: int = 6
|
||||
voice_profiles_dir: str = "voice-profiles/"
|
||||
host_match_threshold: float = 0.75
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
model: str = "qwen3:14b"
|
||||
ollama_host: str = "http://localhost:11434"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PathsConfig:
|
||||
episodes_dir: str = "episodes/"
|
||||
voice_profiles: str = "voice-profiles/"
|
||||
element_library: str = "element-library/"
|
||||
output_dir: str = "processed/"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveConfig:
|
||||
server: str = "172.16.3.10"
|
||||
path: str = "/home/gurushow/public_html/archive/"
|
||||
elements_path: str = "/home/gurushow/public_html/archive/Radio/Elements/"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
show: ShowConfig = field(default_factory=ShowConfig)
|
||||
audio: AudioConfig = field(default_factory=AudioConfig)
|
||||
segment_detection: SegmentDetectionConfig = field(default_factory=SegmentDetectionConfig)
|
||||
diarization: DiarizationConfig = field(default_factory=DiarizationConfig)
|
||||
llm: LLMConfig = field(default_factory=LLMConfig)
|
||||
paths: PathsConfig = field(default_factory=PathsConfig)
|
||||
archive: ArchiveConfig = field(default_factory=ArchiveConfig)
|
||||
base_dir: Path = field(default_factory=lambda: Path.cwd())
|
||||
|
||||
def resolve_path(self, relative: str) -> Path:
|
||||
return self.base_dir / relative
|
||||
|
||||
|
||||
def load_config(config_path: str | Path | None = None) -> Config:
|
||||
if config_path is None:
|
||||
config_path = Path(__file__).parent.parent / "config.yaml"
|
||||
|
||||
config_path = Path(config_path)
|
||||
if not config_path.exists():
|
||||
return Config(base_dir=config_path.parent)
|
||||
|
||||
with open(config_path) as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
config = Config(base_dir=config_path.parent)
|
||||
|
||||
if "show" in raw:
|
||||
config.show = ShowConfig(**raw["show"])
|
||||
if "audio" in raw:
|
||||
config.audio = AudioConfig(**raw["audio"])
|
||||
if "segment_detection" in raw:
|
||||
sd = raw["segment_detection"]
|
||||
weights = DetectionWeights(**sd.pop("weights", {}))
|
||||
config.segment_detection = SegmentDetectionConfig(weights=weights, **sd)
|
||||
if "diarization" in raw:
|
||||
config.diarization = DiarizationConfig(**raw["diarization"])
|
||||
if "llm" in raw:
|
||||
config.llm = LLMConfig(**raw["llm"])
|
||||
if "paths" in raw:
|
||||
config.paths = PathsConfig(**raw["paths"])
|
||||
if "archive" in raw:
|
||||
config.archive = ArchiveConfig(**raw["archive"])
|
||||
|
||||
return config
|
||||
@@ -1,270 +0,0 @@
|
||||
"""Stage 2: Speaker diarization using pyannote.audio with voice profile matching."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpeakerTurn:
|
||||
speaker: str # "SPEAKER_00", "Host: Mike Swanson", "Caller 1", etc.
|
||||
start: float
|
||||
end: float
|
||||
confidence: float = 1.0
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return self.end - self.start
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiarizationResult:
|
||||
turns: list[SpeakerTurn]
|
||||
num_speakers: int
|
||||
speaker_map: dict[str, str] # raw label -> friendly name
|
||||
|
||||
def speaker_at(self, time: float) -> str | None:
|
||||
"""Get the speaker at a given timestamp."""
|
||||
for turn in self.turns:
|
||||
if turn.start <= time <= turn.end:
|
||||
return turn.speaker
|
||||
return None
|
||||
|
||||
def speaker_time(self, speaker: str) -> float:
|
||||
"""Total speaking time for a speaker."""
|
||||
return sum(t.duration for t in self.turns if t.speaker == speaker)
|
||||
|
||||
def speakers_ranked(self) -> list[tuple[str, float]]:
|
||||
"""Speakers ranked by total speaking time."""
|
||||
times = {}
|
||||
for turn in self.turns:
|
||||
times[turn.speaker] = times.get(turn.speaker, 0) + turn.duration
|
||||
return sorted(times.items(), key=lambda x: x[1], reverse=True)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"num_speakers": self.num_speakers,
|
||||
"speaker_map": self.speaker_map,
|
||||
"turns": [
|
||||
{
|
||||
"speaker": t.speaker,
|
||||
"start": t.start,
|
||||
"end": t.end,
|
||||
"confidence": t.confidence,
|
||||
}
|
||||
for t in self.turns
|
||||
],
|
||||
}
|
||||
|
||||
def save(self, output_dir: Path):
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_dir / "diarization.json", "w") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
console.print(f"[green]Diarization saved to {output_dir}[/green]")
|
||||
|
||||
|
||||
class VoiceProfileStore:
|
||||
"""Manages speaker voice embeddings for identification."""
|
||||
|
||||
def __init__(self, profiles_dir: str | Path):
|
||||
self.profiles_dir = Path(profiles_dir)
|
||||
self.embeddings: dict[str, np.ndarray] = {}
|
||||
self.metadata: dict[str, dict] = {}
|
||||
self._load_profiles()
|
||||
|
||||
def _load_profiles(self):
|
||||
if not self.profiles_dir.exists():
|
||||
return
|
||||
|
||||
for npy_file in self.profiles_dir.rglob("*.npy"):
|
||||
name = npy_file.stem
|
||||
# Determine speaker name from directory structure
|
||||
parent = npy_file.parent.name
|
||||
if parent.startswith("host-"):
|
||||
speaker_name = parent.replace("host-", "").replace("-", " ").title()
|
||||
role = "host"
|
||||
elif parent == "guests":
|
||||
speaker_name = name.replace("-", " ").title()
|
||||
role = "guest"
|
||||
elif parent == "callers":
|
||||
speaker_name = name
|
||||
role = "caller"
|
||||
else:
|
||||
speaker_name = name
|
||||
role = "unknown"
|
||||
|
||||
self.embeddings[name] = np.load(npy_file)
|
||||
self.metadata[name] = {
|
||||
"name": speaker_name,
|
||||
"role": role,
|
||||
"file": str(npy_file),
|
||||
}
|
||||
|
||||
if self.embeddings:
|
||||
console.print(f"[dim]Loaded {len(self.embeddings)} voice profiles[/dim]")
|
||||
|
||||
def match_embedding(self, embedding: np.ndarray, threshold: float = 0.75
|
||||
) -> tuple[str | None, float]:
|
||||
"""Match an embedding against stored profiles. Returns (name, similarity)."""
|
||||
if not self.embeddings:
|
||||
return None, 0.0
|
||||
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
|
||||
for name, stored in self.embeddings.items():
|
||||
# Cosine similarity
|
||||
similarity = np.dot(embedding, stored) / (
|
||||
np.linalg.norm(embedding) * np.linalg.norm(stored) + 1e-8
|
||||
)
|
||||
if similarity > best_score:
|
||||
best_score = similarity
|
||||
best_match = name
|
||||
|
||||
if best_score >= threshold:
|
||||
meta = self.metadata.get(best_match, {})
|
||||
friendly_name = meta.get("name", best_match)
|
||||
role = meta.get("role", "unknown")
|
||||
if role == "host":
|
||||
return f"Host: {friendly_name}", best_score
|
||||
return friendly_name, best_score
|
||||
|
||||
return None, best_score
|
||||
|
||||
def save_embedding(self, name: str, embedding: np.ndarray,
|
||||
role: str = "unknown"):
|
||||
"""Save a new voice profile."""
|
||||
if role == "host":
|
||||
subdir = self.profiles_dir / f"host-{name.lower().replace(' ', '-')}"
|
||||
elif role == "guest":
|
||||
subdir = self.profiles_dir / "guests"
|
||||
elif role == "caller":
|
||||
subdir = self.profiles_dir / "callers"
|
||||
else:
|
||||
subdir = self.profiles_dir / "unknown"
|
||||
|
||||
subdir.mkdir(parents=True, exist_ok=True)
|
||||
filename = name.lower().replace(" ", "-")
|
||||
np.save(subdir / f"{filename}.npy", embedding)
|
||||
console.print(f"[green]Saved voice profile: {name} ({role})[/green]")
|
||||
|
||||
|
||||
def diarize(audio_path: str | Path,
|
||||
voice_profiles: VoiceProfileStore | None = None,
|
||||
min_speakers: int = 1,
|
||||
max_speakers: int = 6,
|
||||
host_match_threshold: float = 0.85,
|
||||
transcript_path: str | Path | None = None) -> DiarizationResult:
|
||||
"""Run speaker diarization using WavLM sliding-window speaker identification.
|
||||
|
||||
Uses the built-in VoiceProfiler (WavLM x-vectors) — no HuggingFace token
|
||||
or gated model required. Identifies HOST vs non-HOST speakers using the
|
||||
stored voice profile for Mike Swanson.
|
||||
|
||||
If transcript_path is provided, time ranges containing show promo/bumper
|
||||
text are pre-marked and skipped at speaker-identification time so vocal
|
||||
music doesn't match cohost profiles.
|
||||
"""
|
||||
import torch
|
||||
from .voice_profiler import VoiceProfiler
|
||||
|
||||
audio_path = Path(audio_path)
|
||||
console.print(f"[bold]Diarizing:[/bold] {audio_path.name}")
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
console.print(f"[dim]Device: {device}[/dim]")
|
||||
|
||||
# Locate voice profiles directory from the VoiceProfileStore path
|
||||
profiles_dir = voice_profiles.profiles_dir if voice_profiles else Path("voice-profiles")
|
||||
|
||||
profiler = VoiceProfiler(profiles_dir, device=device)
|
||||
|
||||
if not profiler.profiles:
|
||||
console.print("[yellow]No voice profiles found — labeling all as HOST[/yellow]")
|
||||
# Return a single HOST turn covering the whole episode
|
||||
from .voice_profiler import VoiceProfiler as VP
|
||||
duration = profiler._get_duration(audio_path)
|
||||
return DiarizationResult(
|
||||
turns=[SpeakerTurn(speaker="HOST", start=0.0, end=duration)],
|
||||
num_speakers=1,
|
||||
speaker_map={"HOST": "HOST"},
|
||||
)
|
||||
|
||||
# Pre-compute bumper / promo time ranges from transcript if available
|
||||
bumper_ranges: list[tuple[float, float]] = []
|
||||
if transcript_path is not None:
|
||||
transcript_path = Path(transcript_path)
|
||||
if transcript_path.exists():
|
||||
from .qa_extractor import _is_promo_or_bumper
|
||||
with open(transcript_path) as f:
|
||||
tdata = json.load(f)
|
||||
for seg in tdata.get("segments", []):
|
||||
if _is_promo_or_bumper(seg.get("text", "")):
|
||||
bumper_ranges.append((seg["start"], seg["end"]))
|
||||
if bumper_ranges:
|
||||
console.print(
|
||||
f"[dim]Bumper filter: {len(bumper_ranges)} promo/bumper "
|
||||
f"transcript segments will be skipped during speaker match[/dim]"
|
||||
)
|
||||
|
||||
# Sliding-window identification: 10s windows, 5s hop
|
||||
voice_segs = profiler.identify_speakers(
|
||||
audio_path, window_s=10.0, hop_s=5.0,
|
||||
threshold=host_match_threshold,
|
||||
skip_ranges=bumper_ranges,
|
||||
)
|
||||
|
||||
# Convert VoiceSegment labels to HOST / CALLER
|
||||
raw_turns = []
|
||||
for seg in voice_segs:
|
||||
label = seg.speaker_label.split(" (")[0] # strip confidence score
|
||||
if label.startswith("Host:") or label.startswith("Host "):
|
||||
speaker = "HOST"
|
||||
elif label.startswith("Cohost:"):
|
||||
speaker = "CO-HOST"
|
||||
elif label == "[bumper]":
|
||||
speaker = "BUMPER"
|
||||
elif label == "[error]":
|
||||
speaker = "UNKNOWN"
|
||||
else:
|
||||
speaker = "CALLER"
|
||||
|
||||
raw_turns.append(SpeakerTurn(
|
||||
speaker=speaker,
|
||||
start=seg.start,
|
||||
end=seg.end,
|
||||
confidence=float(seg.speaker_label.split("(")[-1].rstrip(")"))
|
||||
if "(" in seg.speaker_label else 0.5,
|
||||
))
|
||||
|
||||
# Merge consecutive same-speaker turns
|
||||
merged: list[SpeakerTurn] = []
|
||||
for turn in raw_turns:
|
||||
if merged and merged[-1].speaker == turn.speaker:
|
||||
merged[-1].end = turn.end
|
||||
else:
|
||||
merged.append(SpeakerTurn(
|
||||
speaker=turn.speaker,
|
||||
start=turn.start,
|
||||
end=turn.end,
|
||||
confidence=turn.confidence,
|
||||
))
|
||||
|
||||
unique_speakers = set(t.speaker for t in merged)
|
||||
speaker_map = {s: s for s in unique_speakers}
|
||||
|
||||
host_time = sum(t.duration for t in merged if t.speaker == "HOST")
|
||||
caller_time = sum(t.duration for t in merged if t.speaker == "CALLER")
|
||||
console.print(f"[green]Diarization complete:[/green] {len(merged)} turns | "
|
||||
f"HOST {host_time:.0f}s / CALLER {caller_time:.0f}s")
|
||||
|
||||
return DiarizationResult(
|
||||
turns=merged,
|
||||
num_speakers=len(unique_speakers),
|
||||
speaker_map=speaker_map,
|
||||
)
|
||||
@@ -1,17 +0,0 @@
|
||||
"""GPU and CUDA library setup for the audio processor."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def ensure_cuda_libs():
|
||||
"""Ensure CUDA 12 libraries are on LD_LIBRARY_PATH.
|
||||
|
||||
The system has CUDA 13.2 but faster-whisper's ctranslate2 needs CUDA 12.
|
||||
Ollama ships CUDA 12 libs at /usr/local/lib/ollama/cuda_v12/.
|
||||
"""
|
||||
cuda12_path = "/usr/local/lib/ollama/cuda_v12"
|
||||
if Path(cuda12_path).exists():
|
||||
current = os.environ.get("LD_LIBRARY_PATH", "")
|
||||
if cuda12_path not in current:
|
||||
os.environ["LD_LIBRARY_PATH"] = f"{cuda12_path}:{current}" if current else cuda12_path
|
||||
@@ -1,247 +0,0 @@
|
||||
"""
|
||||
Archive transcript index using SQLite FTS5.
|
||||
Stores all transcript segments with speaker labels, searchable by keyword or phrase.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
DB_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS episodes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id TEXT UNIQUE NOT NULL, -- e.g. "2016-s8e42"
|
||||
date TEXT, -- "2016-03-15"
|
||||
audio_path TEXT, -- absolute path to original MP3
|
||||
duration REAL,
|
||||
hr INTEGER -- 1 or 2 (for split episodes)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS segments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id TEXT NOT NULL,
|
||||
seg_index INTEGER NOT NULL,
|
||||
start REAL NOT NULL,
|
||||
end REAL NOT NULL,
|
||||
speaker TEXT, -- "HOST", "CALLER", "UNKNOWN", "COMMERCIAL"
|
||||
text TEXT NOT NULL,
|
||||
FOREIGN KEY (episode_id) REFERENCES episodes(episode_id)
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS segments_fts USING fts5(
|
||||
text,
|
||||
speaker UNINDEXED,
|
||||
episode_id UNINDEXED,
|
||||
seg_index UNINDEXED,
|
||||
content='segments',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS segments_ai AFTER INSERT ON segments BEGIN
|
||||
INSERT INTO segments_fts(rowid, text, speaker, episode_id, seg_index)
|
||||
VALUES (new.id, new.text, new.speaker, new.episode_id, new.seg_index);
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qa_pairs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id TEXT NOT NULL,
|
||||
question_start REAL NOT NULL,
|
||||
question_end REAL NOT NULL,
|
||||
answer_start REAL NOT NULL,
|
||||
answer_end REAL NOT NULL,
|
||||
question_text TEXT NOT NULL,
|
||||
answer_text TEXT NOT NULL,
|
||||
topic TEXT, -- Ollama-tagged topic
|
||||
topic_tags TEXT, -- JSON array of tags
|
||||
FOREIGN KEY (episode_id) REFERENCES episodes(episode_id)
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS qa_fts USING fts5(
|
||||
question_text,
|
||||
answer_text,
|
||||
topic,
|
||||
episode_id UNINDEXED,
|
||||
content='qa_pairs',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS qa_ai AFTER INSERT ON qa_pairs BEGIN
|
||||
INSERT INTO qa_fts(rowid, question_text, answer_text, topic, episode_id)
|
||||
VALUES (new.id, new.question_text, new.answer_text, new.topic, new.episode_id);
|
||||
END;
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
episode_id: str
|
||||
date: str
|
||||
start: float
|
||||
end: float
|
||||
speaker: str
|
||||
text: str
|
||||
audio_path: str
|
||||
score: float = 0.0
|
||||
|
||||
def timestamp_str(self) -> str:
|
||||
def fmt(s):
|
||||
m, sec = divmod(int(s), 60)
|
||||
h, m = divmod(m, 60)
|
||||
return f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}"
|
||||
return f"{fmt(self.start)}–{fmt(self.end)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QAResult:
|
||||
episode_id: str
|
||||
date: str
|
||||
question_start: float
|
||||
question_end: float
|
||||
answer_start: float
|
||||
answer_end: float
|
||||
question_text: str
|
||||
answer_text: str
|
||||
topic: str
|
||||
audio_path: str
|
||||
|
||||
def clip_start(self, padding: float = 1.0) -> float:
|
||||
return max(0.0, self.question_start - padding)
|
||||
|
||||
def clip_end(self, padding: float = 1.0) -> float:
|
||||
return self.answer_end + padding
|
||||
|
||||
def timestamp_str(self) -> str:
|
||||
def fmt(s):
|
||||
m, sec = divmod(int(s), 60)
|
||||
h, m = divmod(m, 60)
|
||||
return f"{h}:{m:02d}:{sec:02d}" if h else f"{m}:{sec:02d}"
|
||||
return f"{fmt(self.question_start)}–{fmt(self.answer_end)}"
|
||||
|
||||
def duration(self) -> float:
|
||||
return self.answer_end - self.question_start
|
||||
|
||||
|
||||
class ArchiveIndex:
|
||||
def __init__(self, db_path: Path):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = sqlite3.connect(str(self.db_path))
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.executescript(DB_SCHEMA)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self):
|
||||
self._conn.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
self.close()
|
||||
|
||||
# ── Ingestion ──────────────────────────────────────────────────────────
|
||||
|
||||
def add_episode(self, episode_id: str, audio_path: Path,
|
||||
date: str = None, duration: float = None, hr: int = None):
|
||||
self._conn.execute(
|
||||
"INSERT OR IGNORE INTO episodes (episode_id, date, audio_path, duration, hr) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(episode_id, date, str(audio_path), duration, hr)
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def add_segments(self, episode_id: str, segments: list[dict]):
|
||||
"""Add transcript segments. Each dict: {start, end, text, speaker}."""
|
||||
existing = self._conn.execute(
|
||||
"SELECT COUNT(*) FROM segments WHERE episode_id = ?", (episode_id,)
|
||||
).fetchone()[0]
|
||||
if existing:
|
||||
return # already indexed
|
||||
|
||||
self._conn.executemany(
|
||||
"INSERT INTO segments (episode_id, seg_index, start, end, speaker, text) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
(episode_id, i, s["start"], s["end"],
|
||||
s.get("speaker", "UNKNOWN"), s["text"])
|
||||
for i, s in enumerate(segments)
|
||||
]
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def add_qa_pair(self, episode_id: str, q_start: float, q_end: float,
|
||||
a_start: float, a_end: float, question: str, answer: str,
|
||||
topic: str = None, tags: list[str] = None):
|
||||
self._conn.execute(
|
||||
"INSERT INTO qa_pairs "
|
||||
"(episode_id, question_start, question_end, answer_start, answer_end, "
|
||||
"question_text, answer_text, topic, topic_tags) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(episode_id, q_start, q_end, a_start, a_end, question, answer,
|
||||
topic, json.dumps(tags or []))
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# ── Search ─────────────────────────────────────────────────────────────
|
||||
|
||||
def search(self, query: str, speaker_filter: str = None,
|
||||
limit: int = 20) -> list[SearchResult]:
|
||||
"""Full-text search across all transcript segments."""
|
||||
speaker_clause = ""
|
||||
params = [query, limit]
|
||||
if speaker_filter:
|
||||
speaker_clause = "AND s.speaker = ?"
|
||||
params.insert(1, speaker_filter)
|
||||
|
||||
rows = self._conn.execute(f"""
|
||||
SELECT s.episode_id, e.date, s.start, s.end, s.speaker, s.text,
|
||||
e.audio_path, rank
|
||||
FROM segments_fts f
|
||||
JOIN segments s ON s.id = f.rowid
|
||||
JOIN episodes e ON e.episode_id = s.episode_id
|
||||
WHERE segments_fts MATCH ?
|
||||
{speaker_clause}
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
""", params).fetchall()
|
||||
|
||||
return [SearchResult(
|
||||
episode_id=r["episode_id"], date=r["date"] or r["episode_id"],
|
||||
start=r["start"], end=r["end"], speaker=r["speaker"],
|
||||
text=r["text"], audio_path=r["audio_path"], score=r["rank"]
|
||||
) for r in rows]
|
||||
|
||||
def search_qa(self, query: str, limit: int = 20) -> list[QAResult]:
|
||||
"""Search Q&A pairs — matches against question, answer, and topic."""
|
||||
rows = self._conn.execute("""
|
||||
SELECT q.episode_id, e.date, q.question_start, q.question_end,
|
||||
q.answer_start, q.answer_end, q.question_text, q.answer_text,
|
||||
q.topic, e.audio_path, rank
|
||||
FROM qa_fts f
|
||||
JOIN qa_pairs q ON q.id = f.rowid
|
||||
JOIN episodes e ON e.episode_id = q.episode_id
|
||||
WHERE qa_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
""", [query, limit]).fetchall()
|
||||
|
||||
return [QAResult(
|
||||
episode_id=r["episode_id"], date=r["date"] or r["episode_id"],
|
||||
question_start=r["question_start"], question_end=r["question_end"],
|
||||
answer_start=r["answer_start"], answer_end=r["answer_end"],
|
||||
question_text=r["question_text"], answer_text=r["answer_text"],
|
||||
topic=r["topic"] or "", audio_path=r["audio_path"]
|
||||
) for r in rows]
|
||||
|
||||
def stats(self) -> dict:
|
||||
return {
|
||||
"episodes": self._conn.execute("SELECT COUNT(*) FROM episodes").fetchone()[0],
|
||||
"segments": self._conn.execute("SELECT COUNT(*) FROM segments").fetchone()[0],
|
||||
"qa_pairs": self._conn.execute("SELECT COUNT(*) FROM qa_pairs").fetchone()[0],
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
"""
|
||||
Q&A pair extraction from diarized transcripts.
|
||||
|
||||
Identifies exchanges where a CALLER asks a question and the HOST answers.
|
||||
Outputs structured Q&A pairs with timestamps for clip extraction and indexing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
# Phrases that signal a caller is asking a question
|
||||
QUESTION_SIGNALS = [
|
||||
r"\?",
|
||||
r"\bhow (do|can|should|would|does)\b",
|
||||
r"\bwhat (is|are|should|can|do|does|about)\b",
|
||||
r"\bwhy (is|are|does|do|would|should)\b",
|
||||
r"\bis (it|there|this|that) (true|safe|possible|good|bad|worth)\b",
|
||||
r"\bshould i\b",
|
||||
r"\bcan you\b",
|
||||
r"\bi (was wondering|wanted to ask|have a question)\b",
|
||||
]
|
||||
|
||||
QUESTION_PATTERN = re.compile("|".join(QUESTION_SIGNALS), re.IGNORECASE)
|
||||
|
||||
# Minimum durations for a meaningful exchange
|
||||
MIN_QUESTION_DURATION = 5.0 # seconds
|
||||
MIN_ANSWER_DURATION = 15.0 # seconds
|
||||
MAX_GAP_BETWEEN_QA = 30.0 # seconds between question end and answer start
|
||||
|
||||
# ── Promo / bumper filter ──────────────────────────────────────────────────
|
||||
# Promos evolve across years but preserve signature phrases.
|
||||
# Weight 2 = highly distinctive (one match sufficient to filter).
|
||||
# Weight 1 = semi-generic (need 2+ to filter).
|
||||
# A question turn with total score >= PROMO_SCORE_THRESHOLD is suppressed.
|
||||
PROMO_SCORE_THRESHOLD = 2
|
||||
|
||||
_PROMO_SIGS: list[tuple[re.Pattern, int]] = [
|
||||
# Highly distinctive — score 2 each
|
||||
(re.compile(r"acquired a life of its own", re.I), 2),
|
||||
(re.compile(r"simply desire a deeper", re.I), 2),
|
||||
(re.compile(r"tame that beast", re.I), 2),
|
||||
(re.compile(r"mike swanson will be back after", re.I), 2),
|
||||
(re.compile(r"heaven forbid.{0,20}virus", re.I | re.DOTALL), 2),
|
||||
(re.compile(r"mike swanson is answering all", re.I), 2),
|
||||
# Semi-distinctive — score 1 each, need two to filter
|
||||
(re.compile(r"\bcomputer running slow\b", re.I), 1),
|
||||
(re.compile(r"\bafter these messages\b", re.I), 1),
|
||||
(re.compile(r"\b790.?2040\b", re.I), 1),
|
||||
(re.compile(r"\b751.?1041\b", re.I), 1),
|
||||
(re.compile(r"\bgurushow\.com\b", re.I), 1),
|
||||
(re.compile(r"\bcall in now\b", re.I), 1),
|
||||
(re.compile(r"\bcomputer troubles\?", re.I), 1),
|
||||
(re.compile(r"\bhardware installation\b", re.I), 1),
|
||||
(re.compile(r"we.?ll get your problem solved", re.I), 1),
|
||||
]
|
||||
|
||||
|
||||
def _is_promo_or_bumper(text: str) -> bool:
|
||||
"""Return True if text scores above threshold on show promo/bumper signatures."""
|
||||
score = sum(w for pat, w in _PROMO_SIGS if pat.search(text))
|
||||
return score >= PROMO_SCORE_THRESHOLD
|
||||
|
||||
|
||||
@dataclass
|
||||
class QAPair:
|
||||
question_start: float
|
||||
question_end: float
|
||||
answer_start: float
|
||||
answer_end: float
|
||||
question_text: str
|
||||
answer_text: str
|
||||
topic: Optional[str] = None
|
||||
topic_tags: list[str] = field(default_factory=list)
|
||||
# Caller identification — populated by attach_caller_names() when a
|
||||
# transcript-side speaker intro precedes the question turn.
|
||||
caller_name: Optional[str] = None
|
||||
caller_role: Optional[str] = None # "caller" / "guest" / "fillin"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"question_start": self.question_start,
|
||||
"question_end": self.question_end,
|
||||
"answer_start": self.answer_start,
|
||||
"answer_end": self.answer_end,
|
||||
"question_text": self.question_text,
|
||||
"answer_text": self.answer_text,
|
||||
"topic": self.topic,
|
||||
"topic_tags": self.topic_tags,
|
||||
"caller_name": self.caller_name,
|
||||
"caller_role": self.caller_role,
|
||||
}
|
||||
|
||||
def clip_start(self, padding: float = 1.5) -> float:
|
||||
return max(0.0, self.question_start - padding)
|
||||
|
||||
def clip_end(self, padding: float = 1.5) -> float:
|
||||
return self.answer_end + padding
|
||||
|
||||
def duration(self) -> float:
|
||||
return self.answer_end - self.question_start
|
||||
|
||||
|
||||
def attach_caller_names(pairs: list[QAPair],
|
||||
transcript_segments: list[dict]) -> list[QAPair]:
|
||||
"""Attach caller names to Q&A pairs using transcript-side speaker intros.
|
||||
|
||||
For each pair, looks up the active intro at the question_start time and
|
||||
populates caller_name / caller_role. Mutates the input pairs and returns
|
||||
the list for chaining.
|
||||
"""
|
||||
from .speaker_oracle import extract_intros, speaker_at
|
||||
intros = extract_intros(transcript_segments)
|
||||
for pair in pairs:
|
||||
intro = speaker_at(pair.question_start, intros)
|
||||
if intro is not None:
|
||||
pair.caller_name = intro.name
|
||||
pair.caller_role = intro.role_hint
|
||||
return pairs
|
||||
|
||||
|
||||
def extract_qa_pairs(diarized_segments: list[dict]) -> list[QAPair]:
|
||||
"""
|
||||
Extract caller Q&A pairs from diarized transcript segments.
|
||||
|
||||
Each segment dict: {start, end, text, speaker}
|
||||
Speaker values: "HOST", "CALLER", "UNKNOWN"
|
||||
"""
|
||||
pairs = []
|
||||
|
||||
# Group consecutive segments by speaker into speaker turns
|
||||
turns = _merge_consecutive_speaker_turns(diarized_segments)
|
||||
|
||||
# Check if diarization produced any non-HOST speakers
|
||||
has_caller_labels = any(t["speaker"] in ("CALLER", "UNKNOWN") for t in turns)
|
||||
|
||||
if not has_caller_labels:
|
||||
# Diarization labels are absent or unreliable — fall back to text-pattern detection
|
||||
return _extract_qa_text_only(turns)
|
||||
|
||||
i = 0
|
||||
while i < len(turns):
|
||||
turn = turns[i]
|
||||
|
||||
# Look for a CALLER turn that looks like a question
|
||||
if turn["speaker"] in ("CALLER", "UNKNOWN") and _looks_like_question(turn["text"]):
|
||||
if _is_promo_or_bumper(turn["text"]):
|
||||
i += 1
|
||||
continue
|
||||
# Skip the opening 90s — real callers never call before the show starts
|
||||
if turn["start"] < 90:
|
||||
i += 1
|
||||
continue
|
||||
q_duration = turn["end"] - turn["start"]
|
||||
if q_duration < MIN_QUESTION_DURATION:
|
||||
i += 1
|
||||
continue
|
||||
# Require caller-intro context: host must have introduced the call, OR
|
||||
# the caller opens with a phone greeting ("hello", "hi", "hey")
|
||||
if not _preceded_by_caller_intro(turns, i) and not _PHONE_GREETING.match(turn["text"].strip()):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Look ahead for HOST answer turn(s)
|
||||
j = i + 1
|
||||
answer_turns = []
|
||||
while j < len(turns):
|
||||
next_turn = turns[j]
|
||||
gap = next_turn["start"] - turns[j - 1]["end"]
|
||||
|
||||
if gap > MAX_GAP_BETWEEN_QA and not answer_turns:
|
||||
break # too big a gap before any answer
|
||||
|
||||
if next_turn["speaker"] == "HOST":
|
||||
answer_turns.append(next_turn)
|
||||
# Keep collecting consecutive HOST turns
|
||||
j += 1
|
||||
while j < len(turns) and turns[j]["speaker"] == "HOST":
|
||||
answer_turns.append(turns[j])
|
||||
j += 1
|
||||
break
|
||||
elif next_turn["speaker"] in ("CALLER", "UNKNOWN"):
|
||||
# Another caller turn before host answered — skip this question
|
||||
break
|
||||
else:
|
||||
j += 1
|
||||
|
||||
if answer_turns:
|
||||
answer_text = " ".join(t["text"] for t in answer_turns)
|
||||
answer_duration = answer_turns[-1]["end"] - answer_turns[0]["start"]
|
||||
|
||||
if answer_duration >= MIN_ANSWER_DURATION:
|
||||
pairs.append(QAPair(
|
||||
question_start=turn["start"],
|
||||
question_end=turn["end"],
|
||||
answer_start=answer_turns[0]["start"],
|
||||
answer_end=answer_turns[-1]["end"],
|
||||
question_text=turn["text"].strip(),
|
||||
answer_text=answer_text.strip(),
|
||||
))
|
||||
i = j
|
||||
continue
|
||||
|
||||
i += 1
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
# Maximum duration for a question turn in text-only mode — avoids capturing monologues
|
||||
_MAX_QUESTION_S_TEXT_MODE = 90.0
|
||||
|
||||
# Caller introduction phrases Mike uses before taking a call
|
||||
_CALLER_INTRO = re.compile(
|
||||
r"\b(let'?s go to|going to the phones?|you'?re on the air|on the air|"
|
||||
r"first caller|next caller|caller from|go ahead|what'?s (your question|going on)|"
|
||||
r"welcome to the show|thanks for calling|thank you for calling|"
|
||||
r"our (first|next|last) (caller|call)|taking (a |your )?call)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_qa_text_only(turns: list[dict]) -> list[QAPair]:
|
||||
"""
|
||||
Q&A extraction when speaker labels are unavailable or all HOST.
|
||||
|
||||
Uses text patterns to identify question anchors. Works well for call-in
|
||||
radio format where callers describe problems and the host answers at length.
|
||||
Captures both genuine caller questions and Mike's own rhetorical Q&A segments.
|
||||
"""
|
||||
pairs = []
|
||||
|
||||
i = 0
|
||||
while i < len(turns):
|
||||
turn = turns[i]
|
||||
q_duration = turn["end"] - turn["start"]
|
||||
|
||||
is_q_candidate = (
|
||||
_looks_like_question(turn["text"])
|
||||
and MIN_QUESTION_DURATION <= q_duration <= _MAX_QUESTION_S_TEXT_MODE
|
||||
)
|
||||
|
||||
# Also treat segments immediately after a caller-intro phrase as candidates
|
||||
if not is_q_candidate and i > 0:
|
||||
prev_text = turns[i - 1]["text"]
|
||||
if _CALLER_INTRO.search(prev_text) and q_duration >= MIN_QUESTION_DURATION:
|
||||
is_q_candidate = True
|
||||
|
||||
if is_q_candidate and _is_promo_or_bumper(turn["text"]):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if is_q_candidate:
|
||||
# Collect following segments as the answer until we hit another question
|
||||
j = i + 1
|
||||
answer_turns = []
|
||||
|
||||
while j < len(turns):
|
||||
next_turn = turns[j]
|
||||
gap = next_turn["start"] - turns[j - 1]["end"]
|
||||
|
||||
if gap > MAX_GAP_BETWEEN_QA and not answer_turns:
|
||||
break
|
||||
|
||||
# Stop collecting if we hit another short question-pattern turn
|
||||
if (
|
||||
_looks_like_question(next_turn["text"])
|
||||
and (next_turn["end"] - next_turn["start"]) <= _MAX_QUESTION_S_TEXT_MODE
|
||||
and answer_turns
|
||||
):
|
||||
break
|
||||
|
||||
answer_turns.append(next_turn)
|
||||
j += 1
|
||||
|
||||
# Stop once we have a substantial answer block
|
||||
if answer_turns:
|
||||
ans_dur = answer_turns[-1]["end"] - answer_turns[0]["start"]
|
||||
if ans_dur >= MIN_ANSWER_DURATION * 3:
|
||||
break
|
||||
|
||||
if answer_turns:
|
||||
answer_text = " ".join(t["text"] for t in answer_turns)
|
||||
answer_duration = answer_turns[-1]["end"] - answer_turns[0]["start"]
|
||||
|
||||
if answer_duration >= MIN_ANSWER_DURATION:
|
||||
pairs.append(QAPair(
|
||||
question_start=turn["start"],
|
||||
question_end=turn["end"],
|
||||
answer_start=answer_turns[0]["start"],
|
||||
answer_end=answer_turns[-1]["end"],
|
||||
question_text=turn["text"].strip(),
|
||||
answer_text=answer_text.strip(),
|
||||
))
|
||||
i = j
|
||||
continue
|
||||
|
||||
i += 1
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def tag_qa_pairs_with_ollama(pairs: list[QAPair], ollama_host: str = "http://localhost:11434",
|
||||
model: str = "qwen3:14b") -> list[QAPair]:
|
||||
"""Use Ollama to tag each Q&A pair with a topic and tags."""
|
||||
try:
|
||||
import ollama
|
||||
client = ollama.Client(host=ollama_host)
|
||||
except ImportError:
|
||||
console.print("[yellow]ollama not installed — skipping topic tagging[/yellow]")
|
||||
return pairs
|
||||
|
||||
for i, pair in enumerate(pairs):
|
||||
console.print(f"[dim]Tagging Q&A {i+1}/{len(pairs)}...[/dim]")
|
||||
try:
|
||||
prompt = (
|
||||
f"A radio show caller asked:\n\"{pair.question_text[:300]}\"\n\n"
|
||||
f"The host answered:\n\"{pair.answer_text[:500]}\"\n\n"
|
||||
"Respond with JSON only, no explanation:\n"
|
||||
'{"topic": "short topic name (3-5 words)", "tags": ["tag1", "tag2", "tag3"]}'
|
||||
)
|
||||
resp = client.chat(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
options={"temperature": 0},
|
||||
)
|
||||
raw = resp["message"]["content"].strip()
|
||||
# Extract JSON from response
|
||||
start = raw.find("{")
|
||||
end = raw.rfind("}") + 1
|
||||
if start >= 0 and end > start:
|
||||
data = json.loads(raw[start:end])
|
||||
pair.topic = data.get("topic", "")
|
||||
pair.topic_tags = data.get("tags", [])
|
||||
except Exception as e:
|
||||
console.print(f"[yellow]Tagging failed for pair {i+1}: {e}[/yellow]")
|
||||
|
||||
return pairs
|
||||
|
||||
|
||||
def load_diarized_transcript(transcript_path: Path,
|
||||
diarization_path: Optional[Path]) -> list[dict]:
|
||||
"""
|
||||
Merge transcript and diarization into speaker-labeled segments.
|
||||
Falls back to HOST-only if no diarization available.
|
||||
"""
|
||||
with open(transcript_path) as f:
|
||||
transcript = json.load(f)
|
||||
|
||||
segments = transcript["segments"]
|
||||
|
||||
if diarization_path is None or not diarization_path.exists():
|
||||
return [
|
||||
{"start": s["start"], "end": s["end"],
|
||||
"text": s["text"], "speaker": "HOST"}
|
||||
for s in segments
|
||||
]
|
||||
|
||||
with open(diarization_path) as f:
|
||||
diarization = json.load(f)
|
||||
|
||||
raw_turns = diarization.get("turns", [])
|
||||
|
||||
# Resolve overlapping boundaries left by the sliding-window diarizer:
|
||||
# place each transition at the midpoint of the overlap region.
|
||||
resolved: list[dict] = []
|
||||
for turn in sorted(raw_turns, key=lambda t: t["start"]):
|
||||
if not resolved:
|
||||
resolved.append(dict(turn))
|
||||
continue
|
||||
prev = resolved[-1]
|
||||
if turn["start"] < prev["end"]:
|
||||
mid = (turn["start"] + prev["end"]) / 2
|
||||
prev["end"] = mid
|
||||
resolved.append({**turn, "start": mid})
|
||||
else:
|
||||
resolved.append(dict(turn))
|
||||
turns = resolved
|
||||
|
||||
# Minimum CALLER coverage to label a transcript segment as CALLER.
|
||||
# Batch transcription produces ~25s segments; caller windows are 10s.
|
||||
# Require 4s of CALLER overlap so brief HOST-edge segments aren't over-claimed.
|
||||
_CALLER_MIN_S = 4.0
|
||||
|
||||
def speaker_for_segment(seg_start: float, seg_end: float) -> str:
|
||||
caller_cov = 0.0
|
||||
coverage: dict[str, float] = {}
|
||||
for turn in turns:
|
||||
overlap = min(seg_end, turn["end"]) - max(seg_start, turn["start"])
|
||||
if overlap <= 0:
|
||||
continue
|
||||
coverage[turn["speaker"]] = coverage.get(turn["speaker"], 0) + overlap
|
||||
if turn["speaker"] == "CALLER":
|
||||
caller_cov += overlap
|
||||
if not coverage:
|
||||
return "UNKNOWN"
|
||||
if caller_cov >= _CALLER_MIN_S:
|
||||
return "CALLER"
|
||||
return max(coverage, key=coverage.__getitem__)
|
||||
|
||||
return [
|
||||
{"start": s["start"], "end": s["end"],
|
||||
"text": s["text"],
|
||||
"speaker": speaker_for_segment(s["start"], s["end"])}
|
||||
for s in segments
|
||||
]
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
_PHONE_GREETING = re.compile(r"^(hello|hi|hey|good (morning|afternoon|evening))\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _preceded_by_caller_intro(turns: list[dict], idx: int, max_host_turns: int = 2) -> bool:
|
||||
"""Return True if a preceding HOST turn (within max_host_turns HOST turns) contains a caller-intro phrase."""
|
||||
host_count = 0
|
||||
for j in range(idx - 1, -1, -1):
|
||||
if turns[j]["speaker"] == "HOST":
|
||||
if _CALLER_INTRO.search(turns[j]["text"]):
|
||||
return True
|
||||
host_count += 1
|
||||
if host_count >= max_host_turns:
|
||||
break
|
||||
return False
|
||||
|
||||
|
||||
def _looks_like_question(text: str) -> bool:
|
||||
return bool(QUESTION_PATTERN.search(text))
|
||||
|
||||
|
||||
def _merge_consecutive_speaker_turns(segments: list[dict]) -> list[dict]:
|
||||
"""Merge adjacent segments from the same speaker into continuous turns."""
|
||||
if not segments:
|
||||
return []
|
||||
|
||||
turns = []
|
||||
current = dict(segments[0])
|
||||
|
||||
for seg in segments[1:]:
|
||||
if seg["speaker"] == current["speaker"]:
|
||||
current["end"] = seg["end"]
|
||||
current["text"] = current["text"].rstrip() + " " + seg["text"].lstrip()
|
||||
else:
|
||||
turns.append(current)
|
||||
current = dict(seg)
|
||||
|
||||
turns.append(current)
|
||||
return turns
|
||||
@@ -1,569 +0,0 @@
|
||||
"""Stage 3: Segment detection — multi-signal commercial/show content classifier."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from enum import Enum
|
||||
|
||||
import numpy as np
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
class SegmentType(Enum):
|
||||
SHOW_CONTENT = "show_content"
|
||||
COMMERCIAL = "commercial"
|
||||
SHOW_ELEMENT = "show_element" # intro, outro, bumper
|
||||
SILENCE = "silence"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectedSegment:
|
||||
start: float
|
||||
end: float
|
||||
segment_type: SegmentType
|
||||
confidence: float
|
||||
label: str = "" # "Segment 1: The Week That Was", "Commercial Break 1", etc.
|
||||
signals: dict = None # Individual signal scores
|
||||
|
||||
def __post_init__(self):
|
||||
if self.signals is None:
|
||||
self.signals = {}
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return self.end - self.start
|
||||
|
||||
|
||||
@dataclass
|
||||
class SegmentDetectionResult:
|
||||
segments: list[DetectedSegment]
|
||||
show_segments: list[DetectedSegment]
|
||||
commercial_segments: list[DetectedSegment]
|
||||
element_segments: list[DetectedSegment]
|
||||
total_show_time: float
|
||||
total_commercial_time: float
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"total_show_time": self.total_show_time,
|
||||
"total_commercial_time": self.total_commercial_time,
|
||||
"segments": [
|
||||
{
|
||||
"start": s.start,
|
||||
"end": s.end,
|
||||
"type": s.segment_type.value,
|
||||
"confidence": s.confidence,
|
||||
"label": s.label,
|
||||
"signals": s.signals,
|
||||
}
|
||||
for s in self.segments
|
||||
],
|
||||
}
|
||||
|
||||
def save(self, output_dir: Path):
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_dir / "detection-report.json", "w") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
def print_summary(self):
|
||||
table = Table(title="Segment Detection Results")
|
||||
table.add_column("Time", style="cyan")
|
||||
table.add_column("Duration", style="magenta")
|
||||
table.add_column("Type", style="green")
|
||||
table.add_column("Confidence", style="yellow")
|
||||
table.add_column("Label")
|
||||
|
||||
for seg in self.segments:
|
||||
start = _format_time(seg.start)
|
||||
dur = f"{seg.duration:.0f}s"
|
||||
type_style = {
|
||||
SegmentType.SHOW_CONTENT: "[green]SHOW[/green]",
|
||||
SegmentType.COMMERCIAL: "[red]COMMERCIAL[/red]",
|
||||
SegmentType.SHOW_ELEMENT: "[blue]ELEMENT[/blue]",
|
||||
SegmentType.SILENCE: "[dim]SILENCE[/dim]",
|
||||
SegmentType.UNKNOWN: "[yellow]UNKNOWN[/yellow]",
|
||||
}.get(seg.segment_type, str(seg.segment_type))
|
||||
|
||||
table.add_row(start, dur, type_style, f"{seg.confidence:.2f}", seg.label)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\nShow content: {self.total_show_time / 60:.1f} min")
|
||||
console.print(f"Commercials: {self.total_commercial_time / 60:.1f} min")
|
||||
|
||||
|
||||
def _format_time(seconds: float) -> str:
|
||||
m = int(seconds // 60)
|
||||
s = int(seconds % 60)
|
||||
return f"{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
class SegmentDetector:
|
||||
"""Multi-signal commercial/show content detector."""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.weights = config.segment_detection.weights
|
||||
|
||||
def detect(self, audio_path: Path, transcript=None, diarization=None,
|
||||
show_prep=None) -> SegmentDetectionResult:
|
||||
"""Run all detection signals and combine scores."""
|
||||
console.print(f"[bold]Detecting segments:[/bold] {audio_path.name}")
|
||||
|
||||
# Load audio for analysis
|
||||
audio_data, sample_rate = self._load_audio(audio_path)
|
||||
duration = len(audio_data) / sample_rate
|
||||
|
||||
# Step 1: Find candidate boundaries using silence detection
|
||||
boundaries = self._detect_silence_boundaries(audio_data, sample_rate)
|
||||
console.print(f"[dim]Found {len(boundaries)} silence boundaries[/dim]")
|
||||
|
||||
# Step 2: Find hard break points from transcript (most reliable signal)
|
||||
transcript_breaks = []
|
||||
if transcript:
|
||||
transcript_breaks = self._find_transcript_breaks(transcript)
|
||||
console.print(f"[dim]Found {len(transcript_breaks)} break cues in transcript[/dim]")
|
||||
|
||||
# Step 3: Create segments using transcript breaks as primary boundaries,
|
||||
# with silence boundaries refining the exact cut points
|
||||
if transcript_breaks:
|
||||
candidates = self._create_segments_from_breaks(
|
||||
transcript_breaks, boundaries, audio_data, sample_rate, duration
|
||||
)
|
||||
else:
|
||||
candidates = self._create_candidate_segments(boundaries, duration)
|
||||
|
||||
# Step 4: Score each candidate with all available signals
|
||||
for candidate in candidates:
|
||||
scores = {}
|
||||
|
||||
scores["fingerprint"] = self._score_fingerprint(
|
||||
audio_data, sample_rate, candidate
|
||||
)
|
||||
|
||||
if diarization:
|
||||
scores["speaker"] = self._score_speaker_identity(
|
||||
diarization, candidate
|
||||
)
|
||||
else:
|
||||
scores["speaker"] = 0.5
|
||||
|
||||
scores["audio_chars"] = self._score_audio_characteristics(
|
||||
audio_data, sample_rate, candidate
|
||||
)
|
||||
|
||||
if transcript:
|
||||
scores["structural"] = self._score_structural(
|
||||
transcript, candidate
|
||||
)
|
||||
else:
|
||||
scores["structural"] = 0.5
|
||||
|
||||
commercial_score = (
|
||||
self.weights.fingerprint_match * scores.get("fingerprint", 0.5) +
|
||||
self.weights.speaker_identity * scores.get("speaker", 0.5) +
|
||||
self.weights.audio_characteristics * scores.get("audio_chars", 0.5) +
|
||||
self.weights.structural_heuristic * scores.get("structural", 0.5)
|
||||
)
|
||||
|
||||
candidate.signals = scores
|
||||
|
||||
# If segment was already typed by transcript breaks, keep it
|
||||
if candidate.segment_type == SegmentType.UNKNOWN:
|
||||
candidate.confidence = commercial_score
|
||||
if commercial_score >= self.config.segment_detection.confidence_threshold:
|
||||
candidate.segment_type = SegmentType.COMMERCIAL
|
||||
else:
|
||||
candidate.segment_type = SegmentType.SHOW_CONTENT
|
||||
else:
|
||||
candidate.confidence = max(commercial_score, 0.80)
|
||||
|
||||
# Step 5: Merge adjacent segments of same type
|
||||
merged = self._merge_adjacent(candidates)
|
||||
|
||||
# Step 6: Apply duration constraints
|
||||
final = self._apply_constraints(merged)
|
||||
|
||||
# Step 7: Label show segments using show prep if available
|
||||
if show_prep:
|
||||
self._label_from_prep(final, transcript, show_prep)
|
||||
|
||||
# Build result
|
||||
show_segs = [s for s in final if s.segment_type == SegmentType.SHOW_CONTENT]
|
||||
comm_segs = [s for s in final if s.segment_type == SegmentType.COMMERCIAL]
|
||||
elem_segs = [s for s in final if s.segment_type == SegmentType.SHOW_ELEMENT]
|
||||
|
||||
result = SegmentDetectionResult(
|
||||
segments=final,
|
||||
show_segments=show_segs,
|
||||
commercial_segments=comm_segs,
|
||||
element_segments=elem_segs,
|
||||
total_show_time=sum(s.duration for s in show_segs),
|
||||
total_commercial_time=sum(s.duration for s in comm_segs),
|
||||
)
|
||||
|
||||
result.print_summary()
|
||||
return result
|
||||
|
||||
def _load_audio(self, audio_path: Path) -> tuple[np.ndarray, int]:
|
||||
"""Load audio file as mono numpy array."""
|
||||
import subprocess
|
||||
import io
|
||||
import struct
|
||||
|
||||
# Use ffmpeg to decode to raw PCM
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-i", str(audio_path), "-f", "s16le", "-ac", "1",
|
||||
"-ar", "16000", "-"],
|
||||
capture_output=True, timeout=300,
|
||||
)
|
||||
audio = np.frombuffer(result.stdout, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
return audio, 16000
|
||||
|
||||
def _detect_silence_boundaries(self, audio: np.ndarray, sr: int,
|
||||
min_silence_ms: int = 500) -> list[float]:
|
||||
"""Detect silence gaps in audio that likely indicate segment boundaries."""
|
||||
frame_size = int(sr * 0.025) # 25ms frames
|
||||
hop_size = int(sr * 0.010) # 10ms hop
|
||||
threshold_db = self.config.segment_detection.silence_threshold_db
|
||||
threshold_amp = 10 ** (threshold_db / 20)
|
||||
min_silence_frames = int(min_silence_ms / 10)
|
||||
|
||||
# Calculate frame energy
|
||||
energies = []
|
||||
for i in range(0, len(audio) - frame_size, hop_size):
|
||||
frame = audio[i:i + frame_size]
|
||||
rms = np.sqrt(np.mean(frame ** 2))
|
||||
energies.append(rms)
|
||||
|
||||
# Find silence regions
|
||||
is_silent = [e < threshold_amp for e in energies]
|
||||
boundaries = []
|
||||
silent_count = 0
|
||||
|
||||
for i, silent in enumerate(is_silent):
|
||||
if silent:
|
||||
silent_count += 1
|
||||
else:
|
||||
if silent_count >= min_silence_frames:
|
||||
# Mark the midpoint of the silence as a boundary
|
||||
mid_frame = i - silent_count // 2
|
||||
boundary_time = mid_frame * 0.010
|
||||
boundaries.append(boundary_time)
|
||||
silent_count = 0
|
||||
|
||||
return boundaries
|
||||
|
||||
def _find_transcript_breaks(self, transcript) -> list[dict]:
|
||||
"""Find commercial break points from transcript content."""
|
||||
break_cues = []
|
||||
going_to_break = [
|
||||
"take a quick break", "take a break", "go to commercial",
|
||||
"going to break", "let's go to break", "we'll be right back",
|
||||
"right back after", "news break coming up", "after the news",
|
||||
"be right back", "stay tuned", "don't go anywhere",
|
||||
]
|
||||
coming_back = [
|
||||
"welcome back", "we're back", "we are back", "back from the break",
|
||||
"back from break", "back on the", "back with you",
|
||||
]
|
||||
|
||||
for seg in transcript.segments:
|
||||
text = seg.text.lower().strip()
|
||||
for cue in going_to_break:
|
||||
if cue in text:
|
||||
break_cues.append({
|
||||
"type": "break_start",
|
||||
"time": seg.end,
|
||||
"text": seg.text.strip(),
|
||||
"cue": cue,
|
||||
})
|
||||
break
|
||||
for cue in coming_back:
|
||||
if cue in text:
|
||||
break_cues.append({
|
||||
"type": "break_end",
|
||||
"time": seg.start,
|
||||
"text": seg.text.strip(),
|
||||
"cue": cue,
|
||||
})
|
||||
break
|
||||
|
||||
return break_cues
|
||||
|
||||
def _create_segments_from_breaks(self, transcript_breaks: list[dict],
|
||||
silence_boundaries: list[float],
|
||||
audio: np.ndarray, sr: int,
|
||||
total_duration: float) -> list[DetectedSegment]:
|
||||
"""Create segments using transcript break cues as primary boundaries.
|
||||
|
||||
For each break_start, find the nearest silence boundary after it (exact cut point).
|
||||
For each break_end, find the nearest silence boundary before it.
|
||||
The gap between break_start and break_end = commercial break.
|
||||
"""
|
||||
segments = []
|
||||
|
||||
# Pair up break_start with the next break_end
|
||||
break_regions = []
|
||||
i = 0
|
||||
while i < len(transcript_breaks):
|
||||
cue = transcript_breaks[i]
|
||||
if cue["type"] == "break_start":
|
||||
# Find the matching break_end
|
||||
end_time = None
|
||||
for j in range(i + 1, len(transcript_breaks)):
|
||||
if transcript_breaks[j]["type"] == "break_end":
|
||||
end_time = transcript_breaks[j]["time"]
|
||||
i = j + 1
|
||||
break
|
||||
if end_time is None:
|
||||
# No matching end — assume break lasts until a reasonable point
|
||||
# (5 minutes max, or until end of audio)
|
||||
end_time = min(cue["time"] + 300, total_duration)
|
||||
i += 1
|
||||
|
||||
# Snap to nearest silence boundaries for clean cuts
|
||||
start = self._nearest_silence(cue["time"], silence_boundaries, after=True)
|
||||
end = self._nearest_silence(end_time, silence_boundaries, after=False)
|
||||
|
||||
if start and end and end > start:
|
||||
break_regions.append((start, end))
|
||||
elif start:
|
||||
break_regions.append((start, end_time))
|
||||
else:
|
||||
i += 1
|
||||
|
||||
if not break_regions:
|
||||
return self._create_candidate_segments(silence_boundaries, total_duration)
|
||||
|
||||
# Build segments: show → commercial → show → commercial → ...
|
||||
prev_end = 0.0
|
||||
for break_start, break_end in break_regions:
|
||||
# Show content before this break
|
||||
if break_start - prev_end > 1.0:
|
||||
segments.append(DetectedSegment(
|
||||
start=prev_end,
|
||||
end=break_start,
|
||||
segment_type=SegmentType.SHOW_CONTENT,
|
||||
confidence=0.85,
|
||||
label="",
|
||||
))
|
||||
|
||||
# Commercial break
|
||||
segments.append(DetectedSegment(
|
||||
start=break_start,
|
||||
end=break_end,
|
||||
segment_type=SegmentType.COMMERCIAL,
|
||||
confidence=0.85,
|
||||
label="",
|
||||
))
|
||||
prev_end = break_end
|
||||
|
||||
# Final show segment after last break
|
||||
if total_duration - prev_end > 1.0:
|
||||
segments.append(DetectedSegment(
|
||||
start=prev_end,
|
||||
end=total_duration,
|
||||
segment_type=SegmentType.SHOW_CONTENT,
|
||||
confidence=0.85,
|
||||
label="",
|
||||
))
|
||||
|
||||
return segments
|
||||
|
||||
def _nearest_silence(self, time: float, boundaries: list[float],
|
||||
after: bool = True, max_distance: float = 10.0) -> float | None:
|
||||
"""Find the nearest silence boundary to a given time."""
|
||||
best = None
|
||||
best_dist = max_distance
|
||||
|
||||
for b in boundaries:
|
||||
dist = abs(b - time)
|
||||
if dist > max_distance:
|
||||
continue
|
||||
if after and b >= time and dist < best_dist:
|
||||
best = b
|
||||
best_dist = dist
|
||||
elif not after and b <= time and dist < best_dist:
|
||||
best = b
|
||||
best_dist = dist
|
||||
|
||||
return best
|
||||
|
||||
def _create_candidate_segments(self, boundaries: list[float],
|
||||
total_duration: float) -> list[DetectedSegment]:
|
||||
"""Create candidate segments from silence boundaries."""
|
||||
candidates = []
|
||||
prev = 0.0
|
||||
|
||||
for boundary in boundaries:
|
||||
if boundary - prev > 1.0: # Ignore segments < 1 second
|
||||
candidates.append(DetectedSegment(
|
||||
start=prev,
|
||||
end=boundary,
|
||||
segment_type=SegmentType.UNKNOWN,
|
||||
confidence=0.0,
|
||||
))
|
||||
prev = boundary
|
||||
|
||||
# Final segment
|
||||
if total_duration - prev > 1.0:
|
||||
candidates.append(DetectedSegment(
|
||||
start=prev,
|
||||
end=total_duration,
|
||||
segment_type=SegmentType.UNKNOWN,
|
||||
confidence=0.0,
|
||||
))
|
||||
|
||||
return candidates
|
||||
|
||||
def _score_fingerprint(self, audio: np.ndarray, sr: int,
|
||||
segment: DetectedSegment) -> float:
|
||||
"""Score based on audio fingerprint matching against element library.
|
||||
Returns 0.0 (no match / definitely show) to 1.0 (definite commercial boundary).
|
||||
"""
|
||||
# TODO: Implement fingerprint matching against element-library/fingerprints.db
|
||||
# For now, return neutral score
|
||||
return 0.5
|
||||
|
||||
def _score_speaker_identity(self, diarization, segment: DetectedSegment) -> float:
|
||||
"""Score based on whether the host is speaking.
|
||||
Returns 0.0 (host definitely speaking = show content)
|
||||
to 1.0 (host definitely absent = likely commercial).
|
||||
"""
|
||||
host_time = 0.0
|
||||
total_time = segment.duration
|
||||
|
||||
for turn in diarization.turns:
|
||||
if turn.end < segment.start or turn.start > segment.end:
|
||||
continue
|
||||
# Calculate overlap
|
||||
overlap_start = max(turn.start, segment.start)
|
||||
overlap_end = min(turn.end, segment.end)
|
||||
overlap = max(0, overlap_end - overlap_start)
|
||||
|
||||
if "host" in turn.speaker.lower():
|
||||
host_time += overlap
|
||||
|
||||
if total_time == 0:
|
||||
return 0.5
|
||||
|
||||
host_fraction = host_time / total_time
|
||||
# Invert: high host presence = low commercial score
|
||||
return 1.0 - host_fraction
|
||||
|
||||
def _score_audio_characteristics(self, audio: np.ndarray, sr: int,
|
||||
segment: DetectedSegment) -> float:
|
||||
"""Score based on audio production characteristics.
|
||||
Commercials tend to be louder, more compressed, different spectral profile.
|
||||
Returns 0.0 (matches show characteristics) to 1.0 (matches commercial characteristics).
|
||||
"""
|
||||
start_sample = int(segment.start * sr)
|
||||
end_sample = min(int(segment.end * sr), len(audio))
|
||||
seg_audio = audio[start_sample:end_sample]
|
||||
|
||||
if len(seg_audio) < sr: # Less than 1 second
|
||||
return 0.5
|
||||
|
||||
# RMS energy (commercials tend to be louder)
|
||||
rms = np.sqrt(np.mean(seg_audio ** 2))
|
||||
|
||||
# Dynamic range (commercials tend to be more compressed)
|
||||
frame_size = int(sr * 0.050) # 50ms frames
|
||||
frame_rms = []
|
||||
for i in range(0, len(seg_audio) - frame_size, frame_size):
|
||||
frame = seg_audio[i:i + frame_size]
|
||||
frame_rms.append(np.sqrt(np.mean(frame ** 2)))
|
||||
|
||||
if not frame_rms:
|
||||
return 0.5
|
||||
|
||||
dynamic_range = max(frame_rms) / (min(frame_rms) + 1e-8)
|
||||
|
||||
# Simple heuristic scoring:
|
||||
# High RMS + low dynamic range = compressed commercial audio
|
||||
score = 0.5
|
||||
if rms > 0.15: # Louder than typical speech
|
||||
score += 0.15
|
||||
if dynamic_range < 5.0: # Very compressed
|
||||
score += 0.15
|
||||
|
||||
return min(1.0, max(0.0, score))
|
||||
|
||||
def _score_structural(self, transcript, segment: DetectedSegment) -> float:
|
||||
"""Score based on transcript content structural cues.
|
||||
Returns 0.0 (show content cues found) to 1.0 (commercial cues found).
|
||||
"""
|
||||
text = transcript.text_at(segment.start, segment.end).lower()
|
||||
|
||||
# Show content indicators
|
||||
show_phrases = [
|
||||
"welcome back", "let's move on", "next up", "our next topic",
|
||||
"let's talk about", "as i mentioned", "the question is",
|
||||
"caller", "what do you think", "here's the thing",
|
||||
]
|
||||
# Commercial/break indicators
|
||||
break_phrases = [
|
||||
"we'll be right back", "stay tuned", "don't go anywhere",
|
||||
"after the break", "when we come back",
|
||||
]
|
||||
|
||||
show_hits = sum(1 for p in show_phrases if p in text)
|
||||
break_hits = sum(1 for p in break_phrases if p in text)
|
||||
|
||||
if show_hits > 0 and break_hits == 0:
|
||||
return 0.2 # Likely show content
|
||||
if break_hits > 0:
|
||||
return 0.8 # Likely near a break
|
||||
return 0.5 # Neutral
|
||||
|
||||
def _merge_adjacent(self, segments: list[DetectedSegment]) -> list[DetectedSegment]:
|
||||
"""Merge adjacent and overlapping segments of the same type."""
|
||||
if not segments:
|
||||
return []
|
||||
|
||||
# Sort by start time first
|
||||
segments.sort(key=lambda s: s.start)
|
||||
|
||||
merged = [segments[0]]
|
||||
for seg in segments[1:]:
|
||||
prev = merged[-1]
|
||||
# Merge if same type AND (overlapping or within 2 seconds)
|
||||
if (prev.segment_type == seg.segment_type and
|
||||
seg.start <= prev.end + 2.0):
|
||||
prev.end = max(prev.end, seg.end)
|
||||
prev.confidence = (prev.confidence + seg.confidence) / 2
|
||||
else:
|
||||
merged.append(seg)
|
||||
|
||||
return merged
|
||||
|
||||
def _apply_constraints(self, segments: list[DetectedSegment]) -> list[DetectedSegment]:
|
||||
"""Apply duration constraints — short 'commercial' segments are likely misclassified."""
|
||||
min_break = self.config.segment_detection.min_break_duration_s
|
||||
|
||||
for seg in segments:
|
||||
if (seg.segment_type == SegmentType.COMMERCIAL and
|
||||
seg.duration < min_break):
|
||||
seg.segment_type = SegmentType.SHOW_CONTENT
|
||||
seg.label = "(reclassified: too short for commercial)"
|
||||
|
||||
return segments
|
||||
|
||||
def _label_from_prep(self, segments: list[DetectedSegment],
|
||||
transcript, show_prep: str):
|
||||
"""Label show segments by matching transcript content to show prep topics."""
|
||||
# TODO: Use Ollama to match transcript sections against show prep segment titles
|
||||
# For now, number them sequentially
|
||||
show_count = 0
|
||||
comm_count = 0
|
||||
for seg in segments:
|
||||
if seg.segment_type == SegmentType.SHOW_CONTENT:
|
||||
show_count += 1
|
||||
seg.label = f"Show Segment {show_count}"
|
||||
elif seg.segment_type == SegmentType.COMMERCIAL:
|
||||
comm_count += 1
|
||||
seg.label = f"Commercial Break {comm_count}"
|
||||
@@ -1,206 +0,0 @@
|
||||
"""
|
||||
Show prep generator: search the archive index for past caller topics,
|
||||
extract clips, and generate "then vs now" talking points via Ollama.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
from rich import box
|
||||
|
||||
from .indexer import ArchiveIndex, QAResult, SearchResult
|
||||
from .clip_extractor import extract_clips_for_results, format_timestamp
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def generate_show_prep(
|
||||
index: ArchiveIndex,
|
||||
topic: str,
|
||||
output_dir: Path,
|
||||
extract_clips: bool = True,
|
||||
ollama_host: str = "http://localhost:11434",
|
||||
ollama_model: str = "qwen3:14b",
|
||||
limit: int = 10,
|
||||
) -> Path:
|
||||
"""
|
||||
Search the archive for past discussions of a topic.
|
||||
Extracts audio clips and generates "then vs now" talking points.
|
||||
Returns path to the generated markdown prep file.
|
||||
"""
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
console.print(Panel.fit(f"[bold]Show Prep:[/bold] {topic}", border_style="blue"))
|
||||
|
||||
# Search Q&A pairs first (caller exchanges)
|
||||
qa_results = index.search_qa(topic, limit=limit)
|
||||
# Also search raw segments (for monologue mentions)
|
||||
segment_results = index.search(topic, limit=limit)
|
||||
|
||||
if not qa_results and not segment_results:
|
||||
console.print(f"[yellow]No results found for: {topic}[/yellow]")
|
||||
return None
|
||||
|
||||
# Display results table
|
||||
_print_results_table(qa_results, segment_results, topic)
|
||||
|
||||
# Extract clips
|
||||
clip_paths = {}
|
||||
if extract_clips and qa_results:
|
||||
clips_dir = output_dir / "clips"
|
||||
console.print(f"\n[dim]Extracting {len(qa_results)} clip(s)...[/dim]")
|
||||
clip_paths = extract_clips_for_results(qa_results, clips_dir)
|
||||
|
||||
# Generate then-vs-now content via Ollama
|
||||
then_now = _generate_then_vs_now(topic, qa_results, segment_results,
|
||||
ollama_host, ollama_model)
|
||||
|
||||
# Write markdown prep file
|
||||
safe_topic = topic.lower().replace(" ", "-").replace("/", "-")[:40]
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
prep_path = output_dir / f"{date_str}-{safe_topic}-prep.md"
|
||||
|
||||
_write_prep_file(prep_path, topic, qa_results, segment_results,
|
||||
clip_paths, then_now)
|
||||
|
||||
console.print(f"\n[bold green]Prep file:[/bold green] {prep_path}")
|
||||
return prep_path
|
||||
|
||||
|
||||
def _print_results_table(qa_results: list[QAResult], segment_results: list[SearchResult],
|
||||
topic: str):
|
||||
if qa_results:
|
||||
table = Table(title=f"Caller Q&A — \"{topic}\"", box=box.SIMPLE, show_lines=True)
|
||||
table.add_column("Date", style="cyan", width=12)
|
||||
table.add_column("Timestamps", style="dim", width=14)
|
||||
table.add_column("Duration", style="dim", width=8)
|
||||
table.add_column("Caller asked", width=35)
|
||||
table.add_column("Topic", style="green", width=20)
|
||||
|
||||
for r in qa_results:
|
||||
dur = r.duration()
|
||||
table.add_row(
|
||||
r.date or r.episode_id,
|
||||
r.timestamp_str(),
|
||||
f"{int(dur//60)}m{int(dur%60):02d}s",
|
||||
r.question_text[:80] + ("…" if len(r.question_text) > 80 else ""),
|
||||
r.topic or "—",
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
if segment_results and not qa_results:
|
||||
console.print(f"\n[dim]No structured Q&A found. Showing {len(segment_results)} "
|
||||
f"transcript mentions:[/dim]")
|
||||
for r in segment_results:
|
||||
console.print(f" [cyan]{r.date}[/cyan] [{r.timestamp_str()}] "
|
||||
f"[dim]{r.speaker}[/dim]: {r.text[:100]}…")
|
||||
|
||||
|
||||
def _generate_then_vs_now(topic: str, qa_results: list, segment_results: list,
|
||||
ollama_host: str, model: str) -> str:
|
||||
try:
|
||||
import ollama
|
||||
client = ollama.Client(host=ollama_host)
|
||||
except ImportError:
|
||||
return "_Ollama not available — install with: pip install ollama_"
|
||||
|
||||
# Build context from past discussions
|
||||
past_context = ""
|
||||
for r in qa_results[:5]:
|
||||
date = r.date or r.episode_id
|
||||
past_context += f"\n[{date}] Caller: {r.question_text[:200]}\n"
|
||||
past_context += f"Host answer: {r.answer_text[:400]}\n"
|
||||
|
||||
if not past_context and segment_results:
|
||||
for r in segment_results[:5]:
|
||||
past_context += f"\n[{r.date}] {r.speaker}: {r.text[:300]}\n"
|
||||
|
||||
if not past_context:
|
||||
return ""
|
||||
|
||||
prompt = f"""You are helping prepare talking points for a technology radio show host.
|
||||
The host discussed "{topic}" in past episodes. Here are excerpts:
|
||||
|
||||
{past_context}
|
||||
|
||||
The host wants to do a new segment revisiting this topic.
|
||||
|
||||
Write talking points in this format:
|
||||
## What I Said Then
|
||||
- [2-3 bullets summarizing the past advice/position]
|
||||
|
||||
## What's Changed Since Then
|
||||
- [2-3 bullets on how the technology/situation has evolved]
|
||||
|
||||
## Why My Answer Is Different Now
|
||||
- [2-3 bullets on the updated recommendation/position]
|
||||
|
||||
## Suggested Opening
|
||||
[1-2 sentences the host can use to open the segment, referencing the old clip]
|
||||
|
||||
Keep it conversational, radio-friendly. Be specific about what actually changed."""
|
||||
|
||||
try:
|
||||
resp = client.chat(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
options={"temperature": 0.3},
|
||||
)
|
||||
return resp["message"]["content"]
|
||||
except Exception as e:
|
||||
return f"_Ollama generation failed: {e}_"
|
||||
|
||||
|
||||
def _write_prep_file(path: Path, topic: str, qa_results: list, segment_results: list,
|
||||
clip_paths: dict, then_now: str):
|
||||
lines = [
|
||||
f"# Show Prep: {topic}",
|
||||
f"",
|
||||
f"_Generated {datetime.now().strftime('%Y-%m-%d %H:%M')}_",
|
||||
f"",
|
||||
]
|
||||
|
||||
if qa_results:
|
||||
lines += [f"## Past Caller Exchanges ({len(qa_results)} found)", ""]
|
||||
for i, r in enumerate(qa_results):
|
||||
clip_info = ""
|
||||
if i in clip_paths:
|
||||
clip_info = f" — `{clip_paths[i].name}`"
|
||||
lines += [
|
||||
f"### {r.date or r.episode_id} — [{r.timestamp_str()}]{clip_info}",
|
||||
f"**Caller:** {r.question_text}",
|
||||
f"",
|
||||
f"**Host:** {r.answer_text[:600]}{'…' if len(r.answer_text) > 600 else ''}",
|
||||
f"",
|
||||
]
|
||||
|
||||
elif segment_results:
|
||||
lines += [f"## Transcript Mentions ({len(segment_results)} found)", ""]
|
||||
for r in segment_results:
|
||||
lines += [
|
||||
f"- **{r.date}** [{r.timestamp_str()}] ({r.speaker}): {r.text[:200]}",
|
||||
]
|
||||
lines.append("")
|
||||
|
||||
if then_now:
|
||||
lines += ["## Then vs Now", "", then_now, ""]
|
||||
|
||||
if clip_paths:
|
||||
lines += [
|
||||
"## Clips",
|
||||
"",
|
||||
f"Extracted to `clips/` — drag into Audition/Audacity:",
|
||||
"",
|
||||
]
|
||||
for i, p in clip_paths.items():
|
||||
if i < len(qa_results):
|
||||
r = qa_results[i]
|
||||
lines.append(f"- `{p.name}` — {r.date} [{r.timestamp_str()}]")
|
||||
lines.append("")
|
||||
|
||||
path.write_text("\n".join(lines), encoding="utf-8")
|
||||
@@ -1,293 +0,0 @@
|
||||
"""
|
||||
Transcript-driven speaker name resolution.
|
||||
|
||||
Mike Swanson almost always announces who's about to speak — caller pickups
|
||||
("let's talk to William"), guest intros ("we have Clay from the Nerd Junkies"),
|
||||
co-host substitutions ("in Tara's place, we have Clay"), thank-yous on
|
||||
caller close. These are deterministic ground-truth signals the audio-only
|
||||
WavLM diarizer cannot use.
|
||||
|
||||
This module:
|
||||
1. Extracts speaker introductions from a transcript.
|
||||
2. Binds each intro to the next non-HOST diarization turn.
|
||||
3. Returns named speaker turns, overriding incorrect cosine matches.
|
||||
|
||||
Pipeline order: run AFTER diarization, the resolved names override the
|
||||
HOST/CO-HOST/CALLER/BUMPER labels with concrete people.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
# ── Introduction patterns ────────────────────────────────────────────────────
|
||||
# Each: (regex, role_hint, name_group_index, optional affiliation_group)
|
||||
# Patterns ordered most-specific first.
|
||||
|
||||
_INTRO_PATTERNS: list[tuple[re.Pattern, str, int, int | None]] = [
|
||||
# "in Tara's place, we have Clay" — fill-in for absent co-host
|
||||
(re.compile(
|
||||
r"in\s+([A-Z][a-z]+).?s\s+place,?\s+we\s+have\s+([A-Z][a-z]+)",
|
||||
re.I), "fillin", 2, None),
|
||||
|
||||
# "we have <name> from <affiliation>" — guest intro
|
||||
(re.compile(
|
||||
r"\bwe\s+have\s+([A-Z][a-z]+)\s+(?:from|with)\s+(?:the\s+)?([A-Z][\w\s]{2,30}?)(?:\.|,|;|\s+(?:on|here|joining|today|tonight|with))",
|
||||
re.I | re.MULTILINE), "guest", 1, 2),
|
||||
|
||||
# "let's talk to <name>" / "let's go ahead and talk to <name>" — caller pickup
|
||||
(re.compile(
|
||||
r"\blet.s\s+(?:go\s+ahead\s+and\s+)?(?:talk|go)\s+to\s+([A-Z][a-z]+)",
|
||||
re.I), "caller", 1, None),
|
||||
|
||||
# "let's fit <name> in" / "let's bring <name> on" — caller pickup variants
|
||||
(re.compile(
|
||||
r"\blet.s\s+(?:go\s+ahead\s+and\s+)?(?:fit|bring|put)\s+([A-Z][a-z]+)\s+(?:in|on)\b",
|
||||
re.I), "caller", 1, None),
|
||||
|
||||
# "Hello, <name>. How are you?" — caller pickup
|
||||
(re.compile(
|
||||
r"\bhello,?\s+([A-Z][a-z]+)\.?\s+(?:how\s+are\s+you|thanks\s+for|are\s+you\s+there|welcome)",
|
||||
re.I), "caller", 1, None),
|
||||
|
||||
# "Hi <name>, welcome/how are you" — caller pickup variant
|
||||
(re.compile(
|
||||
r"\bhi\s+([A-Z][a-z]+),?\s+(?:welcome|how\s+are\s+you|thanks\s+for)",
|
||||
re.I), "caller", 1, None),
|
||||
|
||||
# "thanks (so much) for the call, <name>" / "thanks for calling, <name>" — caller close
|
||||
# Require explicit "for the call/calling" — otherwise greedy matching captures
|
||||
# any capitalized word after "thanks" (Continue, And, For, You, Man, etc.)
|
||||
(re.compile(
|
||||
r"\bthanks?(?:\s+so\s+much)?\s+for\s+(?:the\s+)?(?:call(?:ing)?|patience),?\s+([A-Z][a-z]+)\b",
|
||||
re.I), "caller_close", 1, None),
|
||||
|
||||
# "joining us today/tonight (is|we have) <name>"
|
||||
(re.compile(
|
||||
r"\bjoining\s+(?:us|me)\s+(?:today|tonight|this\s+morning)?\s*(?:is|we\s+have)\s+([A-Z][a-z]+)",
|
||||
re.I), "guest", 1, None),
|
||||
]
|
||||
|
||||
# Words that look like names but aren't real people we'd track.
|
||||
# Includes show callouts, generic words, host self-references, common
|
||||
# capitalized non-name words that survive mid-sentence in transcripts.
|
||||
_NAME_BLACKLIST = {
|
||||
"mike", "swanson", "computer", "guru", "windows", "office", "google",
|
||||
"patreon", "tucson", "arizona", "facebook", "twitter", "youtube",
|
||||
"what", "how", "now", "well", "okay", "right", "sure", "yeah", "yes",
|
||||
"no", "thanks", "today", "tonight", "monday", "tuesday", "wednesday",
|
||||
"thursday", "friday", "saturday", "sunday", "january", "february",
|
||||
"march", "april", "may", "june", "july", "august", "september",
|
||||
"october", "november", "december", "internet", "service", "feature",
|
||||
"back", "show", "phone", "voice", "call", "kvoi", "kby", "ces",
|
||||
"ipad", "iphone", "android", "samsung", "amazon", "intel", "amd",
|
||||
"nvidia", "the", "and", "there", "but", "or", "so", "well",
|
||||
"france", "germany", "japan", "china", "russia", "europe", "asia",
|
||||
"africa", "elon", "musk", "trump", "biden", "obama", # public figures
|
||||
"you", "for", "continue", "very", "buddy", "guys", "man", "god",
|
||||
"everyone", "everybody", "anyone",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpeakerIntro:
|
||||
name: str
|
||||
intro_time: float # transcript segment end time — speaker turn likely starts after this
|
||||
role_hint: str # "caller", "guest", "fillin", "caller_close"
|
||||
affiliation: str | None = None
|
||||
fillin_for: str | None = None # for "in X's place, we have Y" — X
|
||||
source_text: str = ""
|
||||
|
||||
|
||||
def _is_blacklisted(name: str) -> bool:
|
||||
return name.lower() in _NAME_BLACKLIST or len(name) < 3
|
||||
|
||||
|
||||
def extract_intros(transcript_segments: list[dict]) -> list[SpeakerIntro]:
|
||||
"""Walk transcript segments and extract speaker introduction events.
|
||||
|
||||
Deduplicates intros within 5 seconds with the same (name, role_hint).
|
||||
"""
|
||||
raw: list[SpeakerIntro] = []
|
||||
for seg in transcript_segments:
|
||||
text = seg.get("text", "")
|
||||
end = seg.get("end") or seg.get("start", 0)
|
||||
for pat, role, name_idx, affil_idx in _INTRO_PATTERNS:
|
||||
for m in pat.finditer(text):
|
||||
captured = m.group(name_idx)
|
||||
# Reject names that aren't capitalized in the source text —
|
||||
# eliminates mid-sentence lowercase matches like "and", "there"
|
||||
# that re.IGNORECASE picks up.
|
||||
if not captured or not captured[0].isupper():
|
||||
continue
|
||||
name = captured[0].upper() + captured[1:].lower()
|
||||
if _is_blacklisted(name):
|
||||
continue
|
||||
affiliation = None
|
||||
fillin_for = None
|
||||
if role == "guest" and affil_idx:
|
||||
try:
|
||||
affiliation = m.group(affil_idx).strip().rstrip(".,;").title()
|
||||
except (IndexError, AttributeError):
|
||||
affiliation = None
|
||||
if role == "fillin":
|
||||
fillin_for = m.group(1).capitalize()
|
||||
raw.append(SpeakerIntro(
|
||||
name=name,
|
||||
intro_time=float(end),
|
||||
role_hint=role,
|
||||
affiliation=affiliation,
|
||||
fillin_for=fillin_for,
|
||||
source_text=text.strip(),
|
||||
))
|
||||
break # one intro per segment is plenty
|
||||
|
||||
# Deduplicate: collapse same (name, role) within 5s window
|
||||
raw.sort(key=lambda x: x.intro_time)
|
||||
deduped: list[SpeakerIntro] = []
|
||||
for intro in raw:
|
||||
if deduped:
|
||||
prev = deduped[-1]
|
||||
if (prev.name == intro.name
|
||||
and prev.role_hint == intro.role_hint
|
||||
and abs(intro.intro_time - prev.intro_time) <= 5.0):
|
||||
continue
|
||||
deduped.append(intro)
|
||||
return deduped
|
||||
|
||||
|
||||
@dataclass
|
||||
class NamedTurn:
|
||||
speaker: str # original diarizer label (HOST / CO-HOST / CALLER / BUMPER / UNKNOWN)
|
||||
name: str | None # resolved name, or None
|
||||
role_hint: str | None # "caller" / "guest" / "fillin" / etc.
|
||||
start: float
|
||||
end: float
|
||||
confidence: float
|
||||
intro_source: str | None = None # transcript phrase that drove the resolution
|
||||
|
||||
|
||||
# Allow an intro to bind to a turn that started slightly before it
|
||||
# (Whisper segment boundaries vs diarizer turn boundaries don't always
|
||||
# align; sometimes Mike's "let's talk to Kay" lands a few seconds after
|
||||
# Kay's first audio frame).
|
||||
_INTRO_FORWARD_TOLERANCE_S = 8.0
|
||||
# For caller_close patterns, only bind if the close mention is within
|
||||
# this many seconds AFTER the turn ends.
|
||||
_CLOSE_LOOKAHEAD_S = 30.0
|
||||
|
||||
|
||||
def resolve_speakers(diarization_turns: list[dict],
|
||||
intros: list[SpeakerIntro]) -> list[NamedTurn]:
|
||||
"""Assign speaker names to non-HOST diarization turns.
|
||||
|
||||
Algorithm:
|
||||
For each non-HOST turn T:
|
||||
- Find the LATEST opening intro (caller / guest / fillin) at or
|
||||
before T.start (with 8s forward tolerance to handle boundary
|
||||
slop). No time limit — a later intro implicitly closes the
|
||||
previous caller, so the most recent one wins.
|
||||
- If no opening intro applies, look for a "thanks for the call X"
|
||||
closure within 30s after T ends.
|
||||
- If still none, leave the turn unresolved.
|
||||
"""
|
||||
# Sort intros by time so we can walk through linearly
|
||||
opening = sorted(
|
||||
[i for i in intros if i.role_hint != "caller_close"],
|
||||
key=lambda i: i.intro_time,
|
||||
)
|
||||
closing = sorted(
|
||||
[i for i in intros if i.role_hint == "caller_close"],
|
||||
key=lambda i: i.intro_time,
|
||||
)
|
||||
|
||||
out: list[NamedTurn] = []
|
||||
for turn in diarization_turns:
|
||||
speaker = turn["speaker"]
|
||||
start = turn["start"]
|
||||
end = turn["end"]
|
||||
confidence = turn.get("confidence", 0.0)
|
||||
|
||||
if speaker in ("HOST", "BUMPER", "UNKNOWN"):
|
||||
out.append(NamedTurn(
|
||||
speaker=speaker, name=None, role_hint=None,
|
||||
start=start, end=end, confidence=confidence,
|
||||
))
|
||||
continue
|
||||
|
||||
# Latest opening intro at or before this turn (with forward slop)
|
||||
best: SpeakerIntro | None = None
|
||||
cutoff = start + _INTRO_FORWARD_TOLERANCE_S
|
||||
for intro in opening:
|
||||
if intro.intro_time <= cutoff:
|
||||
best = intro # later intros override earlier
|
||||
else:
|
||||
break
|
||||
|
||||
# If no opening, try a close pattern shortly AFTER turn ends
|
||||
if best is None:
|
||||
for intro in closing:
|
||||
if end <= intro.intro_time <= end + _CLOSE_LOOKAHEAD_S:
|
||||
best = intro
|
||||
break
|
||||
|
||||
if best is not None:
|
||||
role = "caller" if best.role_hint == "caller_close" else best.role_hint
|
||||
out.append(NamedTurn(
|
||||
speaker=speaker,
|
||||
name=best.name,
|
||||
role_hint=role,
|
||||
start=start, end=end,
|
||||
confidence=confidence,
|
||||
intro_source=best.source_text[:80],
|
||||
))
|
||||
else:
|
||||
out.append(NamedTurn(
|
||||
speaker=speaker, name=None, role_hint=None,
|
||||
start=start, end=end, confidence=confidence,
|
||||
))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def speaker_at(time: float, intros: list[SpeakerIntro]) -> SpeakerIntro | None:
|
||||
"""Return the active opening intro at the given time, or None.
|
||||
|
||||
Same lookup logic as resolve_speakers but for a single timestamp,
|
||||
used to attach caller names to Q&A pairs by their question_start time.
|
||||
"""
|
||||
opening = sorted(
|
||||
[i for i in intros if i.role_hint != "caller_close"],
|
||||
key=lambda i: i.intro_time,
|
||||
)
|
||||
best: SpeakerIntro | None = None
|
||||
cutoff = time + _INTRO_FORWARD_TOLERANCE_S
|
||||
for intro in opening:
|
||||
if intro.intro_time <= cutoff:
|
||||
best = intro
|
||||
else:
|
||||
break
|
||||
return best
|
||||
|
||||
|
||||
def named_speaker_summary(named_turns: list[NamedTurn], duration: float) -> dict[str, float]:
|
||||
"""Aggregate seconds-by-named-speaker for reporting."""
|
||||
times: dict[str, float] = {}
|
||||
for t in named_turns:
|
||||
if t.name:
|
||||
key = f"{t.role_hint}: {t.name}"
|
||||
else:
|
||||
key = t.speaker
|
||||
times[key] = times.get(key, 0) + (t.end - t.start)
|
||||
return dict(sorted(times.items(), key=lambda x: -x[1]))
|
||||
|
||||
|
||||
def resolve_from_files(transcript_path: Path, diarization_path: Path) -> list[NamedTurn]:
|
||||
"""Convenience wrapper: load JSONs, run extraction + resolution."""
|
||||
with open(transcript_path) as f:
|
||||
tdata = json.load(f)
|
||||
with open(diarization_path) as f:
|
||||
ddata = json.load(f)
|
||||
intros = extract_intros(tdata.get("segments", []))
|
||||
return resolve_speakers(ddata.get("turns", []), intros)
|
||||
@@ -1,178 +0,0 @@
|
||||
"""Stage 1: Audio transcription using faster-whisper with GPU acceleration."""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptWord:
|
||||
word: str
|
||||
start: float
|
||||
end: float
|
||||
probability: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptSegment:
|
||||
id: int
|
||||
text: str
|
||||
start: float
|
||||
end: float
|
||||
words: list[TranscriptWord]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Transcript:
|
||||
segments: list[TranscriptSegment]
|
||||
language: str
|
||||
language_probability: float
|
||||
duration: float
|
||||
|
||||
@property
|
||||
def full_text(self) -> str:
|
||||
return " ".join(seg.text.strip() for seg in self.segments)
|
||||
|
||||
def text_at(self, start: float, end: float) -> str:
|
||||
"""Get transcript text within a time range."""
|
||||
result = []
|
||||
for seg in self.segments:
|
||||
if seg.end < start:
|
||||
continue
|
||||
if seg.start > end:
|
||||
break
|
||||
result.append(seg.text.strip())
|
||||
return " ".join(result)
|
||||
|
||||
def to_srt(self) -> str:
|
||||
"""Export as SRT subtitle format."""
|
||||
lines = []
|
||||
for i, seg in enumerate(self.segments, 1):
|
||||
start = _format_srt_time(seg.start)
|
||||
end = _format_srt_time(seg.end)
|
||||
lines.append(f"{i}")
|
||||
lines.append(f"{start} --> {end}")
|
||||
lines.append(seg.text.strip())
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"language": self.language,
|
||||
"language_probability": self.language_probability,
|
||||
"duration": self.duration,
|
||||
"segments": [
|
||||
{
|
||||
"id": seg.id,
|
||||
"text": seg.text,
|
||||
"start": seg.start,
|
||||
"end": seg.end,
|
||||
"words": [
|
||||
{
|
||||
"word": w.word,
|
||||
"start": w.start,
|
||||
"end": w.end,
|
||||
"probability": w.probability,
|
||||
}
|
||||
for w in seg.words
|
||||
],
|
||||
}
|
||||
for seg in self.segments
|
||||
],
|
||||
}
|
||||
|
||||
def save(self, output_dir: Path):
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# JSON with full detail
|
||||
with open(output_dir / "transcript.json", "w", encoding="utf-8") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
# Plain text
|
||||
with open(output_dir / "transcript.txt", "w", encoding="utf-8") as f:
|
||||
f.write(self.full_text)
|
||||
|
||||
# SRT subtitles
|
||||
with open(output_dir / "transcript.srt", "w", encoding="utf-8") as f:
|
||||
f.write(self.to_srt())
|
||||
|
||||
console.print(f"[green]Transcript saved to {output_dir}[/green]")
|
||||
|
||||
|
||||
def _format_srt_time(seconds: float) -> str:
|
||||
h = int(seconds // 3600)
|
||||
m = int((seconds % 3600) // 60)
|
||||
s = int(seconds % 60)
|
||||
ms = int((seconds % 1) * 1000)
|
||||
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
||||
|
||||
|
||||
def transcribe(audio_path: str | Path, model_size: str = "large-v3",
|
||||
language: str = "en", device: str = "cuda",
|
||||
batch_size: int = 16) -> Transcript:
|
||||
"""Transcribe an audio file using faster-whisper.
|
||||
|
||||
Uses BatchedInferencePipeline + int8_float16 + VAD for archive/batch work.
|
||||
Word timestamps are skipped in batch mode (not needed for segment-level search).
|
||||
Pass batch_size=0 to fall back to sequential WhisperModel with word timestamps.
|
||||
"""
|
||||
from faster_whisper import WhisperModel, BatchedInferencePipeline
|
||||
|
||||
audio_path = Path(audio_path)
|
||||
use_batched = batch_size > 0
|
||||
|
||||
console.print(f"[bold]Transcribing:[/bold] {audio_path.name}")
|
||||
console.print(
|
||||
f"[dim]Model: {model_size} | "
|
||||
f"{'batched x' + str(batch_size) + ' int8_float16' if use_batched else 'sequential float16'} | "
|
||||
f"Device: {device}[/dim]"
|
||||
)
|
||||
|
||||
if use_batched:
|
||||
base_model = WhisperModel(model_size, device=device, compute_type="int8_float16")
|
||||
model = BatchedInferencePipeline(model=base_model)
|
||||
segments_raw, info = model.transcribe(
|
||||
str(audio_path),
|
||||
language=language,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
else:
|
||||
model = WhisperModel(model_size, device=device, compute_type="float16")
|
||||
segments_raw, info = model.transcribe(
|
||||
str(audio_path),
|
||||
language=language,
|
||||
word_timestamps=True,
|
||||
vad_filter=True,
|
||||
vad_parameters=dict(min_silence_duration_ms=500, speech_pad_ms=200),
|
||||
)
|
||||
|
||||
console.print(f"[dim]Duration: {info.duration:.1f}s ({info.duration / 60:.1f} min)[/dim]")
|
||||
|
||||
segments = []
|
||||
for i, seg in enumerate(segments_raw):
|
||||
words = []
|
||||
if not use_batched:
|
||||
words = [
|
||||
TranscriptWord(word=w.word, start=w.start,
|
||||
end=w.end, probability=w.probability)
|
||||
for w in (seg.words or [])
|
||||
]
|
||||
segments.append(TranscriptSegment(
|
||||
id=i, text=seg.text, start=seg.start, end=seg.end, words=words,
|
||||
))
|
||||
if i % 50 == 0:
|
||||
console.print(f"[dim] {i} segments... ({seg.end:.0f}s)[/dim]")
|
||||
|
||||
console.print(f"[green]Transcription complete: {len(segments)} segments[/green]")
|
||||
|
||||
return Transcript(
|
||||
segments=segments,
|
||||
language=info.language,
|
||||
language_probability=info.language_probability,
|
||||
duration=info.duration,
|
||||
)
|
||||
@@ -1,415 +0,0 @@
|
||||
"""Voice profiler: builds and manages speaker embeddings using speechbrain.
|
||||
|
||||
Uses ECAPA-TDNN speaker verification model to generate embeddings.
|
||||
No HuggingFace gated model access required (unlike pyannote).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import soundfile as sf
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
# Target sample rate for the embedding model
|
||||
SAMPLE_RATE = 16000
|
||||
|
||||
# Minimum segment length for a usable embedding (seconds)
|
||||
MIN_SEGMENT_S = 3.0
|
||||
|
||||
# Maximum segment length to process at once (seconds)
|
||||
MAX_SEGMENT_S = 30.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoiceSegment:
|
||||
"""A segment of audio attributed to a single speaker."""
|
||||
start: float
|
||||
end: float
|
||||
embedding: np.ndarray | None = None
|
||||
speaker_label: str = ""
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return self.end - self.start
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpeakerProfile:
|
||||
"""A speaker's voice profile built from multiple embeddings."""
|
||||
name: str
|
||||
role: str # "host", "cohost", "guest", "caller"
|
||||
embeddings: list[np.ndarray]
|
||||
source_episodes: list[str]
|
||||
composite_embedding: np.ndarray | None = None
|
||||
|
||||
@property
|
||||
def num_samples(self) -> int:
|
||||
return len(self.embeddings)
|
||||
|
||||
def compute_composite(self):
|
||||
"""Average all embeddings into a single composite."""
|
||||
if self.embeddings:
|
||||
self.composite_embedding = np.mean(self.embeddings, axis=0)
|
||||
# L2 normalize
|
||||
norm = np.linalg.norm(self.composite_embedding)
|
||||
if norm > 0:
|
||||
self.composite_embedding /= norm
|
||||
|
||||
def similarity(self, embedding: np.ndarray) -> float:
|
||||
"""Cosine similarity between an embedding and this profile's composite."""
|
||||
if self.composite_embedding is None:
|
||||
self.compute_composite()
|
||||
return float(np.dot(self.composite_embedding, embedding) / (
|
||||
np.linalg.norm(self.composite_embedding) * np.linalg.norm(embedding) + 1e-8
|
||||
))
|
||||
|
||||
|
||||
class VoiceProfiler:
|
||||
"""Builds speaker voice profiles from audio using speechbrain ECAPA-TDNN."""
|
||||
|
||||
def __init__(self, profiles_dir: str | Path, device: str = "cuda"):
|
||||
self.profiles_dir = Path(profiles_dir)
|
||||
self.profiles_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.device = device
|
||||
self._model = None
|
||||
self.profiles: dict[str, SpeakerProfile] = {}
|
||||
self._load_existing_profiles()
|
||||
|
||||
def _get_model(self):
|
||||
"""Lazy-load the embedding model (WavLM x-vector)."""
|
||||
if self._model is None:
|
||||
console.print("[dim]Loading speaker embedding model (WavLM-SV)...[/dim]")
|
||||
from transformers import Wav2Vec2FeatureExtractor, WavLMForXVector
|
||||
self._extractor = Wav2Vec2FeatureExtractor.from_pretrained(
|
||||
"microsoft/wavlm-base-sv"
|
||||
)
|
||||
self._model = WavLMForXVector.from_pretrained(
|
||||
"microsoft/wavlm-base-sv"
|
||||
).to(self.device)
|
||||
self._model.eval()
|
||||
console.print("[dim]Speaker embedding model loaded[/dim]")
|
||||
return self._model
|
||||
|
||||
def _load_existing_profiles(self):
|
||||
"""Load saved profiles from disk."""
|
||||
profile_file = self.profiles_dir / "profiles.json"
|
||||
if not profile_file.exists():
|
||||
return
|
||||
|
||||
with open(profile_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
for name, pdata in data.items():
|
||||
embeddings = []
|
||||
emb_dir = self.profiles_dir / name.lower().replace(" ", "-")
|
||||
for emb_file in sorted(emb_dir.glob("embedding_*.npy")):
|
||||
embeddings.append(np.load(emb_file))
|
||||
|
||||
composite = None
|
||||
composite_file = emb_dir / "composite.npy"
|
||||
if composite_file.exists():
|
||||
composite = np.load(composite_file)
|
||||
|
||||
self.profiles[name] = SpeakerProfile(
|
||||
name=name,
|
||||
role=pdata.get("role", "unknown"),
|
||||
embeddings=embeddings,
|
||||
source_episodes=pdata.get("source_episodes", []),
|
||||
composite_embedding=composite,
|
||||
)
|
||||
|
||||
if self.profiles:
|
||||
console.print(f"[dim]Loaded {len(self.profiles)} voice profiles[/dim]")
|
||||
|
||||
def save_profiles(self):
|
||||
"""Save all profiles to disk."""
|
||||
metadata = {}
|
||||
for name, profile in self.profiles.items():
|
||||
slug = name.lower().replace(" ", "-")
|
||||
emb_dir = self.profiles_dir / slug
|
||||
emb_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save individual embeddings
|
||||
for i, emb in enumerate(profile.embeddings):
|
||||
np.save(emb_dir / f"embedding_{i:04d}.npy", emb)
|
||||
|
||||
# Save composite
|
||||
profile.compute_composite()
|
||||
if profile.composite_embedding is not None:
|
||||
np.save(emb_dir / "composite.npy", profile.composite_embedding)
|
||||
|
||||
metadata[name] = {
|
||||
"role": profile.role,
|
||||
"num_samples": profile.num_samples,
|
||||
"source_episodes": profile.source_episodes,
|
||||
}
|
||||
|
||||
with open(self.profiles_dir / "profiles.json", "w") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
console.print(f"[green]Saved {len(self.profiles)} voice profiles[/green]")
|
||||
|
||||
def extract_embedding(self, audio_path: Path, start: float = 0.0,
|
||||
end: float | None = None) -> np.ndarray:
|
||||
"""Extract a speaker embedding from an audio segment (file-based, any format)."""
|
||||
self._get_model()
|
||||
waveform, _ = self._load_audio_segment(audio_path, start, end)
|
||||
return self._embed_audio_np(waveform.squeeze(0).numpy())
|
||||
|
||||
def _embed_audio_np(self, audio_np: np.ndarray) -> np.ndarray:
|
||||
"""Embed a float32 mono numpy array (already at SAMPLE_RATE). Returns L2-normalized embedding."""
|
||||
self._get_model()
|
||||
inputs = self._extractor(
|
||||
audio_np, sampling_rate=SAMPLE_RATE,
|
||||
return_tensors="pt", padding=True,
|
||||
)
|
||||
with torch.no_grad():
|
||||
outputs = self._model(**{k: v.to(self.device) for k, v in inputs.items()})
|
||||
embedding = outputs.embeddings.squeeze().cpu().numpy()
|
||||
norm = np.linalg.norm(embedding)
|
||||
if norm > 0:
|
||||
embedding = embedding / norm
|
||||
return embedding
|
||||
|
||||
def _load_full_audio(self, audio_path: Path) -> np.ndarray:
|
||||
"""Decode entire audio file to float32 mono at SAMPLE_RATE via a single ffmpeg call."""
|
||||
cmd = [
|
||||
"ffmpeg", "-i", str(audio_path),
|
||||
"-f", "wav", "-ac", "1", "-ar", str(SAMPLE_RATE),
|
||||
"-acodec", "pcm_s16le", "pipe:1",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=600)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed: {result.stderr.decode()[:200]}")
|
||||
import io
|
||||
data, _ = sf.read(io.BytesIO(result.stdout), dtype="float32")
|
||||
return data # shape: (samples,)
|
||||
|
||||
def _load_audio_segment(self, audio_path: Path, start: float = 0.0,
|
||||
end: float | None = None) -> tuple[torch.Tensor, int]:
|
||||
"""Load a single audio segment via ffmpeg (used for one-off extraction)."""
|
||||
cmd = ["ffmpeg", "-i", str(audio_path)]
|
||||
if start > 0:
|
||||
cmd.extend(["-ss", str(start)])
|
||||
if end is not None:
|
||||
cmd.extend(["-t", str(end - start)])
|
||||
cmd.extend(["-f", "wav", "-ac", "1", "-ar", str(SAMPLE_RATE),
|
||||
"-acodec", "pcm_s16le", "pipe:1"])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, timeout=60)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed: {result.stderr.decode()[:200]}")
|
||||
|
||||
import io
|
||||
data, sr = sf.read(io.BytesIO(result.stdout), dtype="float32")
|
||||
waveform = torch.from_numpy(data).unsqueeze(0) # [1, samples]
|
||||
return waveform, sr
|
||||
|
||||
def bootstrap_host_from_episodes(self, episode_paths: list[Path],
|
||||
host_name: str = "Mike Swanson"):
|
||||
"""Build host voice profile by extracting the dominant speaker from episodes.
|
||||
|
||||
Strategy: In each episode, the host speaks the most. We extract embeddings
|
||||
from the first 2-5 minutes (usually the intro/monologue) where the host
|
||||
is most likely speaking solo.
|
||||
"""
|
||||
console.print(f"[bold]Bootstrapping voice profile for {host_name}[/bold]")
|
||||
console.print(f"[dim]Processing {len(episode_paths)} episodes[/dim]")
|
||||
|
||||
if host_name not in self.profiles:
|
||||
self.profiles[host_name] = SpeakerProfile(
|
||||
name=host_name,
|
||||
role="host",
|
||||
embeddings=[],
|
||||
source_episodes=[],
|
||||
)
|
||||
|
||||
profile = self.profiles[host_name]
|
||||
|
||||
for ep_idx, ep_path in enumerate(episode_paths, 1):
|
||||
console.print(f"[dim] [{ep_idx}/{len(episode_paths)}] {ep_path.name}[/dim]")
|
||||
|
||||
try:
|
||||
duration = self._get_duration(ep_path)
|
||||
|
||||
windows = []
|
||||
if duration > 90:
|
||||
windows.append((30.0, 90.0))
|
||||
if duration > 180:
|
||||
windows.append((120.0, 180.0))
|
||||
mid = duration / 2
|
||||
if mid > 60:
|
||||
windows.append((mid, min(mid + 60, duration)))
|
||||
late = duration - 180
|
||||
if late > 300:
|
||||
windows.append((late, late + 60))
|
||||
|
||||
chunk_duration = 10.0
|
||||
for start, end in windows:
|
||||
for chunk_start in np.arange(start, end - chunk_duration, chunk_duration):
|
||||
try:
|
||||
emb = self.extract_embedding(
|
||||
ep_path, chunk_start, chunk_start + chunk_duration
|
||||
)
|
||||
profile.embeddings.append(emb)
|
||||
except Exception as e:
|
||||
console.print(f" [dim red]Chunk {chunk_start:.0f}s failed: {e}[/dim red]")
|
||||
|
||||
profile.source_episodes.append(ep_path.name)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f" [red]Failed: {ep_path.name}: {e}[/red]")
|
||||
|
||||
# Compute composite
|
||||
profile.compute_composite()
|
||||
|
||||
console.print(f"\n[green]Host profile built: {profile.num_samples} embeddings "
|
||||
f"from {len(profile.source_episodes)} episodes[/green]")
|
||||
|
||||
# Save
|
||||
self.save_profiles()
|
||||
|
||||
def identify_speakers(self, audio_path: Path,
|
||||
window_s: float = 10.0,
|
||||
hop_s: float = 5.0,
|
||||
threshold: float = 0.70,
|
||||
skip_ranges: list[tuple[float, float]] | None = None
|
||||
) -> list[VoiceSegment]:
|
||||
"""Identify speakers throughout an audio file using sliding window.
|
||||
|
||||
Loads the full audio once then slices in memory — avoids spawning
|
||||
hundreds of ffmpeg subprocesses.
|
||||
Returns timestamped segments with speaker labels and embeddings.
|
||||
|
||||
skip_ranges: list of (start, end) seconds. Windows whose midpoint
|
||||
falls inside any of these ranges are labeled "[bumper]" and the
|
||||
speaker cosine match is skipped — used to suppress music/promo
|
||||
from being matched against speaker profiles.
|
||||
"""
|
||||
console.print(f"[bold]Identifying speakers:[/bold] {audio_path.name}")
|
||||
|
||||
duration = self._get_duration(audio_path)
|
||||
console.print(f"[dim]Loading audio into memory...[/dim]")
|
||||
audio = self._load_full_audio(audio_path) # float32 mono array
|
||||
self._get_model() # ensure model is warm before the loop
|
||||
|
||||
skip_ranges = skip_ranges or []
|
||||
|
||||
segments = []
|
||||
window_samples = int(window_s * SAMPLE_RATE)
|
||||
hop_samples = int(hop_s * SAMPLE_RATE)
|
||||
total_samples = len(audio)
|
||||
|
||||
total_windows = int((duration - window_s) / hop_s) + 1
|
||||
report_every = max(1, total_windows // 10)
|
||||
|
||||
for idx, start in enumerate(np.arange(0, duration - window_s, hop_s)):
|
||||
end = min(start + window_s, duration)
|
||||
s = int(start * SAMPLE_RATE)
|
||||
e = min(s + window_samples, total_samples)
|
||||
|
||||
mid = (start + end) / 2
|
||||
in_bumper = any(rs <= mid <= re for rs, re in skip_ranges)
|
||||
|
||||
if in_bumper:
|
||||
segments.append(VoiceSegment(
|
||||
start=start, end=end,
|
||||
speaker_label="[bumper] (1.00)",
|
||||
))
|
||||
continue
|
||||
|
||||
try:
|
||||
emb = self._embed_audio_np(audio[s:e])
|
||||
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
|
||||
for name, profile in self.profiles.items():
|
||||
score = profile.similarity(emb)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = name
|
||||
|
||||
if best_score >= threshold:
|
||||
role = self.profiles[best_match].role if best_match else "unknown"
|
||||
if role == "host":
|
||||
label = f"Host: {best_match}"
|
||||
elif role == "cohost":
|
||||
label = f"Cohost: {best_match}"
|
||||
else:
|
||||
label = best_match
|
||||
else:
|
||||
label = "Unknown"
|
||||
|
||||
segments.append(VoiceSegment(
|
||||
start=start,
|
||||
end=end,
|
||||
embedding=emb,
|
||||
speaker_label=f"{label} ({best_score:.2f})",
|
||||
))
|
||||
|
||||
except Exception:
|
||||
segments.append(VoiceSegment(
|
||||
start=start, end=end,
|
||||
speaker_label="[error]",
|
||||
))
|
||||
|
||||
if idx % report_every == 0:
|
||||
pct = int(end / duration * 100)
|
||||
console.print(f"[dim] {pct}% ({end:.0f}s / {duration:.0f}s)[/dim]")
|
||||
|
||||
# Print summary
|
||||
self._print_speaker_summary(segments, duration)
|
||||
|
||||
return segments
|
||||
|
||||
def _print_speaker_summary(self, segments: list[VoiceSegment], duration: float):
|
||||
"""Print a summary of who spoke and for how long."""
|
||||
speaker_times: dict[str, float] = {}
|
||||
for seg in segments:
|
||||
label = seg.speaker_label.split(" (")[0] # Strip score
|
||||
speaker_times[label] = speaker_times.get(label, 0) + seg.duration
|
||||
|
||||
table = Table(title="Speaker Summary")
|
||||
table.add_column("Speaker", style="cyan")
|
||||
table.add_column("Time", style="magenta")
|
||||
table.add_column("Percentage", style="green")
|
||||
|
||||
for speaker, time in sorted(speaker_times.items(), key=lambda x: -x[1]):
|
||||
pct = (time / duration) * 100
|
||||
table.add_row(speaker, f"{time:.0f}s", f"{pct:.1f}%")
|
||||
|
||||
console.print(table)
|
||||
|
||||
def _get_duration(self, audio_path: Path) -> float:
|
||||
"""Get audio duration in seconds."""
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
|
||||
"-of", "csv=p=0", str(audio_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
|
||||
def print_profiles(self):
|
||||
"""Print summary of all loaded profiles."""
|
||||
table = Table(title="Voice Profiles")
|
||||
table.add_column("Name", style="cyan")
|
||||
table.add_column("Role", style="green")
|
||||
table.add_column("Samples", style="magenta")
|
||||
table.add_column("Episodes", style="yellow")
|
||||
|
||||
for name, profile in self.profiles.items():
|
||||
table.add_row(
|
||||
name, profile.role,
|
||||
str(profile.num_samples),
|
||||
str(len(profile.source_episodes)),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
@@ -1,241 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test content generation from a transcript using Ollama qwen3:14b.
|
||||
|
||||
Generates:
|
||||
1. Episode analysis (summary, segments, topics, tags, quotes, blog candidates)
|
||||
2. Sample forum discussion post
|
||||
3. Sample blog post draft
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import ollama
|
||||
|
||||
MODEL = "qwen3:14b"
|
||||
OLLAMA_HOST = "http://localhost:11434"
|
||||
# qwen3:14b supports 32k context -- use more of it
|
||||
MAX_TRANSCRIPT_CHARS = 40000
|
||||
|
||||
client = ollama.Client(host=OLLAMA_HOST)
|
||||
|
||||
|
||||
def load_transcript(transcript_dir: str) -> str:
|
||||
"""Load transcript text."""
|
||||
txt_path = Path(transcript_dir) / "transcript.txt"
|
||||
if not txt_path.exists():
|
||||
print(f"ERROR: {txt_path} not found")
|
||||
sys.exit(1)
|
||||
return txt_path.read_text()
|
||||
|
||||
|
||||
def timed_query(label: str, prompt: str, temperature: float = 0.3) -> str:
|
||||
"""Run an Ollama query with timing."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {label}")
|
||||
print(f"{'='*60}")
|
||||
start = time.time()
|
||||
|
||||
response = client.chat(
|
||||
model=MODEL,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
options={"temperature": temperature, "num_ctx": 32768},
|
||||
)
|
||||
|
||||
elapsed = time.time() - start
|
||||
result = response["message"]["content"]
|
||||
print(f" [{elapsed:.1f}s, {len(result)} chars]")
|
||||
return result
|
||||
|
||||
|
||||
def generate_analysis(transcript: str) -> dict:
|
||||
"""Generate episode analysis JSON."""
|
||||
prompt = f"""You are analyzing a transcript from "The Computer Guru Show", a live call-in
|
||||
radio show hosted by Mike Swanson on AM1030 KVOI in Tucson, Arizona. The show covers
|
||||
technology news, tips, and takes listener calls for free tech support.
|
||||
|
||||
Analyze this transcript and provide a JSON response with:
|
||||
|
||||
1. "summary": A 2-3 paragraph episode summary suitable for a podcast page. Write in third
|
||||
person. Be specific about topics and conversations.
|
||||
|
||||
2. "segment_summaries": Array of distinct topic segments discussed, each with:
|
||||
- "title": Compelling segment title
|
||||
- "summary": 3-5 sentence summary
|
||||
- "key_points": Array of key takeaway bullet points
|
||||
- "approximate_position": "early", "mid", or "late" in the show
|
||||
|
||||
3. "topics": Array of main topics discussed (short phrases)
|
||||
|
||||
4. "tags": Array of SEO-friendly tags (lowercase, hyphenated)
|
||||
|
||||
5. "key_quotes": Array of 3-5 notable/quotable moments, each with:
|
||||
- "quote": The exact quote text
|
||||
- "speaker": Who said it
|
||||
- "context": Brief context for why it's notable
|
||||
|
||||
6. "blog_post_candidates": Array of 2-3 topics worth expanding into full blog posts, each with:
|
||||
- "title": Proposed blog post title
|
||||
- "angle": The specific thesis or angle
|
||||
- "why": Why this deserves expansion (audience interest, SEO potential, etc.)
|
||||
- "key_points_to_expand": Array of points from the show to develop further
|
||||
|
||||
Respond ONLY with valid JSON. No markdown fencing, no explanation outside the JSON.
|
||||
|
||||
## Transcript
|
||||
|
||||
{transcript[:MAX_TRANSCRIPT_CHARS]}"""
|
||||
|
||||
result = timed_query("Episode Analysis (JSON)", prompt)
|
||||
|
||||
# Strip markdown fences if present
|
||||
if "```json" in result:
|
||||
result = result.split("```json", 1)[1].split("```", 1)[0]
|
||||
elif "```" in result:
|
||||
result = result.split("```", 1)[1].split("```", 1)[0]
|
||||
|
||||
# Strip thinking tags if qwen3 uses them
|
||||
if "<think>" in result:
|
||||
result = result.split("</think>")[-1]
|
||||
|
||||
try:
|
||||
return json.loads(result.strip())
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" WARNING: JSON parse failed: {e}")
|
||||
print(f" Raw response (first 500 chars): {result[:500]}")
|
||||
return {"raw_response": result}
|
||||
|
||||
|
||||
def generate_forum_post(transcript: str, analysis: dict) -> str:
|
||||
"""Generate a forum discussion thread post."""
|
||||
summary = analysis.get("summary", "")
|
||||
topics = analysis.get("topics", [])
|
||||
|
||||
prompt = f"""You are writing a forum discussion post for "The Computer Guru Show" community
|
||||
forum. The tone should be conversational, engaging, and invite discussion. This is NOT a
|
||||
formal article -- it's a community post that makes people want to comment.
|
||||
|
||||
Show info:
|
||||
- Host: Mike Swanson ("The Computer Guru")
|
||||
- Station: AM1030 KVOI, Tucson AZ
|
||||
- Format: Live call-in tech show
|
||||
|
||||
Episode summary: {summary}
|
||||
Topics covered: {', '.join(topics)}
|
||||
|
||||
Write a forum discussion post with:
|
||||
1. A brief, engaging hook (2-3 sentences about the most interesting thing from the episode)
|
||||
2. Bullet list of topics covered (with one-line teasers, not full summaries)
|
||||
3. 2-3 discussion questions that invite audience participation
|
||||
4. A "Listen to the full episode" call-to-action at the end
|
||||
|
||||
Keep it under 300 words. Use a casual, friendly tone. No emojis.
|
||||
|
||||
Key transcript excerpts for context:
|
||||
{transcript[:8000]}"""
|
||||
|
||||
return timed_query("Forum Discussion Post", prompt, temperature=0.5)
|
||||
|
||||
|
||||
def generate_blog_post(transcript: str, candidate: dict) -> str:
|
||||
"""Generate a full blog post draft from a blog candidate."""
|
||||
prompt = f"""You are writing a blog post for the "Computer Guru Show" website
|
||||
(radio.azcomputerguru.com). The author is Mike Swanson, a veteran IT professional and
|
||||
radio host in Tucson, Arizona. His style is:
|
||||
- Explains complex tech in plain English
|
||||
- Uses analogies and humor
|
||||
- Gives practical, actionable advice
|
||||
- Takes strong positions on consumer rights and privacy
|
||||
- Speaks directly to the reader
|
||||
|
||||
Write a blog post with this info:
|
||||
- Title: {candidate.get('title', 'Untitled')}
|
||||
- Angle: {candidate.get('angle', '')}
|
||||
- Points to expand: {json.dumps(candidate.get('key_points_to_expand', []))}
|
||||
|
||||
Format:
|
||||
1. Engaging opening paragraph (hook the reader)
|
||||
2. 3-5 sections with subheadings
|
||||
3. Practical "what this means for you" section
|
||||
4. Key Takeaways (bullet points)
|
||||
5. Closing paragraph that ties back to the show
|
||||
|
||||
Target length: 800-1200 words. Write in first person as Mike Swanson.
|
||||
Include a note at the bottom: "This topic was discussed on The Computer Guru Show.
|
||||
Listen to the full episode for more."
|
||||
|
||||
Relevant transcript excerpts:
|
||||
{transcript[:12000]}"""
|
||||
|
||||
return timed_query(f"Blog Post: {candidate.get('title', '?')}", prompt, temperature=0.5)
|
||||
|
||||
|
||||
def main():
|
||||
transcript_dir = sys.argv[1] if len(sys.argv) > 1 else \
|
||||
"training-data/transcripts/2016-s8e42"
|
||||
|
||||
print(f"Loading transcript from: {transcript_dir}")
|
||||
transcript = load_transcript(transcript_dir)
|
||||
print(f"Transcript length: {len(transcript)} chars ({len(transcript.splitlines())} lines)")
|
||||
print(f"Sending first {min(len(transcript), MAX_TRANSCRIPT_CHARS)} chars to LLM")
|
||||
|
||||
# Output directory
|
||||
output_dir = Path(transcript_dir) / "generated"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Step 1: Analysis
|
||||
analysis = generate_analysis(transcript)
|
||||
with open(output_dir / "analysis.json", "w") as f:
|
||||
json.dump(analysis, f, indent=2)
|
||||
print(f"\n Saved: {output_dir}/analysis.json")
|
||||
|
||||
# Print summary
|
||||
if "summary" in analysis:
|
||||
print(f"\n--- EPISODE SUMMARY ---")
|
||||
print(analysis["summary"])
|
||||
|
||||
if "topics" in analysis:
|
||||
print(f"\n--- TOPICS ---")
|
||||
for t in analysis["topics"]:
|
||||
print(f" - {t}")
|
||||
|
||||
if "tags" in analysis:
|
||||
print(f"\n--- TAGS ---")
|
||||
print(f" {', '.join(analysis['tags'])}")
|
||||
|
||||
if "blog_post_candidates" in analysis:
|
||||
print(f"\n--- BLOG POST CANDIDATES ---")
|
||||
for i, c in enumerate(analysis["blog_post_candidates"], 1):
|
||||
print(f" {i}. {c.get('title', '?')}")
|
||||
print(f" Angle: {c.get('angle', '?')}")
|
||||
|
||||
# Step 2: Forum post
|
||||
forum_post = generate_forum_post(transcript, analysis)
|
||||
with open(output_dir / "forum-post.md", "w") as f:
|
||||
f.write(forum_post)
|
||||
print(f"\n Saved: {output_dir}/forum-post.md")
|
||||
print(f"\n--- FORUM POST ---")
|
||||
print(forum_post)
|
||||
|
||||
# Step 3: Blog post (pick the first candidate)
|
||||
candidates = analysis.get("blog_post_candidates", [])
|
||||
if candidates:
|
||||
blog_post = generate_blog_post(transcript, candidates[0])
|
||||
slug = candidates[0].get("title", "draft").lower().replace(" ", "-")[:50]
|
||||
with open(output_dir / f"blog-{slug}.md", "w") as f:
|
||||
f.write(blog_post)
|
||||
print(f"\n Saved: {output_dir}/blog-{slug}.md")
|
||||
print(f"\n--- BLOG POST DRAFT ---")
|
||||
print(blog_post)
|
||||
else:
|
||||
print("\n No blog post candidates found, skipping blog generation")
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" All outputs saved to: {output_dir}/")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,75 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick test script to verify faster-whisper works on Mac M4.
|
||||
Transcribes first 60 seconds of an episode.
|
||||
"""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
from pydub import AudioSegment
|
||||
|
||||
# Config
|
||||
EPISODE = Path("training-data/episodes/2011-06-04-hr1.mp3")
|
||||
TEST_DURATION_MS = 60_000 # 60 seconds
|
||||
MODEL_SIZE = "base" # Start small for testing, switch to large-v3 for production
|
||||
|
||||
def main():
|
||||
print(f"[INFO] Loading {MODEL_SIZE} model on CPU...")
|
||||
start = time.time()
|
||||
|
||||
# Use CPU - faster-whisper/ctranslate2 doesn't support MPS
|
||||
model = WhisperModel(MODEL_SIZE, device="cpu", compute_type="int8")
|
||||
print(f"[OK] Model loaded in {time.time() - start:.1f}s")
|
||||
|
||||
# Extract first 60 seconds
|
||||
print(f"[INFO] Extracting first {TEST_DURATION_MS // 1000}s from {EPISODE.name}...")
|
||||
audio = AudioSegment.from_mp3(str(EPISODE))
|
||||
test_clip = audio[:TEST_DURATION_MS]
|
||||
|
||||
# Export to temp file
|
||||
temp_file = Path("/tmp/test_clip.wav")
|
||||
test_clip.export(str(temp_file), format="wav")
|
||||
print(f"[OK] Test clip exported ({temp_file.stat().st_size // 1024}KB)")
|
||||
|
||||
# Transcribe
|
||||
print("[INFO] Transcribing...")
|
||||
start = time.time()
|
||||
|
||||
segments, info = model.transcribe(
|
||||
str(temp_file),
|
||||
language="en",
|
||||
beam_size=5,
|
||||
vad_filter=True,
|
||||
)
|
||||
|
||||
# Collect segments
|
||||
results = []
|
||||
for seg in segments:
|
||||
results.append({
|
||||
"start": seg.start,
|
||||
"end": seg.end,
|
||||
"text": seg.text.strip()
|
||||
})
|
||||
|
||||
elapsed = time.time() - start
|
||||
|
||||
print(f"[OK] Transcription complete in {elapsed:.1f}s")
|
||||
print(f"[INFO] Speed: {TEST_DURATION_MS / 1000 / elapsed:.2f}x realtime")
|
||||
print(f"[INFO] Segments: {len(results)}")
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("TRANSCRIPT:")
|
||||
print("=" * 60)
|
||||
|
||||
for seg in results:
|
||||
print(f"[{seg['start']:.1f}s - {seg['end']:.1f}s] {seg['text']}")
|
||||
|
||||
# Cleanup
|
||||
temp_file.unlink()
|
||||
print()
|
||||
print("[SUCCESS] Test complete!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,431 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Segment-first content generation test.
|
||||
|
||||
Architecture:
|
||||
1. Split transcript at break markers (text-based detection)
|
||||
2. Analyze each segment individually (full context, no truncation)
|
||||
3. Cross-segment synthesis (callbacks, recurring topics, narrative arc)
|
||||
4. Generate forum post and blog post from complete analysis
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import ollama
|
||||
|
||||
MODEL = "qwen3:14b"
|
||||
OLLAMA_HOST = "http://localhost:11434"
|
||||
|
||||
client = ollama.Client(host=OLLAMA_HOST)
|
||||
|
||||
# Break markers — patterns that indicate commercial breaks
|
||||
BREAK_START = re.compile(
|
||||
r"^(We'll be right back|We will be right back)",
|
||||
re.IGNORECASE
|
||||
)
|
||||
BREAK_END = re.compile(
|
||||
r"^(Welcome back to [Tt]he Computer Guru|All right, if you'd like to be a part of the show)",
|
||||
re.IGNORECASE
|
||||
)
|
||||
# Station IDs and bumper text that appear during breaks
|
||||
BREAK_FILLER = re.compile(
|
||||
r"^(This is the Computer Guru Show on|This is a computer guru show|"
|
||||
r"Your computer guru|Whether you're dealing with|"
|
||||
r"Computer running slow|Has your machine somehow|"
|
||||
r"Be one with your operating system|"
|
||||
r"Listen in, chat in|Want your voice to be heard)",
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def load_transcript(transcript_dir: str) -> list[str]:
|
||||
"""Load transcript as lines."""
|
||||
txt_path = Path(transcript_dir) / "transcript.txt"
|
||||
if not txt_path.exists():
|
||||
print(f"ERROR: {txt_path} not found")
|
||||
sys.exit(1)
|
||||
return txt_path.read_text().splitlines()
|
||||
|
||||
|
||||
def split_into_segments(lines: list[str]) -> list[dict]:
|
||||
"""Split transcript lines into show segments, removing commercial breaks.
|
||||
|
||||
Returns list of segments, each with:
|
||||
- number: segment number (1-based)
|
||||
- start_line: first line number in original transcript
|
||||
- end_line: last line number
|
||||
- lines: list of text lines (show content only)
|
||||
- text: joined text
|
||||
"""
|
||||
segments = []
|
||||
current_segment_lines = []
|
||||
current_start = 1
|
||||
in_break = False
|
||||
segment_num = 0
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
# Detect break start
|
||||
if BREAK_START.match(stripped) and not in_break:
|
||||
# Save current segment if it has content
|
||||
if current_segment_lines:
|
||||
segment_num += 1
|
||||
text = "\n".join(current_segment_lines)
|
||||
segments.append({
|
||||
"number": segment_num,
|
||||
"start_line": current_start,
|
||||
"end_line": i - 1,
|
||||
"lines": current_segment_lines,
|
||||
"text": text,
|
||||
"char_count": len(text),
|
||||
})
|
||||
in_break = True
|
||||
current_segment_lines = []
|
||||
continue
|
||||
|
||||
# Detect break end
|
||||
if in_break and BREAK_END.match(stripped):
|
||||
in_break = False
|
||||
current_start = i
|
||||
# Don't include the "welcome back" line itself — it's transitional
|
||||
continue
|
||||
|
||||
# Skip break filler (station IDs, bumper text during breaks)
|
||||
if in_break or BREAK_FILLER.match(stripped):
|
||||
continue
|
||||
|
||||
# Regular show content
|
||||
current_segment_lines.append(stripped)
|
||||
|
||||
# Don't forget the last segment
|
||||
if current_segment_lines:
|
||||
segment_num += 1
|
||||
text = "\n".join(current_segment_lines)
|
||||
segments.append({
|
||||
"number": segment_num,
|
||||
"start_line": current_start,
|
||||
"end_line": len(lines),
|
||||
"lines": current_segment_lines,
|
||||
"text": text,
|
||||
"char_count": len(text),
|
||||
})
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def timed_query(label: str, prompt: str, temperature: float = 0.3,
|
||||
ctx_size: int = 32768) -> str:
|
||||
"""Run an Ollama query with timing."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {label}")
|
||||
print(f"{'='*60}")
|
||||
start = time.time()
|
||||
|
||||
response = client.chat(
|
||||
model=MODEL,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
options={"temperature": temperature, "num_ctx": ctx_size},
|
||||
)
|
||||
|
||||
elapsed = time.time() - start
|
||||
result = response["message"]["content"]
|
||||
|
||||
# Strip thinking tags if qwen3 uses them
|
||||
if "<think>" in result:
|
||||
parts = result.split("</think>")
|
||||
if len(parts) > 1:
|
||||
result = parts[-1].strip()
|
||||
|
||||
print(f" [{elapsed:.1f}s, {len(result)} chars]")
|
||||
return result
|
||||
|
||||
|
||||
def parse_json_response(text: str) -> dict:
|
||||
"""Parse JSON from LLM response, handling markdown fences."""
|
||||
if "```json" in text:
|
||||
text = text.split("```json", 1)[1].split("```", 1)[0]
|
||||
elif "```" in text:
|
||||
text = text.split("```", 1)[1].split("```", 1)[0]
|
||||
try:
|
||||
return json.loads(text.strip())
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" WARNING: JSON parse failed: {e}")
|
||||
print(f" First 300 chars: {text[:300]}")
|
||||
return {}
|
||||
|
||||
|
||||
def analyze_segment(segment: dict, segment_count: int) -> dict:
|
||||
"""Analyze a single segment with full context."""
|
||||
prompt = f"""You are analyzing segment {segment['number']} of {segment_count} from
|
||||
"The Computer Guru Show", a live call-in radio show hosted by Mike Swanson on AM1030
|
||||
KVOI in Tucson, Arizona. Co-host Rob is often present. The show takes listener calls
|
||||
for free tech support and discusses tech news.
|
||||
|
||||
This is the COMPLETE transcript of this segment (nothing is truncated).
|
||||
Analyze it and respond with JSON:
|
||||
|
||||
{{
|
||||
"title": "Compelling segment title",
|
||||
"summary": "3-5 sentence summary of what happened in this segment",
|
||||
"key_points": ["array of key takeaway bullet points"],
|
||||
"topics": ["array of topics discussed"],
|
||||
"speakers": ["array of speakers heard (Mike, Rob, caller names if given)"],
|
||||
"caller_questions": ["array of specific questions callers asked, if any"],
|
||||
"key_quotes": [
|
||||
{{"quote": "exact quote text", "speaker": "who said it", "context": "why notable"}}
|
||||
],
|
||||
"blog_worthy_topics": [
|
||||
{{"topic": "topic name", "angle": "what makes it worth expanding", "details_from_show": "specific points Mike made that a blog post should include"}}
|
||||
],
|
||||
"callbacks": ["any references to earlier segments or topics discussed before the break"]
|
||||
}}
|
||||
|
||||
Respond ONLY with valid JSON.
|
||||
|
||||
## Segment {segment['number']} of {segment_count} — Full Transcript
|
||||
|
||||
{segment['text']}"""
|
||||
|
||||
result = timed_query(
|
||||
f"Segment {segment['number']}/{segment_count} ({segment['char_count']} chars)",
|
||||
prompt
|
||||
)
|
||||
return parse_json_response(result)
|
||||
|
||||
|
||||
def cross_segment_synthesis(segment_analyses: list[dict], segments: list[dict]) -> dict:
|
||||
"""Synthesize across all segments for episode-level analysis."""
|
||||
# Build a compact summary of each segment for the synthesis prompt
|
||||
segment_summaries = []
|
||||
for i, analysis in enumerate(segment_analyses, 1):
|
||||
if not analysis:
|
||||
continue
|
||||
segment_summaries.append(
|
||||
f"### Segment {i}: {analysis.get('title', 'Unknown')}\n"
|
||||
f"Summary: {analysis.get('summary', 'N/A')}\n"
|
||||
f"Topics: {', '.join(analysis.get('topics', []))}\n"
|
||||
f"Speakers: {', '.join(analysis.get('speakers', []))}\n"
|
||||
f"Key points: {json.dumps(analysis.get('key_points', []))}\n"
|
||||
f"Callbacks: {json.dumps(analysis.get('callbacks', []))}"
|
||||
)
|
||||
|
||||
all_blog_topics = []
|
||||
for analysis in segment_analyses:
|
||||
if analysis:
|
||||
all_blog_topics.extend(analysis.get("blog_worthy_topics", []))
|
||||
|
||||
prompt = f"""You are producing the final episode analysis for "The Computer Guru Show".
|
||||
Below are analyses of each individual segment. Your job is to synthesize them into a
|
||||
cohesive episode-level view.
|
||||
|
||||
Respond with JSON:
|
||||
|
||||
{{
|
||||
"episode_title": "A compelling episode title that captures the main theme",
|
||||
"episode_summary": "2-3 paragraph summary of the entire episode. Be specific about topics, callers, and conversations. Write in third person, suitable for a podcast episode page.",
|
||||
"narrative_arc": "1 paragraph describing how the show flowed — what opened, how topics evolved, what closed it out",
|
||||
"recurring_themes": ["topics or ideas that came up across multiple segments"],
|
||||
"cross_segment_connections": ["specific callbacks or topic continuations across segments"],
|
||||
"all_topics": ["complete deduplicated list of every topic discussed"],
|
||||
"all_tags": ["SEO-friendly lowercase hyphenated tags"],
|
||||
"top_quotes": [
|
||||
{{"quote": "text", "speaker": "name", "context": "why notable", "segment": 1}}
|
||||
],
|
||||
"blog_post_candidates": [
|
||||
{{
|
||||
"title": "Proposed blog post title",
|
||||
"angle": "specific thesis or angle",
|
||||
"why": "why this deserves expansion",
|
||||
"source_segments": [1, 2],
|
||||
"key_details_from_show": ["specific points, quotes, and examples from the show to include"]
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
Respond ONLY with valid JSON.
|
||||
|
||||
## Per-Segment Analyses
|
||||
|
||||
{chr(10).join(segment_summaries)}
|
||||
|
||||
## Blog-Worthy Topics Identified Across All Segments
|
||||
|
||||
{json.dumps(all_blog_topics, indent=2)}"""
|
||||
|
||||
result = timed_query("Cross-Segment Synthesis", prompt)
|
||||
return parse_json_response(result)
|
||||
|
||||
|
||||
def generate_forum_post(synthesis: dict) -> str:
|
||||
"""Generate forum discussion post from synthesis."""
|
||||
prompt = f"""Write a community forum discussion post for "The Computer Guru Show" forum.
|
||||
|
||||
Episode title: {synthesis.get('episode_title', 'Unknown')}
|
||||
Summary: {synthesis.get('episode_summary', '')}
|
||||
Topics: {json.dumps(synthesis.get('all_topics', []))}
|
||||
Narrative arc: {synthesis.get('narrative_arc', '')}
|
||||
|
||||
Rules:
|
||||
- Conversational, engaging tone that invites discussion
|
||||
- Brief hook (2-3 sentences about the most interesting thing)
|
||||
- Bullet list of topics with one-line teasers
|
||||
- 2-3 discussion questions that invite audience participation
|
||||
- "Listen to the full episode" call-to-action
|
||||
- Under 300 words
|
||||
- Casual, friendly tone
|
||||
- No emojis
|
||||
- No markdown headers larger than ###
|
||||
|
||||
Write the post now."""
|
||||
|
||||
return timed_query("Forum Post", prompt, temperature=0.5)
|
||||
|
||||
|
||||
def generate_blog_post(synthesis: dict, candidate: dict,
|
||||
segments: list[dict]) -> str:
|
||||
"""Generate a blog post using the full segment transcripts for source material."""
|
||||
# Find the source segments referenced by the blog candidate
|
||||
source_nums = candidate.get("source_segments", [1])
|
||||
source_text = ""
|
||||
for num in source_nums:
|
||||
if 0 < num <= len(segments):
|
||||
source_text += f"\n--- Segment {num} transcript ---\n{segments[num-1]['text'][:15000]}\n"
|
||||
|
||||
# If no specific segments referenced, use the first two
|
||||
if not source_text:
|
||||
for seg in segments[:2]:
|
||||
source_text += f"\n--- Segment {seg['number']} transcript ---\n{seg['text'][:10000]}\n"
|
||||
|
||||
prompt = f"""Write a blog post for the Computer Guru Show website (radio.azcomputerguru.com).
|
||||
Author: Mike Swanson — veteran IT professional, radio host in Tucson AZ.
|
||||
|
||||
His writing style:
|
||||
- Explains complex tech in plain English using analogies
|
||||
- Uses humor — dry, self-deprecating, occasionally sarcastic
|
||||
- Gives practical, actionable advice
|
||||
- Takes strong positions on consumer rights, privacy, and corporate BS
|
||||
- Speaks directly to the reader like a friend
|
||||
- References real conversations from the show
|
||||
|
||||
Blog post details:
|
||||
- Title: {candidate.get('title', 'Untitled')}
|
||||
- Angle: {candidate.get('angle', '')}
|
||||
- Key details from show: {json.dumps(candidate.get('key_details_from_show', []))}
|
||||
|
||||
Format:
|
||||
1. Engaging opening paragraph (hook the reader with something from the show)
|
||||
2. 3-5 sections with ### subheadings
|
||||
3. "What This Means for You" practical section
|
||||
4. Key Takeaways (bullet points)
|
||||
5. Closing that ties back to the show conversation
|
||||
|
||||
Target: 800-1200 words. First person as Mike Swanson.
|
||||
End with: "This topic was discussed on The Computer Guru Show. Listen to the full episode for more."
|
||||
|
||||
IMPORTANT: Draw directly from the transcript below. Use Mike's actual words, analogies, and
|
||||
examples — not generic filler. If Mike made a joke or analogy on air, reference it in the post.
|
||||
|
||||
## Source transcript from the show:
|
||||
{source_text}"""
|
||||
|
||||
return timed_query(f"Blog: {candidate.get('title', '?')}", prompt, temperature=0.5)
|
||||
|
||||
|
||||
def main():
|
||||
transcript_dir = sys.argv[1] if len(sys.argv) > 1 else \
|
||||
"training-data/transcripts/2016-s8e42"
|
||||
|
||||
print(f"Loading transcript from: {transcript_dir}")
|
||||
lines = load_transcript(transcript_dir)
|
||||
print(f"Total lines: {len(lines)}")
|
||||
|
||||
# Step 1: Split into segments
|
||||
print(f"\n{'='*60}")
|
||||
print(f" STEP 1: Splitting into segments")
|
||||
print(f"{'='*60}")
|
||||
segments = split_into_segments(lines)
|
||||
print(f" Found {len(segments)} segments:\n")
|
||||
for seg in segments:
|
||||
print(f" Segment {seg['number']}: lines {seg['start_line']}-{seg['end_line']}, "
|
||||
f"{seg['char_count']} chars, {len(seg['lines'])} lines")
|
||||
# Show first line as preview
|
||||
preview = seg['lines'][0][:80] if seg['lines'] else "(empty)"
|
||||
print(f" Preview: {preview}")
|
||||
|
||||
output_dir = Path(transcript_dir) / "generated-v2"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Save segments for reference
|
||||
segments_meta = [{k: v for k, v in s.items() if k != 'lines'} for s in segments]
|
||||
with open(output_dir / "segments.json", "w") as f:
|
||||
json.dump(segments_meta, f, indent=2)
|
||||
|
||||
# Step 2: Analyze each segment
|
||||
print(f"\n{'='*60}")
|
||||
print(f" STEP 2: Analyzing {len(segments)} segments individually")
|
||||
print(f"{'='*60}")
|
||||
segment_analyses = []
|
||||
for seg in segments:
|
||||
analysis = analyze_segment(seg, len(segments))
|
||||
segment_analyses.append(analysis)
|
||||
|
||||
# Save individual segment analysis
|
||||
with open(output_dir / f"segment-{seg['number']}-analysis.json", "w") as f:
|
||||
json.dump(analysis, f, indent=2)
|
||||
|
||||
if analysis:
|
||||
print(f" Title: {analysis.get('title', '?')}")
|
||||
print(f" Topics: {', '.join(analysis.get('topics', []))}")
|
||||
|
||||
# Step 3: Cross-segment synthesis
|
||||
print(f"\n{'='*60}")
|
||||
print(f" STEP 3: Cross-segment synthesis")
|
||||
print(f"{'='*60}")
|
||||
synthesis = cross_segment_synthesis(segment_analyses, segments)
|
||||
with open(output_dir / "synthesis.json", "w") as f:
|
||||
json.dump(synthesis, f, indent=2)
|
||||
|
||||
if synthesis:
|
||||
print(f"\n Episode title: {synthesis.get('episode_title', '?')}")
|
||||
print(f" Recurring themes: {synthesis.get('recurring_themes', [])}")
|
||||
print(f"\n Episode summary:")
|
||||
print(f" {synthesis.get('episode_summary', 'N/A')[:500]}")
|
||||
|
||||
# Step 4: Generate forum post
|
||||
print(f"\n{'='*60}")
|
||||
print(f" STEP 4: Generate content")
|
||||
print(f"{'='*60}")
|
||||
forum_post = generate_forum_post(synthesis)
|
||||
with open(output_dir / "forum-post.md", "w") as f:
|
||||
f.write(forum_post)
|
||||
print(f"\n--- FORUM POST ---")
|
||||
print(forum_post)
|
||||
|
||||
# Step 5: Generate blog post from best candidate
|
||||
candidates = synthesis.get("blog_post_candidates", [])
|
||||
if candidates:
|
||||
blog_post = generate_blog_post(synthesis, candidates[0], segments)
|
||||
slug = re.sub(r'[^a-z0-9]+', '-', candidates[0].get("title", "draft").lower())[:50]
|
||||
with open(output_dir / f"blog-{slug}.md", "w") as f:
|
||||
f.write(blog_post)
|
||||
print(f"\n--- BLOG POST ---")
|
||||
print(blog_post)
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*60}")
|
||||
print(f" COMPLETE — All outputs in: {output_dir}/")
|
||||
print(f"{'='*60}")
|
||||
print(f" Segments analyzed: {len(segments)}")
|
||||
print(f" Per-segment analyses: {sum(1 for a in segment_analyses if a)}")
|
||||
print(f" Blog candidates: {len(candidates)}")
|
||||
print(f" Files generated: {len(list(output_dir.iterdir()))}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,256 +0,0 @@
|
||||
# Training Plan: Using the 579-Episode Archive
|
||||
|
||||
## Available Training Data
|
||||
|
||||
### Episode Archive
|
||||
- **Location:** `/home/gurushow/public_html/archive/` on IX server (172.16.3.10)
|
||||
- **Count:** 579 MP3 files, 7.8GB
|
||||
- **Span:** 2010-2018 (Seasons 6-10)
|
||||
- **Format:** Split into "HR 1" / "HR 2" per episode (2-hour shows)
|
||||
- **Year breakdown:**
|
||||
- 2010: 43 files (664MB)
|
||||
- 2011: 200 files (1.9GB)
|
||||
- 2012: 98 files (1.2GB)
|
||||
- 2014: 81 files (783MB)
|
||||
- 2015: 50 files (461MB)
|
||||
- 2016: 54 files (1.2GB)
|
||||
- 2017: 41 files (1.5GB)
|
||||
- 2018: 5 files (101MB)
|
||||
|
||||
### Show Production Elements
|
||||
- **Location:** `/home/gurushow/public_html/archive/Radio/Elements/`
|
||||
- **Intros:** 5 WAV files (show intro variations, beast intro, kick back intro, streaming intro)
|
||||
- **Outros:** 2 WAV files
|
||||
- **Bumpers:** 7 files (MP3 + WAV) — music stingers for transitions
|
||||
- **Promos:** 2 WAV files (promo windows, show spot)
|
||||
- **Corrected versions:** Separate folder with phone-number-corrected versions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Audio Element Library (Seed + Discover)
|
||||
|
||||
### Purpose
|
||||
Build a library of all show production elements (intros, outros, bumpers, stingers, station IDs) for reliable segment boundary detection. The archive contains SOME elements but not all — different stations and eras used different production elements.
|
||||
|
||||
### Step 1: Seed with known elements
|
||||
1. Download all files from `Radio/Elements/` on IX server (7 MP3 + 18 WAV)
|
||||
2. Convert WAVs to consistent format (mono, 16kHz for fingerprinting)
|
||||
3. Generate chromaprint fingerprints for each element
|
||||
4. Store in `element-library/fingerprints.db` (SQLite)
|
||||
5. Categorize: show-intro, show-outro, segment-bumper, break-bumper, promo
|
||||
|
||||
### Step 2: Discover unknown elements from archive
|
||||
1. Process episodes through the pipeline
|
||||
2. Detect short non-speech audio segments (music, jingles, produced audio)
|
||||
3. Extract each detected clip
|
||||
4. Compare against known fingerprints — if no match, store as candidate
|
||||
5. Compare candidates against each other across episodes
|
||||
6. Cluster: same audio appearing in 3+ episodes = confirmed show element
|
||||
7. Add to fingerprint database as "unnamed" element
|
||||
|
||||
### Step 3: Host review
|
||||
- Present discovered clusters: "This 4-second audio clip appears in 38 episodes between 2015-2017 — what is it?"
|
||||
- Host names and categorizes each cluster
|
||||
- Named elements improve future detection accuracy
|
||||
|
||||
### What This Enables
|
||||
- **Known elements:** Exact boundary detection when a fingerprinted intro/bumper is detected
|
||||
- **Unknown elements:** Even without the source file, if the same jingle appears repeatedly, we know it marks a boundary
|
||||
- **Era awareness:** Elements used in 2011 may differ from 2016 — the library tracks date ranges
|
||||
- **New show elements:** When the show returns in 2026 with a new station, new bumpers get discovered automatically after a few episodes
|
||||
|
||||
### Tools
|
||||
- `chromaprint` / `fpcalc` for audio fingerprinting
|
||||
- `librosa` for spectral analysis and non-speech detection
|
||||
- `dejavu` (Python audio fingerprinting library) or custom matching
|
||||
- SQLite for fingerprint storage and lookup
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Host Voice Profile (Bootstrapped from Archive)
|
||||
|
||||
### Purpose
|
||||
Build an extremely robust speaker embedding for Mike's voice using hundreds of hours of confirmed speech.
|
||||
|
||||
### Method
|
||||
|
||||
#### Step 1: Bootstrap from clean segments
|
||||
The show intros typically have the host speaking directly. Use a handful of episodes where the host is the only speaker for the first few minutes:
|
||||
1. Transcribe 10 diverse episodes (different years, different energy levels)
|
||||
2. Run pyannote diarization
|
||||
3. The dominant speaker in each episode = the host (by far the most speaking time)
|
||||
4. Extract host-only segments from each episode
|
||||
5. Generate embeddings from all host segments
|
||||
6. Average/cluster to create a robust reference embedding
|
||||
|
||||
#### Step 2: Validate across eras
|
||||
The host's voice may have changed subtly over 8 years. Generate per-year embeddings:
|
||||
- 2010 voice profile
|
||||
- 2014 voice profile
|
||||
- 2018 voice profile
|
||||
- 2026 voice profile (from new episodes)
|
||||
|
||||
Store all as the same speaker with temporal metadata. The matching algorithm checks against all variants.
|
||||
|
||||
#### Step 3: Continuous improvement
|
||||
Each processed new episode refines the host embedding (confirmed host segments get folded back in).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Commercial Break Pattern Training
|
||||
|
||||
### Purpose
|
||||
Learn the specific audio patterns that signal commercial breaks. Because not all production elements are in the archive, the detector must combine multiple signals rather than relying solely on fingerprint matching.
|
||||
|
||||
### Method: Multi-Signal Classifier
|
||||
|
||||
The classifier combines all available signals with weighted scoring. No single signal is required — the system degrades gracefully when some signals are unavailable.
|
||||
|
||||
#### Signal 1: Known + discovered element fingerprints
|
||||
- Match detected audio against the element library (Phase 1)
|
||||
- If a known break-bumper is detected, high confidence of a break boundary
|
||||
- If no match, other signals still contribute
|
||||
- **Availability:** Partial for archive episodes (incomplete element library), improves over time via discovery
|
||||
|
||||
#### Signal 2: Speaker identity (from Phase 2)
|
||||
- Host voice present = show content (high confidence)
|
||||
- Host voice absent for >30 seconds = possible break
|
||||
- Multiple unfamiliar voices in quick succession with produced audio = commercial cluster
|
||||
- **Availability:** High — host voice profile is robust from hundreds of hours
|
||||
|
||||
#### Signal 3: Audio characteristics
|
||||
- Extract per-segment features: MFCC, spectral centroid, zero-crossing rate, loudness (LUFS), dynamic range
|
||||
- Commercials typically: higher loudness, more compression, different spectral profile, different room tone
|
||||
- Show content typically: consistent room tone, natural dynamic range, live mic characteristics
|
||||
- **Availability:** Always available — inherent to audio
|
||||
|
||||
#### Signal 4: HR 1/HR 2 boundary training
|
||||
Since archive episodes are split into Hour 1 and Hour 2, the END of HR 1 and START of HR 2 always contain a commercial break boundary. This gives us 194+ confirmed break points.
|
||||
|
||||
1. Take the last 5 minutes of every HR 1 file and first 5 minutes of every HR 2 file
|
||||
2. Analyze the audio feature transition at the show→commercial boundary
|
||||
3. Train a Random Forest classifier on these labeled transitions
|
||||
4. Apply the learned transition pattern to detect similar boundaries within single-file recordings
|
||||
- **Availability:** Training data from archive; model applies to new episodes
|
||||
|
||||
#### Signal 5: Structural heuristics
|
||||
- Commercial breaks are typically 2-5 minutes
|
||||
- Shows typically break every 12-20 minutes
|
||||
- Transition phrases in transcript ("We'll be right back", "Welcome back", "Stay tuned")
|
||||
- Silence gaps >1 second often bookend breaks
|
||||
- **Availability:** Always available
|
||||
|
||||
#### Combined scoring
|
||||
Each signal produces a confidence value (0.0-1.0). Weighted sum determines classification:
|
||||
|
||||
```
|
||||
score = (w1 * fingerprint_match) +
|
||||
(w2 * speaker_absence) +
|
||||
(w3 * audio_characteristics) +
|
||||
(w4 * break_pattern_match) +
|
||||
(w5 * structural_heuristic)
|
||||
|
||||
if score > threshold: classify as commercial
|
||||
```
|
||||
|
||||
Default weights (tunable after validation):
|
||||
- Fingerprint match: 0.30 (strongest when available, but often unavailable)
|
||||
- Speaker identity: 0.25 (very reliable)
|
||||
- Audio characteristics: 0.20 (always available)
|
||||
- Break pattern: 0.15 (learned from archive)
|
||||
- Structural: 0.10 (least reliable alone, but useful confirmation)
|
||||
|
||||
#### Self-calibration
|
||||
After processing a batch of archive episodes:
|
||||
1. Compare detected breaks against HR1/HR2 boundaries (known ground truth)
|
||||
2. Auto-tune weights to maximize accuracy on held-out episodes
|
||||
3. Report accuracy metrics
|
||||
|
||||
### Expected Accuracy
|
||||
- With all signals available (including fingerprint match): >95%
|
||||
- Without fingerprint matches (new station, new elements): >85%
|
||||
- Improves over time as element discovery adds to the fingerprint library
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Repeat Speaker Detection
|
||||
|
||||
### Purpose
|
||||
Identify co-hosts, regular callers, and guests across the archive.
|
||||
|
||||
### Method
|
||||
1. Diarize a representative sample (20-30 episodes across all years)
|
||||
2. For each episode, extract embeddings for all non-host speakers
|
||||
3. Cluster all non-host embeddings across all episodes
|
||||
4. Clusters that appear in multiple episodes = repeat speakers
|
||||
5. Present clusters to the host for naming: "This voice appears in 47 episodes — who is this?"
|
||||
6. Save named speaker profiles
|
||||
|
||||
### Known Speakers to Look For
|
||||
- Co-hosts (Harry mentioned in early episodes)
|
||||
- Regular callers
|
||||
- Recurring guests
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Batch Processing Pipeline
|
||||
|
||||
### Purpose
|
||||
Process the full archive to build the training dataset and generate transcripts.
|
||||
|
||||
### Approach: Incremental, not all-at-once
|
||||
|
||||
**Batch 1: Training set (10 episodes)**
|
||||
- Select 10 episodes spanning different years
|
||||
- Full transcription + diarization
|
||||
- Manual review to validate accuracy
|
||||
- Use results to tune parameters
|
||||
|
||||
**Batch 2: Element fingerprinting**
|
||||
- Download and fingerprint all show elements
|
||||
- Test detection against Batch 1 episodes
|
||||
|
||||
**Batch 3: Commercial detection training**
|
||||
- Process 50 HR1/HR2 pairs
|
||||
- Train break detection classifier
|
||||
- Validate against held-out episodes
|
||||
|
||||
**Batch 4: Full archive (optional, on demand)**
|
||||
- Process remaining episodes as background task
|
||||
- Each episode: ~5-10 minutes to transcribe on RTX 5070 Ti
|
||||
- Full archive: ~50-100 hours of compute time
|
||||
- Run overnight in batches
|
||||
|
||||
### Storage Requirements
|
||||
- Transcripts (JSON): ~500KB per episode × 194 = ~100MB
|
||||
- Speaker embeddings: negligible
|
||||
- Processed audio (if re-encoding): skip unless needed
|
||||
- Total new storage: < 500MB for all metadata
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **Set up Python environment** — venv with faster-whisper, pyannote, torch CUDA
|
||||
2. **Download show elements** — Fingerprint the known intros/outros/bumpers (seed library)
|
||||
3. **Process 3-5 archive episodes** — Validate transcription + diarization quality
|
||||
4. **Build host voice profile** — Bootstrap from initial batch
|
||||
5. **Run element discovery on initial batch** — Find unknown elements, begin clustering
|
||||
6. **Train commercial detector** — Using HR1/HR2 boundaries + all available signals
|
||||
7. **Process 20-30 more episodes** — Expand element library, refine classifier weights, discover repeat speakers
|
||||
8. **Host review session** — Name discovered elements and speaker clusters
|
||||
9. **Build the CLI tool** — Wire it all together with config file
|
||||
10. **Process a new 2026 episode end-to-end** — Full pipeline test with new station's elements
|
||||
11. **Batch process remaining archive** — Background task, overnight
|
||||
|
||||
---
|
||||
|
||||
## Disk Space Plan
|
||||
|
||||
The archive is 7.8GB on IX server. Options:
|
||||
1. **Stream from server** — Process one at a time via SSH/SCP, don't store locally
|
||||
2. **Download subset** — Training set only (~500MB for 10 episodes + elements)
|
||||
3. **Download all** — 7.8GB to local disk (easy, NVMe has plenty of space)
|
||||
4. **NFS/SSHFS mount** — Mount the IX server directory, process in place
|
||||
|
||||
Recommendation: Download the elements + 10-episode training set first. Full archive download only when ready for batch processing.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user