RishiKar210's picture
Initial backend deploy
99df98c
Raw
History Blame Contribute Delete
15 kB
"""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")