Spaces:
Running
Running
| 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;">“{commentary}”</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: <laugh/> <sigh/> <gasp/> <pause ms="500"/><br> | |
| 4. TTS model synthesizes each segment per-voice<br> | |
| 5. pydub mixes TTS audio with laugh tracks, gasps & 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"]) |