Add listener email system with IMAP polling, TTS playback, and show awareness
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,11 @@ class Settings(BaseSettings):
|
|||||||
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
|
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
|
||||||
signalwire_stream_url: str = os.getenv("SIGNALWIRE_STREAM_URL", "")
|
signalwire_stream_url: str = os.getenv("SIGNALWIRE_STREAM_URL", "")
|
||||||
|
|
||||||
|
# Email (IMAP)
|
||||||
|
submissions_imap_host: str = os.getenv("SUBMISSIONS_IMAP_HOST", "")
|
||||||
|
submissions_imap_user: str = os.getenv("SUBMISSIONS_IMAP_USER", "")
|
||||||
|
submissions_imap_pass: str = os.getenv("SUBMISSIONS_IMAP_PASS", "")
|
||||||
|
|
||||||
# LLM Settings
|
# LLM Settings
|
||||||
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
llm_provider: str = "openrouter" # "openrouter" or "ollama"
|
||||||
openrouter_model: str = "anthropic/claude-sonnet-4-5"
|
openrouter_model: str = "anthropic/claude-sonnet-4-5"
|
||||||
|
|||||||
203
backend/main.py
203
backend/main.py
@@ -2717,15 +2717,22 @@ class Session:
|
|||||||
def get_show_history(self) -> str:
|
def get_show_history(self) -> str:
|
||||||
"""Get formatted show history for AI caller prompts.
|
"""Get formatted show history for AI caller prompts.
|
||||||
Randomly picks one previous caller to have a strong reaction to."""
|
Randomly picks one previous caller to have a strong reaction to."""
|
||||||
if not self.call_history:
|
if not self.call_history and not any(e.read_on_air for e in _listener_emails):
|
||||||
return ""
|
return ""
|
||||||
lines = ["EARLIER IN THE SHOW:"]
|
lines = ["EARLIER IN THE SHOW:"]
|
||||||
for record in self.call_history:
|
for record in self.call_history:
|
||||||
caller_type_label = "(real caller)" if record.caller_type == "real" else "(AI)"
|
caller_type_label = "(real caller)" if record.caller_type == "real" else "(AI)"
|
||||||
lines.append(f"- {record.caller_name} {caller_type_label}: {record.summary}")
|
lines.append(f"- {record.caller_name} {caller_type_label}: {record.summary}")
|
||||||
|
|
||||||
|
# Include emails that were read on the show
|
||||||
|
read_emails = [e for e in _listener_emails if e.read_on_air]
|
||||||
|
for em in read_emails:
|
||||||
|
sender_name = em.sender.split("<")[0].strip().strip('"') if "<" in em.sender else "a listener"
|
||||||
|
preview = em.body[:150] if len(em.body) > 150 else em.body
|
||||||
|
lines.append(f"- A listener email from {sender_name} was read on air: \"{em.subject}\" — {preview}")
|
||||||
|
|
||||||
# 20% chance to have a strong reaction to a previous caller
|
# 20% chance to have a strong reaction to a previous caller
|
||||||
if random.random() < 0.20:
|
if self.call_history and random.random() < 0.20:
|
||||||
target = random.choice(self.call_history)
|
target = random.choice(self.call_history)
|
||||||
reaction = random.choice(SHOW_HISTORY_REACTIONS)
|
reaction = random.choice(SHOW_HISTORY_REACTIONS)
|
||||||
lines.append(f"\nYOU HEARD {target.caller_name.upper()} EARLIER and you {reaction}. Mention it if it comes up.")
|
lines.append(f"\nYOU HEARD {target.caller_name.upper()} EARLIER and you {reaction}. Mention it if it comes up.")
|
||||||
@@ -3092,7 +3099,9 @@ async def _sync_signalwire_voicemails():
|
|||||||
async def startup():
|
async def startup():
|
||||||
"""Pre-generate caller backgrounds on server start"""
|
"""Pre-generate caller backgrounds on server start"""
|
||||||
_load_voicemails()
|
_load_voicemails()
|
||||||
|
_load_emails()
|
||||||
asyncio.create_task(_sync_signalwire_voicemails())
|
asyncio.create_task(_sync_signalwire_voicemails())
|
||||||
|
asyncio.create_task(_poll_imap_emails())
|
||||||
restored = _load_checkpoint()
|
restored = _load_checkpoint()
|
||||||
if not restored:
|
if not restored:
|
||||||
asyncio.create_task(_pregenerate_backgrounds())
|
asyncio.create_task(_pregenerate_backgrounds())
|
||||||
@@ -3418,6 +3427,196 @@ async def delete_voicemail(vm_id: str):
|
|||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Listener Emails ---
|
||||||
|
EMAILS_META = Path(__file__).parent.parent / "data" / "emails.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ListenerEmail:
|
||||||
|
id: str
|
||||||
|
sender: str
|
||||||
|
subject: str
|
||||||
|
body: str
|
||||||
|
timestamp: float
|
||||||
|
read_on_air: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
_listener_emails: list[ListenerEmail] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _load_emails():
|
||||||
|
global _listener_emails
|
||||||
|
if EMAILS_META.exists():
|
||||||
|
try:
|
||||||
|
with open(EMAILS_META) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
_listener_emails = [
|
||||||
|
ListenerEmail(
|
||||||
|
id=e["id"], sender=e["sender"], subject=e["subject"],
|
||||||
|
body=e["body"], timestamp=e["timestamp"],
|
||||||
|
read_on_air=e.get("read_on_air", False),
|
||||||
|
)
|
||||||
|
for e in data.get("emails", [])
|
||||||
|
]
|
||||||
|
print(f"[Email] Loaded {len(_listener_emails)} emails")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Email] Failed to load: {e}")
|
||||||
|
_listener_emails = []
|
||||||
|
|
||||||
|
|
||||||
|
def _save_emails():
|
||||||
|
try:
|
||||||
|
EMAILS_META.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
data = {
|
||||||
|
"emails": [
|
||||||
|
{
|
||||||
|
"id": e.id, "sender": e.sender, "subject": e.subject,
|
||||||
|
"body": e.body, "timestamp": e.timestamp,
|
||||||
|
"read_on_air": e.read_on_air,
|
||||||
|
}
|
||||||
|
for e in _listener_emails
|
||||||
|
],
|
||||||
|
}
|
||||||
|
with open(EMAILS_META, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[Email] Failed to save: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_imap_emails():
|
||||||
|
"""Background task: poll IMAP every 30s for new listener emails"""
|
||||||
|
import imaplib
|
||||||
|
import email as email_lib
|
||||||
|
from email.header import decode_header
|
||||||
|
|
||||||
|
host = settings.submissions_imap_host
|
||||||
|
user = settings.submissions_imap_user
|
||||||
|
passwd = settings.submissions_imap_pass
|
||||||
|
if not host or not user or not passwd:
|
||||||
|
print("[Email] IMAP not configured, skipping email polling")
|
||||||
|
return
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
mail = imaplib.IMAP4_SSL(host, 993)
|
||||||
|
mail.login(user, passwd)
|
||||||
|
mail.select("INBOX")
|
||||||
|
|
||||||
|
_, msg_nums = mail.search(None, "UNSEEN")
|
||||||
|
if msg_nums[0]:
|
||||||
|
for num in msg_nums[0].split():
|
||||||
|
_, msg_data = mail.fetch(num, "(RFC822)")
|
||||||
|
raw = msg_data[0][1]
|
||||||
|
msg = email_lib.message_from_bytes(raw)
|
||||||
|
|
||||||
|
# Decode sender
|
||||||
|
from_raw = msg.get("From", "Unknown")
|
||||||
|
|
||||||
|
# Decode subject
|
||||||
|
subj_raw = msg.get("Subject", "(no subject)")
|
||||||
|
decoded_parts = decode_header(subj_raw)
|
||||||
|
subject = ""
|
||||||
|
for part, charset in decoded_parts:
|
||||||
|
if isinstance(part, bytes):
|
||||||
|
subject += part.decode(charset or "utf-8", errors="replace")
|
||||||
|
else:
|
||||||
|
subject += part
|
||||||
|
|
||||||
|
# Extract plain text body
|
||||||
|
body = ""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
if part.get_content_type() == "text/plain":
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
body = payload.decode(charset, errors="replace")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
if payload:
|
||||||
|
charset = msg.get_content_charset() or "utf-8"
|
||||||
|
body = payload.decode(charset, errors="replace")
|
||||||
|
|
||||||
|
body = body.strip()
|
||||||
|
if not body:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse timestamp from email Date header
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
try:
|
||||||
|
ts = parsedate_to_datetime(msg.get("Date", "")).timestamp()
|
||||||
|
except Exception:
|
||||||
|
ts = time.time()
|
||||||
|
|
||||||
|
em = ListenerEmail(
|
||||||
|
id=str(uuid.uuid4())[:8],
|
||||||
|
sender=from_raw,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
timestamp=ts,
|
||||||
|
)
|
||||||
|
_listener_emails.append(em)
|
||||||
|
print(f"[Email] New email from {from_raw}: {subject[:50]}")
|
||||||
|
|
||||||
|
# Mark as SEEN (already done by fetch with UNSEEN filter)
|
||||||
|
mail.store(num, "+FLAGS", "\\Seen")
|
||||||
|
|
||||||
|
_save_emails()
|
||||||
|
|
||||||
|
mail.logout()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[Email] IMAP poll error: {exc}")
|
||||||
|
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/emails")
|
||||||
|
async def list_emails():
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": e.id, "sender": e.sender, "subject": e.subject,
|
||||||
|
"body": e.body, "timestamp": e.timestamp,
|
||||||
|
"read_on_air": e.read_on_air,
|
||||||
|
}
|
||||||
|
for e in sorted(_listener_emails, key=lambda e: e.timestamp, reverse=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/email/{email_id}/play-on-air")
|
||||||
|
async def play_email_on_air(email_id: str):
|
||||||
|
em = next((e for e in _listener_emails if e.id == email_id), None)
|
||||||
|
if not em:
|
||||||
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
|
|
||||||
|
# Extract display name, fall back to just "a listener"
|
||||||
|
sender_name = em.sender.split("<")[0].strip().strip('"') if "<" in em.sender else "a listener"
|
||||||
|
intro = f"This email is from {sender_name}. Subject: {em.subject}."
|
||||||
|
full_text = f"{intro}\n\n{em.body}"
|
||||||
|
|
||||||
|
async def _generate_and_play():
|
||||||
|
try:
|
||||||
|
audio_bytes = await generate_speech(full_text, "Alex", phone_quality="none", apply_filter=False)
|
||||||
|
audio_service.play_caller_audio(audio_bytes, 24000)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[Email] TTS playback error: {exc}")
|
||||||
|
|
||||||
|
asyncio.create_task(_generate_and_play())
|
||||||
|
em.read_on_air = True
|
||||||
|
_save_emails()
|
||||||
|
return {"status": "playing"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/email/{email_id}")
|
||||||
|
async def delete_email(email_id: str):
|
||||||
|
em = next((e for e in _listener_emails if e.id == email_id), None)
|
||||||
|
if not em:
|
||||||
|
raise HTTPException(status_code=404, detail="Email not found")
|
||||||
|
_listener_emails.remove(em)
|
||||||
|
_save_emails()
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
async def _signalwire_end_call(call_sid: str):
|
async def _signalwire_end_call(call_sid: str):
|
||||||
"""End a phone call via SignalWire REST API"""
|
"""End a phone call via SignalWire REST API"""
|
||||||
if not call_sid or not settings.signalwire_space:
|
if not call_sid or not settings.signalwire_space:
|
||||||
|
|||||||
@@ -761,3 +761,13 @@ section h2 {
|
|||||||
.vm-btn.save:hover { background: #2a5db0; }
|
.vm-btn.save:hover { background: #2a5db0; }
|
||||||
.vm-btn.delete { background: var(--accent-red); color: white; }
|
.vm-btn.delete { background: var(--accent-red); color: white; }
|
||||||
.vm-btn.delete:hover { background: #e03030; }
|
.vm-btn.delete:hover { background: #e03030; }
|
||||||
|
|
||||||
|
/* Listener Emails */
|
||||||
|
.email-item { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.5rem; border-bottom: 1px solid rgba(232, 121, 29, 0.08); }
|
||||||
|
.email-item:last-child { border-bottom: none; }
|
||||||
|
.email-item.vm-unlistened { background: rgba(232, 121, 29, 0.06); }
|
||||||
|
.email-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.email-sender { color: var(--accent); font-size: 0.85rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.email-subject { font-size: 0.85rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.email-preview { font-size: 0.8rem; color: var(--text-muted); line-height: 1.3; }
|
||||||
|
.email-item .vm-actions { margin-top: 0.25rem; }
|
||||||
|
|||||||
@@ -73,6 +73,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Listener Emails -->
|
||||||
|
<section class="voicemail-section">
|
||||||
|
<h2>Emails <span id="email-badge" class="voicemail-badge hidden">0</span></h2>
|
||||||
|
<div id="email-list" class="voicemail-list" style="max-height:300px">
|
||||||
|
<div class="queue-empty">No emails</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Chat -->
|
<!-- Chat -->
|
||||||
<section class="chat-section">
|
<section class="chat-section">
|
||||||
<div id="chat" class="chat-log"></div>
|
<div id="chat" class="chat-log"></div>
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
initEventListeners();
|
initEventListeners();
|
||||||
loadVoicemails();
|
loadVoicemails();
|
||||||
setInterval(loadVoicemails, 30000);
|
setInterval(loadVoicemails, 30000);
|
||||||
|
loadEmails();
|
||||||
|
setInterval(loadEmails, 30000);
|
||||||
log('Ready. Configure audio devices in Settings, then click a caller to start.');
|
log('Ready. Configure audio devices in Settings, then click a caller to start.');
|
||||||
console.log('AI Radio Show ready');
|
console.log('AI Radio Show ready');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1360,3 +1362,85 @@ async function deleteVoicemail(id) {
|
|||||||
log('Failed to delete voicemail: ' + err.message);
|
log('Failed to delete voicemail: ' + err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Listener Emails ---
|
||||||
|
async function loadEmails() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/emails');
|
||||||
|
const data = await res.json();
|
||||||
|
renderEmails(data);
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmails(emails) {
|
||||||
|
const list = document.getElementById('email-list');
|
||||||
|
const badge = document.getElementById('email-badge');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
const unread = emails.filter(e => !e.read_on_air).length;
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = unread;
|
||||||
|
badge.classList.toggle('hidden', unread === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emails.length === 0) {
|
||||||
|
list.innerHTML = '<div class="queue-empty">No emails</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = emails.map(e => {
|
||||||
|
const date = new Date(e.timestamp * 1000);
|
||||||
|
const timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const preview = e.body.length > 120 ? e.body.substring(0, 120) + '…' : e.body;
|
||||||
|
const unreadCls = e.read_on_air ? '' : ' vm-unlistened';
|
||||||
|
const senderName = e.sender.replace(/<.*>/, '').trim() || e.sender;
|
||||||
|
return `<div class="email-item${unreadCls}" data-id="${e.id}">
|
||||||
|
<div class="email-header">
|
||||||
|
<span class="email-sender">${escapeHtml(senderName)}</span>
|
||||||
|
<span class="vm-time">${timeStr}</span>
|
||||||
|
</div>
|
||||||
|
<div class="email-subject">${escapeHtml(e.subject)}</div>
|
||||||
|
<div class="email-preview">${escapeHtml(preview)}</div>
|
||||||
|
<div class="vm-actions">
|
||||||
|
<button class="vm-btn listen" onclick="viewEmail('${e.id}')">View</button>
|
||||||
|
<button class="vm-btn on-air" onclick="playEmailOnAir('${e.id}')">On Air (TTS)</button>
|
||||||
|
<button class="vm-btn delete" onclick="deleteEmail('${e.id}')">Del</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewEmail(id) {
|
||||||
|
fetch('/api/emails').then(r => r.json()).then(emails => {
|
||||||
|
const em = emails.find(e => e.id === id);
|
||||||
|
if (!em) return;
|
||||||
|
alert(`From: ${em.sender}\nSubject: ${em.subject}\n\n${em.body}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playEmailOnAir(id) {
|
||||||
|
try {
|
||||||
|
await safeFetch(`/api/email/${id}/play-on-air`, { method: 'POST' });
|
||||||
|
log('Reading email on air (TTS)');
|
||||||
|
loadEmails();
|
||||||
|
} catch (err) {
|
||||||
|
log('Failed to play email: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEmail(id) {
|
||||||
|
if (!confirm('Delete this email?')) return;
|
||||||
|
try {
|
||||||
|
await safeFetch(`/api/email/${id}`, { method: 'DELETE' });
|
||||||
|
loadEmails();
|
||||||
|
} catch (err) {
|
||||||
|
log('Failed to delete email: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
||||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,14 @@
|
|||||||
|
|
||||||
<div class="hiw-step">
|
<div class="hiw-step">
|
||||||
<div class="hiw-step-number">6</div>
|
<div class="hiw-step-number">6</div>
|
||||||
|
<div class="hiw-step-content">
|
||||||
|
<h3>Listener Emails</h3>
|
||||||
|
<p>Listeners can send emails to <a href="mailto:submissions@lukeattheroost.com" style="color:var(--accent)">submissions@lukeattheroost.com</a> and have them read on the show. A background poller checks for new messages every 30 seconds — they show up in the control room as soon as they arrive. Luke can read them himself on the mic, or hit a button to have an AI voice read them aloud on the caller channel. It's like a call-in show meets a letters segment — listeners who can't call in can still be part of the conversation.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hiw-step">
|
||||||
|
<div class="hiw-step-number">7</div>
|
||||||
<div class="hiw-step-content">
|
<div class="hiw-step-content">
|
||||||
<h3>The Control Room</h3>
|
<h3>The Control Room</h3>
|
||||||
<p>The entire show runs through a custom-built control panel. Luke manages callers, plays music and sound effects, runs ads, monitors the call queue, and controls everything from one screen. Audio is routed across multiple channels simultaneously — caller voices, music, sound effects, and live phone audio all on separate tracks. The website shows a live on-air indicator so listeners know when to call in.</p>
|
<p>The entire show runs through a custom-built control panel. Luke manages callers, plays music and sound effects, runs ads, monitors the call queue, and controls everything from one screen. Audio is routed across multiple channels simultaneously — caller voices, music, sound effects, and live phone audio all on separate tracks. The website shows a live on-air indicator so listeners know when to call in.</p>
|
||||||
@@ -203,6 +211,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<span>Voicemails</span>
|
<span>Voicemails</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="diagram-box diagram-accent">
|
||||||
|
<div class="diagram-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>Listener Emails</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="diagram-arrow">↓</div>
|
<div class="diagram-arrow">↓</div>
|
||||||
<!-- Row 2: Control Room -->
|
<!-- Row 2: Control Room -->
|
||||||
@@ -396,7 +410,7 @@
|
|||||||
|
|
||||||
<div class="hiw-steps">
|
<div class="hiw-steps">
|
||||||
<div class="hiw-step">
|
<div class="hiw-step">
|
||||||
<div class="hiw-step-number">7</div>
|
<div class="hiw-step-number">8</div>
|
||||||
<div class="hiw-step-content">
|
<div class="hiw-step-content">
|
||||||
<h3>Multi-Stem Recording</h3>
|
<h3>Multi-Stem Recording</h3>
|
||||||
<p>During every show, the system records five separate audio stems simultaneously: host microphone, AI caller voices, music, sound effects, and ads. Each stem is captured as an independent WAV file with sample-accurate alignment. This gives full control over the final mix — like having a recording studio's multitrack session, not just a flat recording.</p>
|
<p>During every show, the system records five separate audio stems simultaneously: host microphone, AI caller voices, music, sound effects, and ads. Each stem is captured as an independent WAV file with sample-accurate alignment. This gives full control over the final mix — like having a recording studio's multitrack session, not just a flat recording.</p>
|
||||||
@@ -422,7 +436,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hiw-step">
|
<div class="hiw-step">
|
||||||
<div class="hiw-step-number">8</div>
|
<div class="hiw-step-number">9</div>
|
||||||
<div class="hiw-step-content">
|
<div class="hiw-step-content">
|
||||||
<h3>Post-Production Pipeline</h3>
|
<h3>Post-Production Pipeline</h3>
|
||||||
<p>Once the show ends, a 15-step automated pipeline processes the raw stems into a broadcast-ready episode. Ads and sound effects are hard-limited to prevent clipping. The host mic gets a high-pass filter, de-essing, and breath reduction. Voice tracks are compressed — the host gets aggressive spoken-word compression for consistent levels, callers get telephone EQ to sound like real phone calls. All stems are level-matched, music is ducked under dialog and muted during ads, then everything is mixed to stereo with panning and width. A bus compressor glues the final mix together before silence trimming, fades, and EBU R128 loudness normalization.</p>
|
<p>Once the show ends, a 15-step automated pipeline processes the raw stems into a broadcast-ready episode. Ads and sound effects are hard-limited to prevent clipping. The host mic gets a high-pass filter, de-essing, and breath reduction. Voice tracks are compressed — the host gets aggressive spoken-word compression for consistent levels, callers get telephone EQ to sound like real phone calls. All stems are level-matched, music is ducked under dialog and muted during ads, then everything is mixed to stereo with panning and width. A bus compressor glues the final mix together before silence trimming, fades, and EBU R128 loudness normalization.</p>
|
||||||
@@ -448,7 +462,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hiw-step">
|
<div class="hiw-step">
|
||||||
<div class="hiw-step-number">9</div>
|
<div class="hiw-step-number">10</div>
|
||||||
<div class="hiw-step-content">
|
<div class="hiw-step-content">
|
||||||
<h3>Automated Publishing</h3>
|
<h3>Automated Publishing</h3>
|
||||||
<p>A single command takes a finished episode and handles everything: the audio is transcribed using MLX Whisper running on Apple Silicon GPU to generate full-text transcripts, then an LLM analyzes the transcript to write the episode title, description, and chapter markers with timestamps. The episode is uploaded to the podcast server, chapters and transcripts are attached to the metadata, and all media is synced to a global CDN so listeners everywhere get fast downloads.</p>
|
<p>A single command takes a finished episode and handles everything: the audio is transcribed using MLX Whisper running on Apple Silicon GPU to generate full-text transcripts, then an LLM analyzes the transcript to write the episode title, description, and chapter markers with timestamps. The episode is uploaded to the podcast server, chapters and transcripts are attached to the metadata, and all media is synced to a global CDN so listeners everywhere get fast downloads.</p>
|
||||||
@@ -474,7 +488,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hiw-step">
|
<div class="hiw-step">
|
||||||
<div class="hiw-step-number">10</div>
|
<div class="hiw-step-number">11</div>
|
||||||
<div class="hiw-step-content">
|
<div class="hiw-step-content">
|
||||||
<h3>Automated Social Clips</h3>
|
<h3>Automated Social Clips</h3>
|
||||||
<p>No manual editing, no scheduling tools. After each episode, an LLM reads the full transcript and picks the best moments — funny exchanges, wild confessions, heated debates. Each clip is automatically extracted, transcribed with word-level timestamps, then polished by a second LLM pass that fixes punctuation, capitalization, and misheard words while preserving timing. The clips are rendered as vertical video with speaker-labeled captions and the show's branding. A third LLM writes platform-specific descriptions and hashtags. Then clips are uploaded directly to YouTube Shorts and Bluesky via their APIs, and pushed to Instagram Reels, Facebook, and Mastodon — six platforms, zero manual work.</p>
|
<p>No manual editing, no scheduling tools. After each episode, an LLM reads the full transcript and picks the best moments — funny exchanges, wild confessions, heated debates. Each clip is automatically extracted, transcribed with word-level timestamps, then polished by a second LLM pass that fixes punctuation, capitalization, and misheard words while preserving timing. The clips are rendered as vertical video with speaker-labeled captions and the show's branding. A third LLM writes platform-specific descriptions and hashtags. Then clips are uploaded directly to YouTube Shorts and Bluesky via their APIs, and pushed to Instagram Reels, Facebook, and Mastodon — six platforms, zero manual work.</p>
|
||||||
@@ -500,7 +514,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hiw-step">
|
<div class="hiw-step">
|
||||||
<div class="hiw-step-number">11</div>
|
<div class="hiw-step-number">12</div>
|
||||||
<div class="hiw-step-content">
|
<div class="hiw-step-content">
|
||||||
<h3>Global Distribution</h3>
|
<h3>Global Distribution</h3>
|
||||||
<p>Episodes are served through a CDN edge network for fast, reliable playback worldwide. The RSS feed is automatically updated and picked up by Spotify, Apple Podcasts, YouTube, and every other podcast app. The website pulls the live feed to show episodes with embedded playback, full transcripts, and chapter navigation — all served through Cloudflare with edge caching. From recording to available on every platform, the whole pipeline is automated end-to-end.</p>
|
<p>Episodes are served through a CDN edge network for fast, reliable playback worldwide. The RSS feed is automatically updated and picked up by Spotify, Apple Podcasts, YouTube, and every other podcast app. The website pulls the live feed to show episodes with embedded playback, full transcripts, and chapter navigation — all served through Cloudflare with edge caching. From recording to available on every platform, the whole pipeline is automated end-to-end.</p>
|
||||||
@@ -610,7 +624,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
||||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
||||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ Luke at the Roost is a late-night call-in radio show hosted by Luke MacNeil, bro
|
|||||||
- Host: Luke MacNeil
|
- Host: Luke MacNeil
|
||||||
- Genre: Comedy
|
- Genre: Comedy
|
||||||
- Format: Live call-in radio show
|
- Format: Live call-in radio show
|
||||||
- Contact: luke@macneilmediagroup.com
|
- Contact: luke@lukeattheroost.com
|
||||||
|
|||||||
@@ -71,14 +71,14 @@
|
|||||||
<p>Our content is rated explicit and is not directed at children under 13. We do not knowingly collect personal information from children.</p>
|
<p>Our content is rated explicit and is not directed at children under 13. We do not knowingly collect personal information from children.</p>
|
||||||
|
|
||||||
<h2>Your Rights</h2>
|
<h2>Your Rights</h2>
|
||||||
<p>If you have questions about your data or want to request removal of your voice from a published episode, contact us at <a href="mailto:luke@macneilmediagroup.com" style="color: var(--accent, #d4a44a);">luke@macneilmediagroup.com</a>.</p>
|
<p>If you have questions about your data or want to request removal of your voice from a published episode, contact us at <a href="mailto:luke@lukeattheroost.com" style="color: var(--accent, #d4a44a);">luke@lukeattheroost.com</a>.</p>
|
||||||
|
|
||||||
<h2>Changes</h2>
|
<h2>Changes</h2>
|
||||||
<p>We may update this policy from time to time. Changes will be posted on this page with an updated date.</p>
|
<p>We may update this policy from time to time. Changes will be posted on this page with an updated date.</p>
|
||||||
|
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
<p>MacNeil Media Group<br>
|
<p>MacNeil Media Group<br>
|
||||||
Email: <a href="mailto:luke@macneilmediagroup.com" style="color: var(--accent, #d4a44a);">luke@macneilmediagroup.com</a></p>
|
Email: <a href="mailto:luke@lukeattheroost.com" style="color: var(--accent, #d4a44a);">luke@lukeattheroost.com</a></p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
||||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
|
||||||
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
|
||||||
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
<p>© 2026 Luke at the Roost · <a href="/privacy">Privacy Policy</a> · <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user