File size: 11,919 Bytes
1328885
569eba9
0d203e0
 
569eba9
 
 
20172e6
569eba9
20172e6
 
 
569eba9
 
 
 
20172e6
1328885
20172e6
1328885
0d203e0
1328885
 
569eba9
1328885
 
569eba9
1328885
569eba9
1328885
 
 
 
 
 
569eba9
1328885
 
 
 
 
 
569eba9
1328885
 
 
 
 
569eba9
1328885
569eba9
1328885
 
 
 
 
 
569eba9
1328885
 
 
 
 
 
569eba9
1328885
 
 
 
 
 
569eba9
1328885
 
 
 
 
 
569eba9
1328885
 
 
 
 
569eba9
1328885
569eba9
1328885
 
 
 
 
 
569eba9
1328885
 
 
 
 
 
 
 
 
20172e6
569eba9
1328885
569eba9
1328885
 
 
569eba9
20172e6
1328885
 
 
569eba9
1328885
 
 
 
569eba9
1328885
 
 
20172e6
1328885
 
 
 
 
 
 
20172e6
1328885
 
569eba9
1328885
 
 
569eba9
20172e6
1328885
569eba9
20172e6
 
1328885
 
 
 
20172e6
 
 
569eba9
20172e6
1328885
569eba9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1328885
20172e6
1328885
 
 
20172e6
 
 
 
 
 
 
1328885
 
 
 
 
 
569eba9
 
 
 
 
 
 
 
 
 
 
1328885
 
 
 
 
 
 
 
569eba9
1328885
20172e6
 
 
 
569eba9
 
1328885
20172e6
 
 
1328885
 
 
 
20172e6
1328885
569eba9
1328885
 
569eba9
1328885
 
 
 
 
 
 
569eba9
 
20172e6
 
 
569eba9
20172e6
569eba9
20172e6
1328885
 
20172e6
 
 
 
1328885
20172e6
 
1328885
20172e6
 
 
 
1328885
20172e6
 
569eba9
20172e6
 
 
 
 
1328885
20172e6
 
 
569eba9
 
1328885
569eba9
 
 
 
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
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
import io
import os
import uuid
import wave
import tempfile
import threading
import urllib.request
from pathlib import Path
from flask import Flask, render_template, request, jsonify, send_file

app = Flask(__name__)

# HuggingFace Spaces stores files under /app
BASE_DIR   = Path(__file__).parent
OUTPUT_DIR = BASE_DIR / "static" / "audio"
MODELS_DIR = BASE_DIR / "models"
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
MODELS_DIR.mkdir(parents=True, exist_ok=True)

HF_BASE = "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0"
HF_MAIN = "https://huggingface.co/rhasspy/piper-voices/resolve/main"

