Spaces:
Runtime error
Runtime error
File size: 6,263 Bytes
3e3ac98 11d3b2d e73ffd4 3f1edfb 11d3b2d 5fc0f2e 3f1edfb e73ffd4 5fc0f2e 11d3b2d 207b983 e73ffd4 207b983 11d3b2d 8ce2809 53fa677 11d3b2d 3e3ac98 11d3b2d 53fa677 e73ffd4 8ce2809 11d3b2d 5fc0f2e 53fa677 11d3b2d 8ce2809 e73ffd4 8ce2809 e73ffd4 8ce2809 e73ffd4 8ce2809 e73ffd4 8ce2809 53fa677 11d3b2d 207b983 11d3b2d 207b983 11d3b2d e73ffd4 11d3b2d e73ffd4 11d3b2d 8ce2809 11d3b2d 8ce2809 207b983 8ce2809 11d3b2d 8ce2809 e73ffd4 11d3b2d 8ce2809 11d3b2d 8ce2809 11d3b2d e73ffd4 11d3b2d 53fa677 3f1edfb 11d3b2d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | 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)
|