USP / app.py
Phoe2004's picture
Upload 2 files
8ce2809 verified
Raw
History Blame Contribute Delete
6.26 kB
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)