# ─────────────────────────────────────────────
# Voice Registry
# ─────────────────────────────────────────────
VOICES = {
    # ── Hindi β€” on HF_MAIN (added after v1.0.0 tag) ──
    "hi_rohan": {
        "label": "Rohan", "lang": "hi", "gender": "male", "quality": "medium",
        "onnx_url": f"{HF_MAIN}/hi/hi_IN/rohan/medium/hi_IN-rohan-medium.onnx",
        "json_url": f"{HF_MAIN}/hi/hi_IN/rohan/medium/hi_IN-rohan-medium.onnx.json",
        "onnx": "hi_IN-rohan-medium.onnx",
        "json": "hi_IN-rohan-medium.onnx.json",
    },
    "hi_pratham": {
        "label": "Pratham", "lang": "hi", "gender": "male", "quality": "medium",
        "onnx_url": f"{HF_MAIN}/hi/hi_IN/pratham/medium/hi_IN-pratham-medium.onnx",
        "json_url": f"{HF_MAIN}/hi/hi_IN/pratham/medium/hi_IN-pratham-medium.onnx.json",
        "onnx": "hi_IN-pratham-medium.onnx",
        "json": "hi_IN-pratham-medium.onnx.json",
    },
    "hi_priyamvada": {
        "label": "Priyamvada", "lang": "hi", "gender": "female", "quality": "medium",
        "onnx_url": f"{HF_MAIN}/hi/hi_IN/priyamvada/medium/hi_IN-priyamvada-medium.onnx",
        "json_url": f"{HF_MAIN}/hi/hi_IN/priyamvada/medium/hi_IN-priyamvada-medium.onnx.json",
        "onnx": "hi_IN-priyamvada-medium.onnx",
        "json": "hi_IN-priyamvada-medium.onnx.json",
    },
    # ── English US ──
    "en_lessac": {
        "label": "Lessac", "lang": "en", "gender": "male", "quality": "medium",
        "onnx_url": f"{HF_BASE}/en/en_US/lessac/medium/en_US-lessac-medium.onnx",
        "json_url": f"{HF_BASE}/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json",
        "onnx": "en_US-lessac-medium.onnx",
        "json": "en_US-lessac-medium.onnx.json",
    },
    "en_amy": {
        "label": "Amy", "lang": "en", "gender": "female", "quality": "medium",
        "onnx_url": f"{HF_BASE}/en/en_US/amy/medium/en_US-amy-medium.onnx",
        "json_url": f"{HF_BASE}/en/en_US/amy/medium/en_US-amy-medium.onnx.json",
        "onnx": "en_US-amy-medium.onnx",
        "json": "en_US-amy-medium.onnx.json",
    },
    "en_ryan": {
        "label": "Ryan", "lang": "en", "gender": "male", "quality": "high",
        "onnx_url": f"{HF_BASE}/en/en_US/ryan/high/en_US-ryan-high.onnx",
        "json_url": f"{HF_BASE}/en/en_US/ryan/high/en_US-ryan-high.onnx.json",
        "onnx": "en_US-ryan-high.onnx",
        "json": "en_US-ryan-high.onnx.json",
    },
    "en_kristin": {
        "label": "Kristin", "lang": "en", "gender": "female", "quality": "medium",
        "onnx_url": f"{HF_BASE}/en/en_US/kristin/medium/en_US-kristin-medium.onnx",
        "json_url": f"{HF_BASE}/en/en_US/kristin/medium/en_US-kristin-medium.onnx.json",
        "onnx": "en_US-kristin-medium.onnx",
        "json": "en_US-kristin-medium.onnx.json",
    },
    "en_joe": {
        "label": "Joe", "lang": "en", "gender": "male", "quality": "medium",
        "onnx_url": f"{HF_BASE}/en/en_US/joe/medium/en_US-joe-medium.onnx",
        "json_url": f"{HF_BASE}/en/en_US/joe/medium/en_US-joe-medium.onnx.json",
        "onnx": "en_US-joe-medium.onnx",
        "json": "en_US-joe-medium.onnx.json",
    },
    # ── English GB ──
    "en_gb_alan": {
        "label": "Alan (British)", "lang": "en", "gender": "male", "quality": "medium",
        "onnx_url": f"{HF_BASE}/en/en_GB/alan/medium/en_GB-alan-medium.onnx",
        "json_url": f"{HF_BASE}/en/en_GB/alan/medium/en_GB-alan-medium.onnx.json",
        "onnx": "en_GB-alan-medium.onnx",
        "json": "en_GB-alan-medium.onnx.json",
    },
    "en_gb_jenny": {
        "label": "Jenny (British)", "lang": "en", "gender": "female", "quality": "medium",
        "onnx_url": f"{HF_BASE}/en/en_GB/jenny_dioco/medium/en_GB-jenny_dioco-medium.onnx",
        "json_url": f"{HF_BASE}/en/en_GB/jenny_dioco/medium/en_GB-jenny_dioco-medium.onnx.json",
        "onnx": "en_GB-jenny_dioco-medium.onnx",
        "json": "en_GB-jenny_dioco-medium.onnx.json",
    },
}

# ─────────────────────────────────────────────
# State
# ─────────────────────────────────────────────
loaded_voices  = {}
loading_status = {}
tts_lock       = threading.Lock()


# ─────────────────────────────────────────────
# Download & Load
# ─────────────────────────────────────────────
def _download(url, dest):
    if dest.exists():
        return
    print(f"  ⬇  Downloading {dest.name} …", flush=True)
    tmp = dest.with_suffix(".tmp")
    try:
        urllib.request.urlretrieve(url, tmp)
        tmp.rename(dest)
        print(f"  βœ… {dest.name} ready", flush=True)
    except Exception as e:
        tmp.unlink(missing_ok=True)
        raise RuntimeError(f"Download failed: {e}")


def _load_voice(key):
    from piper import PiperVoice
    loading_status[key] = "loading"
    v = VOICES[key]
    onnx_path = MODELS_DIR / v["onnx"]
    json_path = MODELS_DIR / v["json"]
    try:
        _download(v["onnx_url"], onnx_path)
        _download(v["json_url"], json_path)
        voice = PiperVoice.load(str(onnx_path), config_path=str(json_path))
        with tts_lock:
            loaded_voices[key] = voice
        loading_status[key] = "ready"
        print(f"  πŸŽ™  Voice '{key}' ready.", flush=True)
    except Exception as e:
        loading_status[key] = f"error: {e}"
        print(f"  ❌ Voice '{key}' failed: {e}", flush=True)


def preload_defaults():
    for key in ("en_lessac", "hi_rohan"):
        t = threading.Thread(target=_load_voice, args=(key,), daemon=True)
        t.start()


