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()] @app.route("/") def index(): return send_file(os.path.join(BASE_DIR, "index.html")) @app.route("/api/voices") 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) @app.route("/api/tts", methods=["POST"]) 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 @after_this_request 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, ) @app.route("/api/health") 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)