Spaces:
Sleeping
Sleeping
| """Streamlit UI for ElevenLabs TTS β Emotion Control, Batch Processing, Voice Cloning.""" | |
| import io, os, re, time, zipfile | |
| import requests | |
| import streamlit as st | |
| from pydub import AudioSegment | |
| # ββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ELEVENLABS_BASE = "https://api.elevenlabs.io/v1" | |
| SAMPLE_RATE = 44100 | |
| DEFAULT_VOICE = "8uYaUDIcnotZPgF7r41V" | |
| DEFAULT_SEED = 42 | |
| MODELS = { | |
| "Eleven v3 (Expressive)": "eleven_v3", | |
| "Multilingual v2 (Stable)": "eleven_multilingual_v2", | |
| "Flash v2.5 (Fast)": "eleven_flash_v2_5", | |
| } | |
| # ElevenLabs v3 audio tags mapped to emotion dimensions | |
| EMOTION_TAGS = { | |
| "happy": "[happy]", | |
| "sad": "[sad]", | |
| "angry": "[angry]", | |
| "calm": "[calm]", | |
| "excited": "[excited]", | |
| "serious": "[serious]", | |
| "whisper": "[whisper]", | |
| "soft": "[soft]", | |
| "loud": "[loud]", | |
| } | |
| EMOTION_PRESETS = { | |
| "Neutral": {}, | |
| "Happy & Excited": {"happy": 0.7, "excited": 0.8}, | |
| "Calm & Gentle": {"calm": 0.8, "soft": 0.6}, | |
| "Sad & Soft": {"sad": 0.7, "soft": 0.5}, | |
| "Angry & Loud": {"angry": 0.8, "loud": 0.7}, | |
| "Serious & Low": {"serious": 0.7}, | |
| "Whisper": {"whisper": 0.9, "soft": 0.4}, | |
| "Excited & Loud": {"excited": 0.9, "loud": 0.6}, | |
| } | |
| ACCENT_TAGS = [ | |
| "None", "American accent", "British accent", | |
| "slight Japanese accent", "Japanese accent", | |
| "Australian accent", "Indian accent", | |
| ] | |
| # ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_api_key(): | |
| key = os.environ.get("ELEVENLABS_API_KEY", "") | |
| if not key: | |
| env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") | |
| if os.path.exists(env_path): | |
| with open(env_path) as f: | |
| for line in f: | |
| if line.startswith("ELEVENLABS_API_KEY="): | |
| key = line.strip().split("=", 1)[1].strip() | |
| return key | |
| def build_tagged_text(text, emotions, accent, model): | |
| """Prepend emotion and accent audio tags to text (v3 only).""" | |
| if model != "eleven_v3": | |
| return text | |
| tags = [] | |
| if accent != "None": | |
| tags.append(f"[{accent}]") | |
| # Pick the dominant emotion (highest value > 0.3) | |
| active = {k: v for k, v in emotions.items() if v > 0.3} | |
| if active: | |
| dominant = max(active, key=active.get) | |
| tags.append(EMOTION_TAGS[dominant]) | |
| return " ".join(tags + [text]) | |
| def parse_emotion_text(text): | |
| """Parse (emotion description) prefixed lines into tagged text. | |
| Example: '(cheerful and excited) Hello!' -> '[happy] [excited] Hello!' | |
| """ | |
| EMOTION_MAP = { | |
| "cheerful": "happy", "joyful": "happy", "happy": "happy", | |
| "excited": "excited", "enthusiastic": "excited", | |
| "calm": "calm", "gentle": "calm", "peaceful": "calm", | |
| "sad": "sad", "melancholic": "sad", "sorrowful": "sad", | |
| "angry": "angry", "furious": "angry", "mad": "angry", | |
| "serious": "serious", "solemn": "serious", "grave": "serious", | |
| "whisper": "whisper", "whispering": "whisper", "hushed": "whisper", | |
| "soft": "soft", "quiet": "soft", "tender": "soft", | |
| "loud": "loud", "shouting": "loud", "yelling": "loud", | |
| } | |
| match = re.match(r"^\(([^)]+)\)\s*(.+)$", text.strip()) | |
| if not match: | |
| return text, {} | |
| desc, content = match.group(1).lower(), match.group(2) | |
| found = {} | |
| for word in re.findall(r"[a-z]+", desc): | |
| if word in EMOTION_MAP: | |
| found[EMOTION_MAP[word]] = 0.8 | |
| return content, found | |
| def tts_call(api_key, voice_id, text, speed, seed, model, stability, similarity): | |
| """Call ElevenLabs TTS API. Text should already have tags prepended.""" | |
| resp = requests.post( | |
| f"{ELEVENLABS_BASE}/text-to-speech/{voice_id}", | |
| headers={ | |
| "xi-api-key": api_key, | |
| "Content-Type": "application/json", | |
| "Accept": "audio/mpeg", | |
| }, | |
| json={ | |
| "text": text, | |
| "model_id": model, | |
| "seed": seed, | |
| "output_format": "mp3_44100_128", | |
| "voice_settings": { | |
| "stability": stability, | |
| "similarity_boost": similarity, | |
| "speed": round(speed, 3), | |
| }, | |
| }, | |
| timeout=60, | |
| ) | |
| resp.raise_for_status() | |
| return resp.content | |
| # ββ Page Config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.set_page_config(page_title="ElevenLabs TTS Studio", layout="wide") | |
| st.title("ElevenLabs TTS Studio") | |
| api_key = get_api_key() | |
| if not api_key: | |
| st.error("ELEVENLABS_API_KEY not found. Add it to your .env file.") | |
| st.stop() | |
| # ββ Sidebar: Voice & Model ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with st.sidebar: | |
| st.header("Voice & Model") | |
| voice_id = st.text_input("Voice ID", value=DEFAULT_VOICE) | |
| model_name = st.selectbox("Model", list(MODELS.keys())) | |
| model_id = MODELS[model_name] | |
| is_v3 = model_id == "eleven_v3" | |
| st.divider() | |
| st.header("Voice Settings") | |
| stability = st.slider("Stability", 0.0, 1.0, 0.85, 0.05, | |
| help="Higher = more consistent, less responsive to tags") | |
| similarity = st.slider("Similarity Boost", 0.0, 1.0, 0.90, 0.05, | |
| help="Higher = closer to original voice clone") | |
| speed = st.slider("Speed", 0.7, 1.5, 1.0, 0.05) | |
| st.divider() | |
| st.header("Determinism") | |
| seed = st.number_input("Seed", min_value=0, max_value=4294967295, value=DEFAULT_SEED, | |
| help="Fixed seed = consistent output across runs") | |
| st.divider() | |
| st.header("Accent (v3 only)") | |
| accent = st.selectbox("Accent Tag", ACCENT_TAGS, disabled=not is_v3) | |
| if not is_v3 and accent != "None": | |
| accent = "None" | |
| # ββ Main Area: Tabs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| tab_single, tab_batch = st.tabs(["Single", "Batch"]) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SINGLE MODE | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with tab_single: | |
| st.subheader("Text Input") | |
| st.caption("Supports emotion-tagged text: `(cheerful and excited) Hello!`") | |
| text_input = st.text_area("Enter text", height=150, key="single_text", | |
| placeholder="Type text here...\nor (happy) This is great!\nor (calm and gentle) Take your time.") | |
| # Emotion Control | |
| st.subheader("Emotion Control" + (" (v3 only)" if not is_v3 else "")) | |
| col_preset, col_custom = st.columns([1, 2]) | |
| with col_preset: | |
| preset = st.selectbox("Preset", list(EMOTION_PRESETS.keys())) | |
| preset_vals = EMOTION_PRESETS[preset] | |
| with col_custom: | |
| st.caption("Fine-tune each dimension (0.0 = off, 1.0 = max)") | |
| # Emotion sliders in 2 rows of 4 | |
| emotions = {} | |
| emotion_keys = list(EMOTION_TAGS.keys()) | |
| row1 = st.columns(5) | |
| row2 = st.columns(4) | |
| all_cols = row1 + row2 | |
| for i, emo in enumerate(emotion_keys): | |
| with all_cols[i]: | |
| default = preset_vals.get(emo, 0.0) | |
| emotions[emo] = st.slider(emo.capitalize(), 0.0, 1.0, default, 0.1, | |
| key=f"emo_{emo}", disabled=not is_v3) | |
| # Generate button | |
| if st.button("Generate", type="primary", key="single_gen"): | |
| if not text_input.strip(): | |
| st.error("Enter some text.") | |
| else: | |
| with st.spinner("Generating..."): | |
| try: | |
| # Check for emotion-tagged text format | |
| clean_text, parsed_emo = parse_emotion_text(text_input.strip()) | |
| # Merge: parsed emotions from text override slider if present | |
| merged_emo = {**emotions, **parsed_emo} if parsed_emo else emotions | |
| final_text = build_tagged_text(clean_text, merged_emo, accent, model_id) | |
| mp3 = tts_call(api_key, voice_id, final_text, speed, seed, model_id, stability, similarity) | |
| audio = AudioSegment.from_mp3(io.BytesIO(mp3)).set_frame_rate(SAMPLE_RATE).set_channels(1) | |
| wav_buf = io.BytesIO() | |
| audio.export(wav_buf, format="wav") | |
| st.session_state["s_audio"] = wav_buf.getvalue() | |
| st.session_state["s_mp3"] = mp3 | |
| st.session_state["s_dur"] = len(audio) / 1000 | |
| st.session_state["s_tags"] = final_text | |
| except Exception as e: | |
| st.error(f"API Error: {e}") | |
| # Results | |
| if "s_audio" in st.session_state: | |
| st.divider() | |
| c1, c2 = st.columns([1, 3]) | |
| with c1: | |
| st.metric("Duration", f"{st.session_state['s_dur']:.2f}s") | |
| with c2: | |
| with st.expander("Tags sent to API"): | |
| st.code(st.session_state["s_tags"]) | |
| st.audio(st.session_state["s_audio"], format="audio/wav") | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.download_button("Download WAV", data=st.session_state["s_audio"], | |
| file_name="output.wav", mime="audio/wav", key="dl_wav_s") | |
| with col2: | |
| st.download_button("Download MP3", data=st.session_state["s_mp3"], | |
| file_name="output.mp3", mime="audio/mpeg", key="dl_mp3_s") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # BATCH MODE | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with tab_batch: | |
| st.subheader("Batch Text Input") | |
| st.caption("One line per segment. Supports emotion tags: `(happy) Line text`") | |
| batch_text = st.text_area("Enter lines (one per segment)", height=250, key="batch_text", | |
| placeholder="(cheerful) Hello everyone!\n(calm) Welcome to the session.\n(serious) Let's begin.\nPlain text line without emotion.") | |
| if st.button("Generate Batch", type="primary", key="batch_gen"): | |
| lines = [l.strip() for l in batch_text.strip().split("\n") if l.strip()] | |
| if not lines: | |
| st.error("Enter at least one line.") | |
| else: | |
| progress = st.progress(0, text="Starting...") | |
| results = [] | |
| for i, line in enumerate(lines): | |
| progress.progress((i + 1) / len(lines), text=f"Segment {i+1}/{len(lines)}") | |
| try: | |
| clean_text, parsed_emo = parse_emotion_text(line) | |
| merged_emo = parsed_emo if parsed_emo else emotions | |
| final_text = build_tagged_text(clean_text, merged_emo, accent, model_id) | |
| mp3 = tts_call(api_key, voice_id, final_text, speed, seed, model_id, stability, similarity) | |
| audio = AudioSegment.from_mp3(io.BytesIO(mp3)).set_frame_rate(SAMPLE_RATE).set_channels(1) | |
| dur = len(audio) / 1000 | |
| wav_buf = io.BytesIO() | |
| audio.export(wav_buf, format="wav") | |
| results.append({ | |
| "idx": i + 1, "text": clean_text, "tags": final_text, | |
| "duration": dur, "wav": wav_buf.getvalue(), "mp3": mp3, "status": "OK", | |
| }) | |
| except Exception as e: | |
| results.append({ | |
| "idx": i + 1, "text": line, "tags": "", | |
| "duration": 0, "wav": b"", "mp3": b"", "status": f"ERROR: {e}", | |
| }) | |
| time.sleep(0.15) | |
| progress.progress(1.0, text="Done!") | |
| st.session_state["batch_results"] = results | |
| # Batch Results | |
| if "batch_results" in st.session_state: | |
| results = st.session_state["batch_results"] | |
| st.divider() | |
| # Summary | |
| ok = sum(1 for r in results if r["status"] == "OK") | |
| err = len(results) - ok | |
| total_dur = sum(r["duration"] for r in results) | |
| c1, c2, c3 = st.columns(3) | |
| c1.metric("Segments", f"{ok}/{len(results)}") | |
| c2.metric("Errors", err) | |
| c3.metric("Total Duration", f"{total_dur:.1f}s") | |
| # Report table | |
| st.dataframe([{ | |
| "#": r["idx"], "Text": r["text"][:60], | |
| "Duration": f"{r['duration']:.2f}s", "Status": r["status"], | |
| } for r in results], use_container_width=True) | |
| # Per-segment playback | |
| with st.expander("Preview segments"): | |
| for r in results: | |
| if r["wav"]: | |
| st.caption(f"#{r['idx']}: {r['text'][:80]}") | |
| st.audio(r["wav"], format="audio/wav") | |
| # Downloads | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| # Stitched output | |
| combined = AudioSegment.empty() | |
| for r in results: | |
| if r["wav"]: | |
| combined += AudioSegment.from_wav(io.BytesIO(r["wav"])) | |
| combined += AudioSegment.silent(duration=300) # 300ms gap between segments | |
| buf = io.BytesIO() | |
| combined.export(buf, format="wav") | |
| st.download_button("Download Combined WAV", data=buf.getvalue(), | |
| file_name="batch_combined.wav", mime="audio/wav", key="dl_batch_wav") | |
| with col2: | |
| zip_buf = io.BytesIO() | |
| with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf: | |
| for r in results: | |
| if r["wav"]: | |
| zf.writestr(f"{r['idx']:04d}.wav", r["wav"]) | |
| st.download_button("Download All Segments (.zip)", data=zip_buf.getvalue(), | |
| file_name="batch_segments.zip", mime="application/zip", key="dl_batch_zip") | |