# ─────────────────────────────────────────────
# Synthesis β€” handles all piper-tts API versions
# ─────────────────────────────────────────────
def _synthesize_to_bytes(voice, text):
    # v2 API: synthesize_wav
    if hasattr(voice, "synthesize_wav"):
        buf = io.BytesIO()
        with wave.open(buf, "wb") as wf:
            voice.synthesize_wav(text, wf)
        return buf.getvalue()

    # v1 API: synthesize_stream_raw (yields raw PCM)
    if hasattr(voice, "synthesize_stream_raw"):
        buf = io.BytesIO()
        wf = wave.open(buf, "wb")
        try:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(voice.config.sample_rate)
            for chunk in voice.synthesize_stream_raw(text):
                wf.writeframes(chunk)
        finally:
            wf.close()
        return buf.getvalue()

    # v1 fallback: synthesize(text, wav_file) β€” needs a real file path
    tmp_fd, tmp_path = tempfile.mkstemp(suffix=".wav")
    try:
        os.close(tmp_fd)
        with wave.open(tmp_path, "w") as wf:
            voice.synthesize(text, wf)
        return Path(tmp_path).read_bytes()
    finally:
        Path(tmp_path).unlink(missing_ok=True)


# ─────────────────────────────────────────────
# Routes
# ─────────────────────────────────────────────
@app.route("/")
def index():
    return render_template("index.html")


@app.route("/status")
def status():
    ready = any(s == "ready" for s in loading_status.values())
    return jsonify({"ready": ready, "loading": loading_status})


@app.route("/voices")
def voices_route():
    return jsonify({"voices": [
        {
            "key":     k,
            "label":   v["label"],
            "lang":    v["lang"],
            "gender":  v["gender"],
            "quality": v["quality"],
            "status":  loading_status.get(k, "not_loaded"),
        }
        for k, v in VOICES.items()
    ]})


@app.route("/load_voice", methods=["POST"])
def load_voice_route():
    key = request.get_json().get("key")
    if key not in VOICES:
        return jsonify({"error": "Unknown voice key"}), 400
    if loading_status.get(key) not in ("ready", "loading"):
        threading.Thread(target=_load_voice, args=(key,), daemon=True).start()
    return jsonify({"status": loading_status.get(key, "loading")})


@app.route("/synthesize", methods=["POST"])
def synthesize():
    data      = request.get_json()
    text      = data.get("text", "").strip()
    voice_key = data.get("voice", "en_lessac")

    if not text:
        return jsonify({"error": "Text cannot be empty"}), 400
    if len(text) > 50000:
        return jsonify({"error": "Text too long (max 50,000 characters)"}), 400
    if voice_key not in VOICES:
        return jsonify({"error": "Unknown voice"}), 400

    if loading_status.get(voice_key) not in ("ready", "loading"):
        threading.Thread(target=_load_voice, args=(voice_key,), daemon=True).start()

    if loading_status.get(voice_key) == "loading":
        return jsonify({"error": "Voice is still loading, please retry in a moment."}), 503
    if loading_status.get(voice_key, "").startswith("error"):
        return jsonify({"error": loading_status[voice_key]}), 500

    voice = loaded_voices.get(voice_key)
    if not voice:
        return jsonify({"error": "Voice not ready yet."}), 503

    file_id  = uuid.uuid4().hex[:12]
    out_path = OUTPUT_DIR / f"speech_{VOICES[voice_key]['lang']}_{file_id}.wav"

    try:
        with tts_lock:
            out_path.write_bytes(_synthesize_to_bytes(voice, text))
        _cleanup_old_files()
        return jsonify({"success": True, "file": f"/audio/{out_path.name}", "filename": out_path.name})
    except Exception as e:
        out_path.unlink(missing_ok=True)
        return jsonify({"error": f"Synthesis failed: {e}"}), 500


@app.route("/audio/<filename>")
def serve_audio(filename):
    path = OUTPUT_DIR / Path(filename).name
    if not path.exists():
        return jsonify({"error": "File not found"}), 404
    return send_file(str(path), mimetype="audio/wav")


@app.route("/download/<filename>")
def download_audio(filename):
    path = OUTPUT_DIR / Path(filename).name
    if not path.exists():
        return jsonify({"error": "File not found"}), 404
    return send_file(str(path), mimetype="audio/wav", as_attachment=True, download_name=filename)


def _cleanup_old_files(keep=20):
    files = sorted(OUTPUT_DIR.glob("speech_*.wav"), key=lambda f: f.stat().st_mtime)
    for f in files[:-keep]:
        f.unlink(missing_ok=True)


if __name__ == "__main__":
    print("\nπŸŽ™οΈ  VoxStudio β€” HuggingFace Space", flush=True)
    print("πŸ“¦  Pre-loading default voices in background…", flush=True)
    preload_defaults()
    # HuggingFace Spaces requires port 7860
    port = int(os.environ.get("PORT", 7860))
    print(f"πŸš€  Running on http://0.0.0.0:{port}", flush=True)
    app.run(host="0.0.0.0", port=port, debug=False, threaded=True)