feelin / app.py
ArkenB's picture
Update app.py
faa548e verified
Raw
History Blame Contribute Delete
24.2 kB
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"<div style='font-size:11px;font-family:DM Mono,monospace;color:{color};text-align:right;padding:2px 0;'>{n} / 280</div>"
def get_stats_html() -> str:
s = api_stats()
return f"""
<div class="feelin-stats">
<div><span>{s.get('total_posts', 0)}</span> confessions</div>
<div><span>{s.get('brags_caught', 0)}</span> brags caught</div>
<div class="live-badge"><div class="live-dot"></div>live</div>
</div>"""
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"""
<div style='color:#888;font-style:italic;padding:2.5rem;text-align:center;
border:0.5px dashed var(--border-color-primary);border-radius:12px;'>
{emoji} No posts yet in <strong>{category}</strong>.<br>
<span style='font-size:12px;'>Be the first chaos agent.</span>
</div>"""
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"""
<div style="display:flex;align-items:flex-start;gap:14px;padding:14px 18px;
border-bottom:0.5px solid var(--border-color-primary);">
<div style="font-size:18px;font-weight:800;color:{rank_color};min-width:30px;
font-family:'DM Mono',monospace;line-height:1.3;margin-top:3px;">#{i+1}</div>
<div style="flex:1;min-width:0;">
<div style="font-size:14px;line-height:1.65;margin-bottom:7px;word-break:break-word;">
{preview}
</div>
{f'<div style="font-size:12px;color:#888;font-style:italic;margin-bottom:8px;">&ldquo;{commentary}&rdquo;</div>' if commentary else ''}
<div style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;height:4px;background:#eee;border-radius:2px;overflow:hidden;">
<div style="width:{bar_w}%;height:100%;background:{color};border-radius:2px;"></div>
</div>
<span style="font-size:12px;font-weight:700;font-family:'DM Mono',monospace;
color:{color};min-width:30px;text-align:right;">{score:.1f}</span>
</div>
</div>
</div>"""
return f"""
<div style="border:0.5px solid var(--border-color-primary);border-radius:12px;overflow:hidden;">
<div style="padding:14px 18px;border-bottom:0.5px solid var(--border-color-primary);
display:flex;align-items:center;gap:10px;background:var(--background-fill-secondary);">
<span style="font-size:20px;">{emoji}</span>
<span style="font-weight:700;font-size:15px;">{persona.get('show', category.title())}</span>
<span style="font-size:11px;color:#888;font-family:'DM Mono',monospace;margin-left:auto;">
{len(posts)} post{'s' if len(posts) != 1 else ''}
</span>
</div>
{rows}
</div>"""
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"""
<div style="padding:32px;text-align:center;
border:0.5px dashed var(--border-color-primary);border-radius:12px;">
<div style="font-size:48px;margin-bottom:14px;">{emoji}</div>
<div style="font-weight:700;font-size:17px;margin-bottom:8px;">{persona['show']}</div>
<div style="color:#888;font-size:13px;line-height:1.6;">
No episode available yet for <strong>{cat}</strong>.
</div>
<div style="margin-top:16px;font-size:12px;color:#aaa;font-family:'DM Mono',monospace;">
Episodes are pre-generated and updated periodically.
</div>
</div>"""
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"""
<div style="border:0.5px solid {color};border-radius:12px;overflow:hidden;">
<div style="height:3px;background:{color};"></div>
<div style="padding:18px 20px;">
<div style="display:flex;align-items:flex-start;gap:12px;margin-bottom:4px;">
<span style="font-size:28px;line-height:1;">{emoji}</span>
<div style="flex:1;">
<div style="font-weight:800;font-size:16px;margin-bottom:3px;">{persona['show']}</div>
<div style="font-size:12px;color:#888;font-family:'DM Mono',monospace;">
Host: {persona['name']} β€” {persona['desc']}
</div>
</div>
<span style="font-size:11px;font-family:'DM Mono',monospace;
padding:4px 12px;border-radius:100px;
background:var(--background-fill-secondary);color:#888;white-space:nowrap;">
{size_mb:.1f} MB Β· {mtime}
</span>
</div>
</div>
</div>"""
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("""
<div class="feelin-header">
<div class="feelin-logo">feelin<span class="accent">'</span></div>
<div class="feelin-tagline">the anti-linkedin Β· no bragging Β· no humble brags Β· no buzzwords</div>
<div class="feelin-stats">
<div id="stat-posts"><span>β€”</span> confessions</div>
<div id="stat-brags"><span>β€”</span> brags caught</div>
<div class="live-badge"><div class="live-dot"></div>live</div>
</div>
</div>
""")
with gr.Tabs():
# ══ TAB 1 β€” CONFESS ══════════════════════════════════════════════
with gr.Tab("01 β€” confess"):
with gr.Column():
gr.HTML("""
<div style="padding:36px 0 0;">
<div class="section-label">your safe space</div>
<div class="section-title">Say what LinkedIn won't let you.</div>
<div class="section-sub">
No bragging. No "excited to announce". No synergy.<br>
Just the raw, unfiltered chaos of professional life.
</div>
</div>
""")
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(
"<div style='font-size:11px;font-family:DM Mono,monospace;color:#888;'>0 / 280</div>"
)
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("""
<div style="margin-top:36px;padding:20px 24px;
border:0.5px dashed var(--border-color-primary);border-radius:12px;">
<div style="font-size:12px;font-family:'DM Mono',monospace;
color:var(--body-text-color-subdued);line-height:2;">
<strong style="color:var(--body-text-color);">🚩 what gets you flagged</strong><br>
"Excited to announce…" Β· "Proud to share…" Β· "Humbled and honored…"<br>
Revenue flexing Β· Award announcements Β· Name-dropping Β· "I've been selected…"
<br><br>
<strong style="color:var(--body-text-color);">πŸ† what gets you on the leaderboard</strong><br>
Real stories Β· Actual workplace pain Β· Absurd situations Β· Chaotic energy Β· Raw truth
</div>
</div>
""")
# ══ TAB 2 β€” LEADERBOARD ══════════════════════════════════════════
with gr.Tab("02 β€” leaderboard"):
with gr.Column():
gr.HTML("""
<div style="padding:36px 0 0;">
<div class="section-label">hall of fame</div>
<div class="section-title">The most painfully relatable posts.</div>
<div class="section-sub">Top 10 per vibe. AI-scored. Updated in real-time.</div>
</div>
""")
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="<div style='padding:2rem;color:#888;text-align:center;'>Select a category above.</div>"
)
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("""
<div style="padding:36px 0 0;">
<div class="section-label">the broadcast</div>
<div class="section-title">Your chaos, narrated by AI.</div>
<div class="section-sub">
Five shows. Five hosts. One soul-crushing episode per vibe.<br>
Synthesized with Chatterbox TTS. Mixed with real SFX.
</div>
</div>
""")
# Show grid β€” purely informational, no generate button
gr.HTML(f"""
<div class="pod-grid">
{''.join([f"""
<div style="background:var(--background-fill-primary);
border:0.5px solid var(--border-color-primary);
border-radius:12px;padding:16px;
border-top:3px solid {CAT_COLORS[cat]};">
<div style="font-size:28px;margin-bottom:10px;">{CAT_EMOJIS[cat]}</div>
<div style="font-weight:700;font-size:13px;margin-bottom:4px;line-height:1.3;">
{PERSONAS[cat]['show']}
</div>
<div style="font-size:11px;color:#888;font-family:'DM Mono',monospace;line-height:1.5;">
{PERSONAS[cat]['name']}<br>{PERSONAS[cat]['desc']}
</div>
</div>""" for cat in CATEGORIES])}
</div>
""")
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("""
<div style="margin-top:36px;padding:20px 24px;
border:0.5px solid var(--border-color-primary);border-radius:12px;">
<div style="font-weight:700;font-size:13px;margin-bottom:12px;">how episodes are made</div>
<div style="font-size:12px;font-family:'DM Mono',monospace;
color:var(--body-text-color-subdued);line-height:2.2;">
1. Top 10 posts per category (score β‰₯ 5.0) are selected<br>
2. LLM writes a full script in the host's voice<br>
3. Emotion tags injected: &lt;laugh/&gt; &lt;sigh/&gt; &lt;gasp/&gt; &lt;pause ms="500"/&gt;<br>
4. TTS model synthesizes each segment per-voice<br>
5. pydub mixes TTS audio with laugh tracks, gasps &amp; SFX<br>
6. Final 128kbps MP3 is ready to stream
</div>
</div>
""")
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"])