| |
| try: |
| import gradio_client.utils as _gcu |
| _orig = _gcu.get_type |
| def _safe(s): |
| if not isinstance(s, dict): return "any" |
| return _orig(s) |
| _gcu.get_type = _safe |
| except Exception: |
| pass |
|
|
| import base64, httpx, uuid, os |
| from pathlib import Path |
| from transformers import pipeline |
| import gradio as gr |
| from dotenv import load_dotenv |
|
|
| |
| load_dotenv() |
|
|
| Path("audio_output").mkdir(exist_ok=True) |
|
|
| print("Loading emotion model...") |
| emotion_classifier = pipeline( |
| "text-classification", |
| model="j-hartmann/emotion-english-distilroberta-base", |
| top_k=1 |
| ) |
| print("Model loaded.") |
|
|
| |
| INWORLD_API_KEY = os.getenv("INWORLD_API_KEY") |
| INWORLD_VOICE_ID = os.getenv("INWORLD_VOICE_ID", "Clive") |
|
|
| if not INWORLD_API_KEY: |
| raise ValueError( |
| "[ERROR] INWORLD_API_KEY not found in environment variables. " |
| "Please create a .env file with INWORLD_API_KEY=your_key_here" |
| ) |
|
|
| EMOTION_CONFIG = { |
| "joy": {"label": "Joyful", "base_rate": 1.25, "base_temp": 1.3, "rate_boost": 0.25, "temp_boost": 0.4}, |
| "surprise": {"label": "Surprised", "base_rate": 1.2, "base_temp": 1.4, "rate_boost": 0.2, "temp_boost": 0.35}, |
| "anger": {"label": "Angry", "base_rate": 0.85, "base_temp": 0.5, "rate_boost": -0.1, "temp_boost": -0.15}, |
| "disgust": {"label": "Disgusted", "base_rate": 0.8, "base_temp": 0.55, "rate_boost": -0.1, "temp_boost": -0.1}, |
| "fear": {"label": "Fearful", "base_rate": 1.1, "base_temp": 1.2, "rate_boost": 0.15, "temp_boost": 0.25}, |
| "sadness": {"label": "Sad", "base_rate": 0.7, "base_temp": 0.45, "rate_boost": -0.1, "temp_boost": -0.1}, |
| "neutral": {"label": "Neutral", "base_rate": 1.0, "base_temp": 1.0, "rate_boost": 0.0, "temp_boost": 0.0}, |
| } |
|
|
| session_emotions = {} |
| session_history = [] |
|
|
| def synthesize(text): |
| global session_emotions, session_history |
| if not text or not text.strip(): |
| return None, "[WARNING] Please enter some text.", "", "", "" |
|
|
| text = text.strip() |
| results = emotion_classifier(text) |
| top = results[0][0] |
| emotion = top["label"].lower() |
| score = float(top["score"]) |
| intensity = round(score * 100, 1) |
| cfg = EMOTION_CONFIG.get(emotion, EMOTION_CONFIG["neutral"]) |
| rate = round(max(0.5, min(2.0, cfg["base_rate"] + cfg["rate_boost"] * score)), 3) |
| temp = round(max(0.1, min(2.0, cfg["base_temp"] + cfg["temp_boost"] * score)), 3) |
|
|
| try: |
| resp = httpx.post( |
| "https://api.inworld.ai/tts/v1/voice", |
| headers={"Authorization": f"Basic {INWORLD_API_KEY}", "Content-Type": "application/json"}, |
| json={"text": text, "voiceId": INWORLD_VOICE_ID, "modelId": "inworld-tts-1.5-max", |
| "speakingRate": rate, "temperature": temp}, |
| timeout=30, |
| ) |
| if resp.status_code != 200: |
| return None, f"[ERROR] Inworld error {resp.status_code}: {resp.text[:200]}", "", "", "" |
| audio_b64 = resp.json().get("audioContent", "") |
| if not audio_b64: |
| return None, "[ERROR] No audio returned.", "", "", "" |
| filepath = f"audio_output/{uuid.uuid4().hex}.mp3" |
| Path(filepath).write_bytes(base64.b64decode(audio_b64)) |
| except Exception as e: |
| return None, f"[ERROR] {e}", "", "", "" |
|
|
| session_emotions[emotion] = session_emotions.get(emotion, 0) + 1 |
| session_history.insert(0, {"text": text, "label": cfg["label"], |
| "intensity": intensity, "rate": rate}) |
| if len(session_history) > 20: |
| session_history.pop() |
|
|
| total = len(session_history) |
| dom = max(session_emotions, key=session_emotions.get) |
| dom_cfg = EMOTION_CONFIG[dom] |
| avg_c = round(sum(h["intensity"] for h in session_history) / total, 1) |
|
|
| emotion_text = ( |
| f"[{cfg['label'].upper()}]\n\n" |
| f" Confidence {intensity}%\n" |
| f" Speaking Rate {rate}x\n" |
| f" Temperature {temp}" |
| ) |
| stats_text = ( |
| f" Synthesized {total} clips\n" |
| f" Top Emotion {dom_cfg['label']}\n" |
| f" Avg Conf. {avg_c}%" |
| ) |
| max_c = max(session_emotions.values()) |
| dist_lines = [] |
| for e, cnt in sorted(session_emotions.items(), key=lambda x: -x[1]): |
| c = EMOTION_CONFIG[e] |
| filled = round((cnt / max_c) * 20) |
| bar = "[" + "#" * filled + "-" * (20 - filled) + "]" |
| dist_lines.append(f" {c['label']:<12} {bar} {cnt}") |
| dist_text = "\n".join(dist_lines) |
|
|
| hist_lines = [ |
| f" {h['label']:<12} {h['intensity']:>5}% {h['text'][:50]}" |
| for h in session_history[:10] |
| ] |
| hist_text = "\n".join(hist_lines) |
|
|
| return filepath, emotion_text, stats_text, dist_text, hist_text |
|
|
|
|
| SAMPLES = [ |
| "I just got the promotion I've been working towards for years!", |
| "This is completely unacceptable behaviour from your team.", |
| "I really miss those days. It all feels so far away now.", |
| "Wait β that actually cannot be real, right?!", |
| "The meeting has been rescheduled to 3pm tomorrow.", |
| "I heard something strange coming from downstairs at 2am.", |
| ] |
|
|
| CSS = """ |
| /* ββ FULL DARK THEME ββ */ |
| *, *::before, *::after { box-sizing: border-box; } |
| |
| body { |
| background: #0d1117 !important; |
| color: #e6edf3 !important; |
| } |
| |
| .gradio-container { |
| background: #0d1117 !important; |
| max-width: 1000px !important; |
| margin: 0 auto !important; |
| font-family: 'Inter', system-ui, sans-serif !important; |
| color: #e6edf3 !important; |
| } |
| |
| /* All blocks */ |
| .block, .form, .box { |
| background: #161b22 !important; |
| border: 1px solid #30363d !important; |
| border-radius: 10px !important; |
| color: #e6edf3 !important; |
| } |
| |
| /* Labels */ |
| label > span, .label-wrap span { |
| color: #8b949e !important; |
| font-size: 11px !important; |
| font-weight: 600 !important; |
| letter-spacing: 0.08em !important; |
| text-transform: uppercase !important; |
| } |
| |
| /* Textareas and inputs */ |
| textarea, input[type=text] { |
| background: #0d1117 !important; |
| border: 1px solid #30363d !important; |
| border-radius: 8px !important; |
| color: #e6edf3 !important; |
| font-size: 14px !important; |
| caret-color: #58a6ff !important; |
| } |
| textarea:focus, input:focus { |
| border-color: #58a6ff !important; |
| box-shadow: 0 0 0 3px rgba(88,166,255,0.1) !important; |
| outline: none !important; |
| } |
| textarea::placeholder, input::placeholder { |
| color: #484f58 !important; |
| } |
| |
| /* Output textboxes */ |
| .output-box textarea { |
| font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace !important; |
| font-size: 13px !important; |
| line-height: 1.75 !important; |
| color: #cdd9e5 !important; |
| background: #0d1117 !important; |
| } |
| |
| /* Button */ |
| button.primary { |
| background: #238636 !important; |
| border: 1px solid #2ea043 !important; |
| border-radius: 8px !important; |
| color: #ffffff !important; |
| font-weight: 600 !important; |
| font-size: 14px !important; |
| padding: 10px 24px !important; |
| transition: background 0.15s !important; |
| } |
| button.primary:hover { |
| background: #2ea043 !important; |
| } |
| button.secondary { |
| background: #21262d !important; |
| border: 1px solid #30363d !important; |
| color: #cdd9e5 !important; |
| border-radius: 8px !important; |
| } |
| |
| /* Markdown text */ |
| .markdown, .prose, .md { |
| color: #cdd9e5 !important; |
| } |
| .markdown h1, .prose h1 { color: #e6edf3 !important; font-size: 1.8rem !important; font-weight: 700 !important; } |
| .markdown h2, .prose h2 { color: #e6edf3 !important; font-size: 1.3rem !important; font-weight: 600 !important; margin-top: 20px !important; } |
| .markdown h3, .prose h3 { color: #cdd9e5 !important; font-size: 1.05rem !important; font-weight: 600 !important; margin-top: 16px !important; } |
| .markdown p, .prose p { color: #8b949e !important; font-size: 14px !important; line-height: 1.65 !important; } |
| .markdown li, .prose li { color: #8b949e !important; font-size: 14px !important; line-height: 1.7 !important; } |
| .markdown strong, .prose strong { color: #cdd9e5 !important; } |
| .markdown a, .prose a { color: #58a6ff !important; text-decoration: none !important; } |
| .markdown a:hover, .prose a:hover { text-decoration: underline !important; } |
| .markdown hr, .prose hr { border-color: #30363d !important; margin: 18px 0 !important; } |
| .markdown blockquote, .prose blockquote { |
| border-left: 3px solid #388bfd !important; |
| margin: 12px 0 !important; |
| padding: 8px 16px !important; |
| background: #1c2128 !important; |
| border-radius: 0 6px 6px 0 !important; |
| color: #8b949e !important; |
| } |
| |
| /* Inline code */ |
| .markdown code, .prose code, code { |
| background: #1c2128 !important; |
| color: #79c0ff !important; |
| padding: 2px 6px !important; |
| border-radius: 4px !important; |
| font-size: 12px !important; |
| font-family: 'JetBrains Mono', monospace !important; |
| border: 1px solid #30363d !important; |
| } |
| |
| /* Tables */ |
| table { width: 100% !important; border-collapse: collapse !important; background: transparent !important; } |
| thead tr { background: #1c2128 !important; } |
| th { |
| background: #1c2128 !important; |
| color: #8b949e !important; |
| font-size: 12px !important; |
| font-weight: 600 !important; |
| padding: 10px 14px !important; |
| text-align: left !important; |
| border-bottom: 1px solid #30363d !important; |
| } |
| td { |
| background: transparent !important; |
| color: #cdd9e5 !important; |
| font-size: 13px !important; |
| padding: 9px 14px !important; |
| border-bottom: 1px solid #21262d !important; |
| } |
| tr:last-child td { border-bottom: none !important; } |
| tr:hover td { background: #1c2128 !important; } |
| |
| /* Accordion / details */ |
| details { |
| background: #161b22 !important; |
| border: 1px solid #30363d !important; |
| border-radius: 10px !important; |
| overflow: hidden !important; |
| } |
| details > summary { |
| background: #161b22 !important; |
| color: #cdd9e5 !important; |
| font-weight: 600 !important; |
| font-size: 14px !important; |
| padding: 14px 18px !important; |
| cursor: pointer !important; |
| list-style: none !important; |
| } |
| details > summary:hover { background: #1c2128 !important; } |
| details[open] > summary { border-bottom: 1px solid #30363d !important; } |
| details .markdown p, details .prose p { color: #8b949e !important; } |
| details .markdown li, details .prose li { color: #8b949e !important; } |
| |
| /* Examples */ |
| .examples { |
| background: #161b22 !important; |
| border: 1px solid #30363d !important; |
| border-radius: 8px !important; |
| } |
| .examples table td { |
| background: transparent !important; |
| color: #8b949e !important; |
| font-size: 12px !important; |
| border-bottom: 1px solid #21262d !important; |
| } |
| .examples table th { |
| background: #1c2128 !important; |
| color: #8b949e !important; |
| } |
| .examples table tr:hover td { background: #1c2128 !important; } |
| |
| /* Audio */ |
| audio { border-radius: 8px !important; background: #161b22 !important; } |
| audio::-webkit-media-controls-panel { background: #161b22 !important; } |
| |
| /* Scrollbar */ |
| ::-webkit-scrollbar { width: 6px; height: 6px; } |
| ::-webkit-scrollbar-track { background: #0d1117; } |
| ::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; } |
| ::-webkit-scrollbar-thumb:hover { background: #484f58; } |
| """ |
|
|
| with gr.Blocks(title="Empathy Engine", css=CSS, theme=gr.themes.Base()) as demo: |
|
|
| gr.Markdown(""" |
| # Empathy Engine |
| ### Giving AI a Human Voice |
| |
| Prepared for the **Darwix AI Evaluation** assignment |
| |
| --- |
| |
| **K V Jaya Harsha** | [Personal Website](https://kvjharsha.vercel.app) | [GitHub](https://github.com/kvj-harsha) | [LinkedIn](https://linkedin.com/in/kvjharsha) |
| |
| --- |
| """) |
|
|
| with gr.Accordion("Two Approaches to the Problem", open=True): |
| gr.Markdown(""" |
| ## Approach 1 β Implemented (This Demo) |
| |
| Uses the **Inworld TTS API** with dynamic vocal parameter modulation driven by a HuggingFace emotion classifier: |
| |
| - **Emotion Detection** β `j-hartmann/emotion-english-distilroberta-base` β 7 classes: joy, surprise, anger, disgust, fear, sadness, neutral |
| - **Intensity Scaling** β Model confidence (0β1) linearly scales vocal params β "I'm okay" sounds different from "THIS IS AMAZING" |
| - **Vocal Modulation** β `speakingRate` (speed) + `temperature` (expressiveness) β both dynamically computed per emotion |
| - **Emotion Mapping** β Each emotion has a defined base rate + temperature, scaled by intensity score |
| - **TTS Engine** β Inworld TTS `inworld-tts-1.5-max`, voice: Clive, output: `.mp3` |
| |
| --- |
| |
| ## Approach 2 β Theoretical (Research-Grade) |
| |
| Architecturally superior β validated in research, not implemented here due to time and compute constraints: |
| |
| - **Model** β **F5-TTS** with conditional flow matching, learns the full distribution of human speech prosody |
| - **Emotion Transfer** β Zero-shot emotion cloning from reference audio β the model hears the target emotion and replicates it |
| - **Why it's better** β Flow matching captures micro-variations in pitch, rhythm, and timbre that API parameters cannot replicate |
| - **Papers** β F5-TTS: A Fairytale for Flow-matching-based TTS (Chen et al., 2024) Β· Voicebox (Meta AI, 2023) |
| |
| > This approach remains theoretical in this submission but is well-validated in published research, and would be the production-grade choice given sufficient time and GPU resources. |
| """) |
|
|
| gr.Markdown("---") |
|
|
| with gr.Row(): |
| with gr.Column(scale=3): |
| txt_input = gr.Textbox( |
| label="Input Text", |
| placeholder="Type something expressive... e.g. 'I cannot believe we actually pulled this off!'", |
| lines=4, |
| ) |
| gr.Examples(examples=SAMPLES, inputs=txt_input, label="Quick Samples") |
| synth_btn = gr.Button("Synthesize Voice", variant="primary") |
|
|
| with gr.Column(scale=1): |
| gr.Markdown(""" |
| **Emotion to Voice Mapping** |
| |
| | Emotion | Rate | Temp | |
| |---|---|---| |
| | Joyful | 1.25β1.50 | 1.3β1.7 | |
| | Surprised | 1.20β1.40 | 1.4β1.75 | |
| | Fearful | 1.10β1.25 | 1.2β1.45 | |
| | Neutral | 1.00 | 1.00 | |
| | Angry | 0.75β0.85 | 0.35β0.50 | |
| | Disgusted | 0.70β0.80 | 0.45β0.55 | |
| | Sad | 0.60β0.70 | 0.35β0.45 | |
| |
| *Rate = speed Β· Temp = energy* |
| """) |
|
|
| gr.Markdown("### Results") |
|
|
| with gr.Row(): |
| with gr.Column(): |
| audio_out = gr.Audio(label="Synthesized Audio", type="filepath", elem_classes="output-box") |
| emotion_out = gr.Textbox(label="Detected Emotion & Parameters", lines=5, |
| interactive=False, elem_classes="output-box") |
| with gr.Column(): |
| stats_out = gr.Textbox(label="Session Stats", lines=4, |
| interactive=False, elem_classes="output-box") |
| dist_out = gr.Textbox(label="Emotion Distribution", lines=8, |
| interactive=False, elem_classes="output-box") |
|
|
| hist_out = gr.Textbox(label="Session History (last 10)", lines=10, |
| interactive=False, elem_classes="output-box") |
|
|
| with gr.Accordion("Requirements Coverage", open=False): |
| gr.Markdown(""" |
| | # | Requirement | Status | Notes | |
| |---|---|---|---| |
| | 1 | Text Input | COMPLETE | Textbox + REST endpoint | |
| | 2 | Emotion Detection β 3+ classes | COMPLETE | 7 classes via DistilRoBERTa | |
| | 3 | Vocal Parameter Modulation β 2+ | COMPLETE | speakingRate + temperature | |
| | 4 | Emotion to Voice Mapping | COMPLETE | Config table with clear logic per emotion | |
| | 5 | Audio Output (.mp3) | COMPLETE | Inworld TTS, Base64 decoded to file | |
| | 6 | Granular Emotions | BONUS | 7 states: joy, anger, fear, sadness, surprise, disgust, neutral | |
| | 7 | Intensity Scaling | BONUS | Confidence score linearly scales both params | |
| | 8 | Web Interface | BONUS | Gradio UI with live stats, history, distribution | |
| """) |
|
|
| gr.Markdown(""" |
| --- |
| **Stack:** Python Β· Gradio Β· HuggingFace Transformers Β· Inworld TTS API Β· httpx |
| |
| *Darwix AI Evaluation Β· 2025 Β· K V Jaya Harsha* |
| """) |
|
|
| synth_btn.click( |
| fn=synthesize, |
| inputs=[txt_input], |
| outputs=[audio_out, emotion_out, stats_out, dist_out, hist_out], |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch(server_name="0.0.0.0", server_port=7860, share=False) |