Spaces:
Runtime error
Runtime error
| import os | |
| import uuid | |
| import asyncio | |
| import tempfile | |
| from collections import OrderedDict | |
| import edge_tts | |
| from flask import Flask, request, jsonify, send_file, after_this_request | |
| from voices_data import VOICE_LIST | |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| app = Flask(__name__) | |
| # --- Build voice catalogue: short_name -> {lang, gender} --- | |
| VOICES = OrderedDict() | |
| for short_name, lang, gender in VOICE_LIST: | |
| VOICES[short_name] = {"lang": lang, "gender": gender} | |
| DEFAULT_VOICE = "my-MM-NilarNeural" | |
| TMP_DIR = os.path.join(tempfile.gettempdir(), "mm_tts_outputs") | |
| os.makedirs(TMP_DIR, exist_ok=True) | |
| MAX_CHARS = 10000 | |
| def clamp_pct(value, lo=-50, hi=50): | |
| try: | |
| value = int(value) | |
| except (TypeError, ValueError): | |
| value = 0 | |
| return max(lo, min(hi, value)) | |
| def display_name(short_name): | |
| # e.g. "en-US-AvaMultilingualNeural" -> "Ava (Multilingual)" | |
| part = short_name.split("-")[-1] | |
| part = part.replace("Neural", "") | |
| if part.endswith("Multilingual"): | |
| part = part.replace("Multilingual", "") + " (Multilingual)" | |
| return part | |
| GENDER_SYMBOL = {"Female": "\u2640", "Male": "\u2642"} | |
| def region_code(short_name): | |
| """Pull the ISO region code out of an edge-tts short name, e.g. | |
| 'en-US-AvaNeural' -> 'US', 'iu-Cans-CA-SiqiniqNeural' -> 'CA', | |
| 'zh-CN-liaoning-XiaobeiNeural' -> 'CN'.""" | |
| parts = short_name.split("-") | |
| if len(parts) < 2: | |
| return None | |
| candidate = parts[1] | |
| if len(candidate) == 4 and candidate.isalpha(): | |
| # script subtag (e.g. "Cans", "Latn") -> region is the next part | |
| return parts[2] if len(parts) > 2 else None | |
| if len(candidate) == 2 and candidate.isalpha(): | |
| return candidate | |
| return None | |
| def flag_emoji(region): | |
| if not region or len(region) != 2: | |
| return "\U0001F310" # globe fallback | |
| base = 0x1F1E6 | |
| try: | |
| return "".join(chr(base + (ord(c.upper()) - ord("A"))) for c in region) | |
| except Exception: | |
| return "\U0001F310" | |
| import re | |
| MAX_PARALLEL_CHUNKS = 6 # cap concurrent Edge TTS connections | |
| def split_paragraphs(text): | |
| """Split on blank lines into paragraphs. If there are no blank lines | |
| (just one block of text), returns a single-item list β same as the | |
| old single-request behavior.""" | |
| raw = re.split(r"\n\s*\n+", text.strip()) | |
| paragraphs = [p.strip() for p in raw if p.strip()] | |
| return paragraphs or [text.strip()] | |
| def index(): | |
| return send_file(os.path.join(BASE_DIR, "index.html")) | |
| def get_voices(): | |
| """Return voices grouped by language for the UI dropdowns, with a flag | |
| per language group and a gender symbol baked into each voice name.""" | |
| grouped = OrderedDict() | |
| flags = {} | |
| for short_name, lang, gender in VOICE_LIST: | |
| flags.setdefault(lang, flag_emoji(region_code(short_name))) | |
| symbol = GENDER_SYMBOL.get(gender, "") | |
| grouped.setdefault(lang, []).append( | |
| {"id": short_name, "name": display_name(short_name), "gender": gender, "symbol": symbol} | |
| ) | |
| result = OrderedDict() | |
| for lang, voices in grouped.items(): | |
| result[lang] = {"flag": flags[lang], "voices": voices} | |
| return jsonify(result) | |
| def tts(): | |
| data = request.get_json(silent=True) or {} | |
| text = (data.get("text") or "").strip() | |
| voice = data.get("voice") or DEFAULT_VOICE | |
| rate = clamp_pct(data.get("rate", 0)) | |
| pitch = clamp_pct(data.get("pitch", 0)) | |
| if not text: | |
| return jsonify({"error": "α α¬αα¬αΈ ααα·αΊαα±αΈαα« (text is required)"}), 400 | |
| if len(text) > MAX_CHARS: | |
| return jsonify({"error": f"α α¬αα¬αΈ {MAX_CHARS} αα―αΆαΈα‘αααα¬ ααα·αΊααα―ααΊαα«αααΊ"}), 400 | |
| if voice not in VOICES: | |
| voice = DEFAULT_VOICE | |
| rate_str = f"{'+' if rate >= 0 else ''}{rate}%" | |
| pitch_str = f"{'+' if pitch >= 0 else ''}{pitch}Hz" | |
| paragraphs = split_paragraphs(text) | |
| final_filename = f"{uuid.uuid4().hex}.mp3" | |
| final_filepath = os.path.join(TMP_DIR, final_filename) | |
| part_paths = [ | |
| os.path.join(TMP_DIR, f"{uuid.uuid4().hex}_part{i}.mp3") | |
| for i in range(len(paragraphs)) | |
| ] | |
| async def generate_one(idx, paragraph, sem): | |
| async with sem: | |
| communicate = edge_tts.Communicate( | |
| paragraph, voice, rate=rate_str, pitch=pitch_str | |
| ) | |
| await communicate.save(part_paths[idx]) | |
| async def generate_all(): | |
| sem = asyncio.Semaphore(MAX_PARALLEL_CHUNKS) | |
| await asyncio.gather( | |
| *[generate_one(i, p, sem) for i, p in enumerate(paragraphs)] | |
| ) | |
| try: | |
| asyncio.run(generate_all()) | |
| except Exception as e: | |
| for p in part_paths: | |
| if os.path.exists(p): | |
| os.remove(p) | |
| return jsonify({"error": str(e)}), 500 | |
| # Stitch the per-paragraph mp3s back together in order. Edge TTS's mp3 | |
| # output is plain MPEG audio frames with no embedded ID3 tags, so a raw | |
| # byte-for-byte concatenation plays back correctly β no ffmpeg needed. | |
| try: | |
| with open(final_filepath, "wb") as out: | |
| for p in part_paths: | |
| if os.path.exists(p) and os.path.getsize(p) > 0: | |
| with open(p, "rb") as part: | |
| out.write(part.read()) | |
| finally: | |
| for p in part_paths: | |
| if os.path.exists(p): | |
| os.remove(p) | |
| if not os.path.exists(final_filepath) or os.path.getsize(final_filepath) == 0: | |
| return jsonify({"error": "α‘ααΆααα―ααΊ αα―ααΊαα°ααΌααΊαΈ αα‘α±α¬ααΊααΌααΊαα«"}), 500 | |
| def cleanup(response): | |
| try: | |
| os.remove(final_filepath) | |
| except OSError: | |
| pass | |
| return response | |
| return send_file( | |
| final_filepath, | |
| mimetype="audio/mpeg", | |
| as_attachment=False, | |
| download_name="voice.mp3", | |
| conditional=False, | |
| ) | |
| def health(): | |
| return jsonify({"status": "ok"}) | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| app.run(host="0.0.0.0", port=port) | |