from __future__ import annotations
import sys
import os
import time
import threading
from pathlib import Path
import gradio as gr
sys.path.insert(0, str(Path(__file__).parent))
# ── Paths ────────────────────────────────────────────────────────────────────
DATA_DIR = Path("/data")
POD_DIR = DATA_DIR / "podcasts"
POD_DIR.mkdir(parents=True, exist_ok=True)
API_BASE = os.getenv("FEELIN_API", "http://localhost:8000")
CATEGORIES = ["hilarious", "tragic", "unhinged", "awkward", "chaotic"]
CAT_EMOJIS = {
"hilarious": "😂", "tragic": "😢",
"unhinged": "😤", "awkward": "😬", "chaotic": "🤯",
}
CAT_COLORS = {
"hilarious": "#E8C547", "tragic": "#7B9EC7",
"unhinged": "#E07B5A", "awkward": "#A67DC4", "chaotic": "#5BBF8A",
}
PERSONAS = {
"hilarious": {"name": "Dave", "show": "The Weekly Wheeze", "desc": "42, raspy, laughs mid-sentence"},
"tragic": {"name": "Elena", "show": "Corporate Tears", "desc": "31, soft, melancholic sighs"},
"unhinged": {"name": "Marcus", "show": "Officially Unhinged", "desc": "55, baritone, barely-contained rage"},
"awkward": {"name": "Priya", "show": "Please Stop Talking", "desc": "27, fast talker, nervous giggle"},
"chaotic": {"name": "Rex", "show": "Total System Failure", "desc": "???, manic, breaks 4th wall"},
}
# ── API helpers ───────────────────────────────────────────────────────────────
import requests
def api_post(text: str) -> dict:
try:
r = requests.post(f"{API_BASE}/post", json={"text": text}, timeout=90)
r.raise_for_status()
return r.json()
except Exception as e:
return {"response_type": "error", "response_message": f"⚠️ API error: {e}"}
def api_leaderboard(category: str) -> list:
try:
r = requests.get(f"{API_BASE}/leaderboard/{category}", timeout=15)
r.raise_for_status()
return r.json().get("posts", [])
except Exception:
return []
def api_stats() -> dict:
try:
r = requests.get(f"{API_BASE}/stats", timeout=5)
return r.json()
except Exception:
return {"total_posts": 0, "brags_caught": 0}
# ── Podcast: resolve from /data/podcasts/ directly ───────────────────────────
def get_podcast_path(category: str) -> Path | None:
"""
Find the latest file matching podcast_{category}*.mp3 or *.wav .
"""
candidates = sorted(
list(POD_DIR.glob(f"podcast_{category}*.mp3")) +
list(POD_DIR.glob(f"podcast_{category}*.wav")),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
return candidates[0] if candidates else None
# ── UI helpers ────────────────────────────────────────────────────────────────
def submit_confession(text: str):
if not text or len(text.strip()) < 10:
return "✍️ Express your feelings or any situation you're keeping inside."
result = api_post(text.strip())
return result.get("response_message", "Something went sideways.")
def update_char_count(text: str) -> str:
n = len(text)
color = "#E07B5A" if n > 260 else "#888"
return f"
{n} / 280
"
def get_stats_html() -> str:
s = api_stats()
return f"""
{s.get('total_posts', 0)} confessions
{s.get('brags_caught', 0)} brags caught
"""
def render_leaderboard_html(category: str) -> str:
posts = api_leaderboard(category)
color = CAT_COLORS[category]
emoji = CAT_EMOJIS[category]
persona = PERSONAS.get(category, {})
if not posts:
return f"""
{emoji} No posts yet in {category}.
Be the first chaos agent.
"""
rows = ""
for i, p in enumerate(posts):
rank_color = "#E8C547" if i == 0 else ("#B4B2A9" if i == 1 else ("#CD7F32" if i == 2 else "#666"))
score = p.get("score", 0)
bar_w = int(score * 10)
commentary = p.get("commentary", "")
preview = p["text"][:150] + ("…" if len(p["text"]) > 150 else "")
rows += f"""
#{i+1}
{preview}
{f'
“{commentary}”
' if commentary else ''}
"""
return f"""
{emoji}
{persona.get('show', category.title())}
{len(posts)} post{'s' if len(posts) != 1 else ''}
{rows}
"""
def load_podcast(cat_label: str) -> tuple[str, str | None]:
"""
Resolve podcast audio directly from /data/podcasts/ — no localhost URLs.
Returns (info_html, absolute_file_path_or_None).
Gradio gr.Audio accepts a local file path string directly.
"""
cat = cat_label.split(" ")[-1].strip()
persona = PERSONAS[cat]
color = CAT_COLORS[cat]
emoji = CAT_EMOJIS[cat]
pod_path = get_podcast_path(cat)
if pod_path is None:
html = f"""
{emoji}
{persona['show']}
No episode available yet for {cat}.
Episodes are pre-generated and updated periodically.
"""
return html, None
# Episode found
mtime = time.strftime("%b %d, %Y", time.localtime(pod_path.stat().st_mtime))
size_mb = pod_path.stat().st_size / 1_000_000
html = f"""
{emoji}
{persona['show']}
Host: {persona['name']} — {persona['desc']}
{size_mb:.1f} MB · {mtime}
"""
return html, str(pod_path) # ← local path, Gradio handles it natively
# ── CSS ───────────────────────────────────────────────────────────────────────
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Syne:wght@400;500;700;800&family=DM+Mono:wght@400;500&display=swap');
*, *::before, *::after { box-sizing: border-box; }
.gradio-container {
max-width: 920px !important;
margin: 0 auto !important;
font-family: 'Syne', sans-serif !important;
}
/* Header */
.feelin-header {
text-align: center;
padding: 52px 24px 36px;
border-bottom: 0.5px solid var(--border-color-primary);
}
.feelin-logo {
font-size: 60px;
font-weight: 800;
letter-spacing: -2.5px;
line-height: 1;
margin-bottom: 10px;
font-family: 'Syne', sans-serif;
}
.feelin-logo .accent { color: #E8C547; }
.feelin-tagline {
font-size: 13px;
color: var(--body-text-color-subdued);
font-family: 'DM Mono', monospace;
letter-spacing: 0.06em;
margin-bottom: 24px;
text-transform: uppercase;
}
.feelin-stats {
display: inline-flex;
gap: 28px;
font-size: 12px;
font-family: 'DM Mono', monospace;
color: var(--body-text-color-subdued);
padding: 9px 24px;
border: 0.5px solid var(--border-color-primary);
border-radius: 100px;
}
.feelin-stats span { color: var(--body-text-color); font-weight: 600; }
/* Live badge */
.live-badge {
display: inline-flex;
align-items: center;
gap: 6px;
color: #5BBF8A;
}
.live-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: #5BBF8A;
animation: livepulse 1.4s ease-in-out infinite;
}
@keyframes livepulse {
0%,100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Section headers */
.section-label {
font-size: 10px;
font-family: 'DM Mono', monospace;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--body-text-color-subdued);
margin-bottom: 6px;
}
.section-title {
font-size: 30px;
font-weight: 800;
letter-spacing: -0.8px;
margin-bottom: 6px;
line-height: 1.1;
}
.section-sub {
font-size: 14px;
color: var(--body-text-color-subdued);
margin-bottom: 28px;
line-height: 1.6;
}
/* Tabs */
.tabs > .tab-nav { border-bottom: 0.5px solid var(--border-color-primary) !important; gap: 0 !important; }
.tabs > .tab-nav button {
font-family: 'Syne', sans-serif !important;
font-weight: 600 !important;
font-size: 13px !important;
padding: 14px 26px !important;
border-radius: 0 !important;
border: none !important;
border-bottom: 2px solid transparent !important;
color: var(--body-text-color-subdued) !important;
transition: all 0.15s !important;
background: transparent !important;
letter-spacing: 0.02em !important;
}
.tabs > .tab-nav button.selected {
color: var(--body-text-color) !important;
border-bottom-color: #E8C547 !important;
background: transparent !important;
}
/* Textarea */
textarea {
font-family: 'Syne', sans-serif !important;
font-size: 15px !important;
line-height: 1.75 !important;
border-radius: 12px !important;
border: 0.5px solid var(--border-color-primary) !important;
padding: 16px 18px !important;
resize: none !important;
transition: border-color 0.15s !important;
}
textarea:focus {
border-color: #E8C547 !important;
outline: none !important;
box-shadow: 0 0 0 2px #E8C54722 !important;
}
/* Buttons */
.gr-button-primary, button[variant="primary"] {
background: var(--body-text-color) !important;
color: var(--body-background-fill) !important;
border: none !important;
border-radius: 100px !important;
font-family: 'Syne', sans-serif !important;
font-weight: 700 !important;
font-size: 14px !important;
padding: 12px 28px !important;
letter-spacing: 0.02em !important;
transition: opacity 0.15s !important;
}
.gr-button-primary:hover { opacity: 0.75 !important; }
/* Verdict output */
#verdict-output .prose { font-size: 15px !important; line-height: 1.75 !important; }
/* Pod grid */
.pod-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
gap: 12px;
margin-bottom: 26px;
}
/* Scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-color-primary); border-radius: 2px; }
@media (max-width: 600px) {
.feelin-logo { font-size: 42px; }
.section-title { font-size: 22px; }
.feelin-stats { gap: 16px; padding: 8px 16px; }
}
"""
# ── Build UI ──────────────────────────────────────────────────────────────────
def build_ui():
with gr.Blocks(
css=CSS,
title="feelin' — Professional gossip podcast platform with AI mood moderation",
theme=gr.themes.Base(
font=gr.themes.GoogleFont("Syne"),
font_mono=gr.themes.GoogleFont("DM Mono"),
),
) as demo:
# ── Header ────────────────────────────────────────────────────────────
gr.HTML("""
""")
with gr.Tabs():
# ══ TAB 1 — CONFESS ══════════════════════════════════════════════
with gr.Tab("01 — confess"):
with gr.Column():
gr.HTML("""
your safe space
Say what LinkedIn won't let you.
No bragging. No "excited to announce". No synergy.
Just the raw, unfiltered chaos of professional life.
""")
post_input = gr.Textbox(
placeholder='My manager scheduled a "quick alignment sync" that lasted 3 hours. I now believe time is a lie...',
lines=5, max_lines=8,
show_label=False,
elem_id="confess-input",
)
with gr.Row():
char_display = gr.HTML(
"0 / 280
"
)
submit_btn = gr.Button("drop the tea ↗", variant="primary")
verdict_out = gr.Markdown(value="", elem_id="verdict-output")
post_input.change(fn=update_char_count, inputs=post_input, outputs=char_display)
submit_btn.click(fn=submit_confession, inputs=post_input, outputs=verdict_out)
gr.HTML("""
🚩 what gets you flagged
"Excited to announce…" · "Proud to share…" · "Humbled and honored…"
Revenue flexing · Award announcements · Name-dropping · "I've been selected…"
🏆 what gets you on the leaderboard
Real stories · Actual workplace pain · Absurd situations · Chaotic energy · Raw truth
""")
# ══ TAB 2 — LEADERBOARD ══════════════════════════════════════════
with gr.Tab("02 — leaderboard"):
with gr.Column():
gr.HTML("""
hall of fame
The most painfully relatable posts.
Top 10 per vibe. AI-scored. Updated in real-time.
""")
cat_select = gr.Radio(
choices=[f"{CAT_EMOJIS[c]} {c}" for c in CATEGORIES],
value=f"{CAT_EMOJIS['hilarious']} hilarious",
label="",
)
board_html = gr.HTML(
value="Select a category above.
"
)
refresh_btn = gr.Button("↻ refresh", size="sm")
def load_board(cat_label: str) -> str:
return render_leaderboard_html(cat_label.split(" ")[-1].strip())
cat_select.change(fn=load_board, inputs=cat_select, outputs=board_html)
refresh_btn.click(fn=load_board, inputs=cat_select, outputs=board_html)
demo.load(fn=lambda: render_leaderboard_html("hilarious"), outputs=board_html)
# ══ TAB 3 — PODCAST ══════════════════════════════════════════════
with gr.Tab("03 — podcast"):
with gr.Column():
gr.HTML("""
the broadcast
Your chaos, narrated by AI.
Five shows. Five hosts. One soul-crushing episode per vibe.
Synthesized with Chatterbox TTS. Mixed with real SFX.
""")
# Show grid — purely informational, no generate button
gr.HTML(f"""
{''.join([f"""
{CAT_EMOJIS[cat]}
{PERSONAS[cat]['show']}
{PERSONAS[cat]['name']}
{PERSONAS[cat]['desc']}
""" for cat in CATEGORIES])}
""")
pod_cat_select = gr.Radio(
choices=[f"{CAT_EMOJIS[c]} {c}" for c in CATEGORIES],
value=f"{CAT_EMOJIS['hilarious']} hilarious",
label="Select episode:",
)
pod_info_html = gr.HTML()
pod_audio = gr.Audio(label="", autoplay=False, type="filepath")
refresh_pod = gr.Button("↻ check for latest episode", size="sm")
# Wire up — selecting or refreshing resolves from /data/podcasts/
pod_cat_select.change(fn=load_podcast, inputs=pod_cat_select, outputs=[pod_info_html, pod_audio])
refresh_pod.click(fn=load_podcast, inputs=pod_cat_select, outputs=[pod_info_html, pod_audio])
demo.load(
fn=lambda: load_podcast(f"{CAT_EMOJIS['hilarious']} hilarious"),
outputs=[pod_info_html, pod_audio],
)
gr.HTML("""
how episodes are made
1. Top 10 posts per category (score ≥ 5.0) are selected
2. LLM writes a full script in the host's voice
3. Emotion tags injected: <laugh/> <sigh/> <gasp/> <pause ms="500"/>
4. TTS model synthesizes each segment per-voice
5. pydub mixes TTS audio with laugh tracks, gasps & SFX
6. Final 128kbps MP3 is ready to stream
""")
return demo
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
import uvicorn
def run_api():
sys.path.insert(0, str(Path(__file__).parent))
from api.main import app as fastapi_app
uvicorn.run(fastapi_app, host="0.0.0.0", port=8000, log_level="warning")
api_thread = threading.Thread(target=run_api, daemon=True)
api_thread.start()
time.sleep(2)
ui = build_ui()
ui.launch(server_name="0.0.0.0", server_port=7860, show_error=True,
allowed_paths=["/data/podcasts"])