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
live
""" 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 ''}
{score:.1f}
""" 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("""
the anti-linkedin · no bragging · no humble brags · no buzzwords
confessions
brags caught
live
""") 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"])