Ep13 publish, MLX whisper, voicemail system, hero redesign, massive topic expansion

- Switch whisper transcription from faster-whisper (CPU) to lightning-whisper-mlx (GPU)
- Fix word_timestamps hanging, use ffprobe for accurate duration
- Add Cloudflare Pages Worker for SignalWire voicemail fallback when server offline
- Add voicemail sync on startup, delete tracking, save feature
- Add /feed RSS proxy to _worker.js (was broken by worker taking over routing)
- Redesign website hero section: ghost buttons, compact phone, plain text links
- Rewrite caller prompts for faster point-getting and host-following
- Expand TOPIC_CALLIN from ~250 to 547 entries across 34 categories
- Add new categories: biology, psychology, engineering, math, geology, animals,
  work, money, books, movies, relationships, health, language, true crime,
  drunk/high/unhinged callers
- Remove bad Inworld voices (Pixie, Dominus), reduce repeat caller frequency
- Add audio monitor device routing, uvicorn --reload-dir fix
- Publish episode 13

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 01:56:47 -07:00
parent 8d3d67a177
commit 3164a70e48
23 changed files with 2944 additions and 512 deletions

View File

@@ -349,6 +349,19 @@ section h2 {
margin-bottom: 10px;
}
.music-section select optgroup {
color: var(--accent);
font-weight: bold;
font-style: normal;
padding: 4px 0;
}
.music-section select option {
color: var(--text);
font-weight: normal;
padding: 2px 8px;
}
.music-controls {
display: flex;
gap: 8px;
@@ -725,3 +738,26 @@ section h2 {
.message.real-caller { border-left: 3px solid var(--accent-red); padding-left: 0.5rem; }
.message.ai-caller { border-left: 3px solid var(--accent); padding-left: 0.5rem; }
.message.host { border-left: 3px solid var(--accent-green); padding-left: 0.5rem; }
/* Voicemail */
.voicemail-section { margin: 1rem 0; }
.voicemail-list { border: 1px solid rgba(232, 121, 29, 0.15); border-radius: var(--radius-sm); padding: 0.5rem; max-height: 200px; overflow-y: auto; }
.voicemail-badge { background: var(--accent-red); color: white; font-size: 0.7rem; font-weight: bold; padding: 0.1rem 0.45rem; border-radius: 10px; margin-left: 0.4rem; vertical-align: middle; }
.voicemail-badge.hidden { display: none; }
.vm-item { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.5rem; border-bottom: 1px solid rgba(232, 121, 29, 0.08); }
.vm-item:last-child { border-bottom: none; }
.vm-item.vm-unlistened { background: rgba(232, 121, 29, 0.06); }
.vm-info { display: flex; gap: 0.6rem; align-items: center; flex: 1; min-width: 0; }
.vm-phone { font-family: monospace; color: var(--accent); font-size: 0.85rem; }
.vm-time { color: var(--text-muted); font-size: 0.8rem; }
.vm-dur { color: var(--text-muted); font-size: 0.8rem; }
.vm-actions { display: flex; gap: 0.3rem; flex-shrink: 0; }
.vm-btn { border: none; padding: 0.2rem 0.5rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.75rem; transition: background 0.2s; }
.vm-btn.listen { background: var(--accent); color: white; }
.vm-btn.listen:hover { background: var(--accent-hover); }
.vm-btn.on-air { background: var(--accent-green); color: white; }
.vm-btn.on-air:hover { background: #6a9a4c; }
.vm-btn.save { background: #3a7bd5; color: white; }
.vm-btn.save:hover { background: #2a5db0; }
.vm-btn.delete { background: var(--accent-red); color: white; }
.vm-btn.delete:hover { background: #e03030; }

View File

@@ -65,6 +65,14 @@
</div>
</section>
<!-- Voicemail -->
<section class="voicemail-section">
<h2>Voicemail <span id="voicemail-badge" class="voicemail-badge hidden">0</span></h2>
<div id="voicemail-list" class="voicemail-list">
<div class="queue-empty">No voicemails</div>
</div>
</section>
<!-- Chat -->
<section class="chat-section">
<div id="chat" class="chat-log"></div>
@@ -224,6 +232,6 @@
</div>
</div>
<script src="/js/app.js?v=15"></script>
<script src="/js/app.js?v=17"></script>
</body>
</html>

View File

@@ -16,6 +16,15 @@ let tracks = [];
let sounds = [];
// --- Helpers ---
function _isTyping() {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
// --- Safe JSON parsing ---
async function safeFetch(url, options = {}, timeoutMs = 30000) {
const controller = new AbortController();
@@ -51,6 +60,8 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadSounds();
await loadSettings();
initEventListeners();
loadVoicemails();
setInterval(loadVoicemails, 30000);
log('Ready. Configure audio devices in Settings, then click a caller to start.');
console.log('AI Radio Show ready');
} catch (err) {
@@ -137,6 +148,20 @@ function initEventListeners() {
talkBtn.addEventListener('touchend', e => { e.preventDefault(); stopRecording(); });
}
// Spacebar push-to-talk — blur buttons so Space doesn't also trigger button click
document.addEventListener('keydown', e => {
if (e.code !== 'Space' || e.repeat || _isTyping()) return;
e.preventDefault();
// Blur any focused button so browser doesn't fire its click
if (document.activeElement?.tagName === 'BUTTON') document.activeElement.blur();
startRecording();
});
document.addEventListener('keyup', e => {
if (e.code !== 'Space' || _isTyping()) return;
e.preventDefault();
stopRecording();
});
// Type button
document.getElementById('type-btn')?.addEventListener('click', () => {
document.getElementById('type-modal')?.classList.remove('hidden');
@@ -630,11 +655,31 @@ async function loadMusic() {
const previousValue = select.value;
select.innerHTML = '';
tracks.forEach((track, i) => {
const option = document.createElement('option');
option.value = track.file;
option.textContent = track.name;
select.appendChild(option);
// Group tracks by genre
const genres = {};
tracks.forEach(track => {
const genre = track.genre || 'Other';
if (!genres[genre]) genres[genre] = [];
genres[genre].push(track);
});
// Sort genre names, but put "Other" last
const genreOrder = Object.keys(genres).sort((a, b) => {
if (a === 'Other') return 1;
if (b === 'Other') return -1;
return a.localeCompare(b);
});
genreOrder.forEach(genre => {
const group = document.createElement('optgroup');
group.label = genre;
genres[genre].forEach(track => {
const option = document.createElement('option');
option.value = track.file;
option.textContent = track.name;
group.appendChild(option);
});
select.appendChild(group);
});
// Restore previous selection if it still exists
@@ -1225,3 +1270,93 @@ async function stopServer() {
log('Failed to stop server: ' + err.message);
}
}
// --- Voicemail ---
let _currentVmAudio = null;
async function loadVoicemails() {
try {
const res = await fetch('/api/voicemails');
const data = await res.json();
renderVoicemails(data);
} catch (err) {}
}
function renderVoicemails(voicemails) {
const list = document.getElementById('voicemail-list');
const badge = document.getElementById('voicemail-badge');
if (!list) return;
const unlistened = voicemails.filter(v => !v.listened).length;
if (badge) {
badge.textContent = unlistened;
badge.classList.toggle('hidden', unlistened === 0);
}
if (voicemails.length === 0) {
list.innerHTML = '<div class="queue-empty">No voicemails</div>';
return;
}
list.innerHTML = voicemails.map(v => {
const date = new Date(v.timestamp * 1000);
const timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const mins = Math.floor(v.duration / 60);
const secs = v.duration % 60;
const durStr = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
const unlistenedCls = v.listened ? '' : ' vm-unlistened';
return `<div class="vm-item${unlistenedCls}" data-id="${v.id}">
<div class="vm-info">
<span class="vm-phone">${v.phone}</span>
<span class="vm-time">${timeStr}</span>
<span class="vm-dur">${durStr}</span>
</div>
<div class="vm-actions">
<button class="vm-btn listen" onclick="listenVoicemail('${v.id}')">Listen</button>
<button class="vm-btn on-air" onclick="playVoicemailOnAir('${v.id}')">On Air</button>
<button class="vm-btn save" onclick="saveVoicemail('${v.id}')">Save</button>
<button class="vm-btn delete" onclick="deleteVoicemail('${v.id}')">Del</button>
</div>
</div>`;
}).join('');
}
function listenVoicemail(id) {
if (_currentVmAudio) {
_currentVmAudio.pause();
_currentVmAudio = null;
}
_currentVmAudio = new Audio(`/api/voicemail/${id}/audio`);
_currentVmAudio.play();
fetch(`/api/voicemail/${id}/mark-listened`, { method: 'POST' }).then(() => loadVoicemails());
}
async function playVoicemailOnAir(id) {
try {
await safeFetch(`/api/voicemail/${id}/play-on-air`, { method: 'POST' });
log('Playing voicemail on air');
loadVoicemails();
} catch (err) {
log('Failed to play voicemail: ' + err.message);
}
}
async function saveVoicemail(id) {
try {
await safeFetch(`/api/voicemail/${id}/save`, { method: 'POST' });
log('Voicemail saved to archive');
} catch (err) {
log('Failed to save voicemail: ' + err.message);
}
}
async function deleteVoicemail(id) {
if (!confirm('Delete this voicemail?')) return;
try {
await safeFetch(`/api/voicemail/${id}`, { method: 'DELETE' });
loadVoicemails();
} catch (err) {
log('Failed to delete voicemail: ' + err.message);
}
}