zaktree commited on
Commit
458591a
·
verified ·
1 Parent(s): 3025ef4

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +314 -0
app.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import os
3
+ import subprocess
4
+ import time
5
+ import wave
6
+ import numpy as np
7
+ import gradio as gr
8
+ import requests
9
+ import torch
10
+ from functools import lru_cache
11
+ from fastapi import FastAPI, HTTPException
12
+ from fastapi.responses import FileResponse, JSONResponse, Response
13
+ from zipvoice.luxvoice import LuxTTS
14
+
15
+ from pydantic import BaseModel
16
+
17
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
18
+
19
+ # Tracked so /health can report uptime and request volume - useful for
20
+ # correlating a slow day with "how long has this container been running"
21
+ # or "how many requests has it handled" without digging through raw logs.
22
+ START_TIME = time.time()
23
+ speak_request_count = 0
24
+
25
+
26
+ class ChatBody(BaseModel):
27
+ messages: list
28
+
29
+ # Init Model (weights already downloaded at build time)
30
+ device = "cuda" if torch.cuda.is_available() else "cpu"
31
+ lux_tts = LuxTTS("YatharthS/LuxTTS", device=device, threads=2)
32
+
33
+
34
+ @lru_cache(maxsize=8)
35
+ def get_encoded_prompt(audio_prompt, ref_duration, rms):
36
+ # encode_prompt() is deterministic for a given (file, duration, rms)
37
+ # combo, and me.wav never changes between calls from the chat bot -
38
+ # so we only need to pay this cost once instead of on every message.
39
+ return lux_tts.encode_prompt(audio_prompt, duration=ref_duration, rms=rms)
40
+
41
+
42
+ # Warm the cache at startup so the very first real request doesn't
43
+ # also have to pay the reference-encoding cost.
44
+ try:
45
+ get_encoded_prompt("me.wav", 5, 0.01)
46
+ print("Reference prompt pre-encoded and cached.")
47
+ except Exception as e:
48
+ print(f"Could not pre-warm reference prompt cache: {e}")
49
+
50
+
51
+ def run_tts(
52
+ text,
53
+ audio_prompt=None,
54
+ rms=0.01,
55
+ ref_duration=5,
56
+ t_shift=0.9,
57
+ num_steps=4,
58
+ speed=0.8,
59
+ return_smooth=False,
60
+ ):
61
+ # Shared core used by both the Gradio UI (infer) and the /speak
62
+ # FastAPI route, so there's one code path for actual generation.
63
+ if not audio_prompt:
64
+ audio_prompt = "me.wav"
65
+
66
+ start_time = time.time()
67
+
68
+ encoded_prompt = get_encoded_prompt(audio_prompt, ref_duration, rms)
69
+
70
+ final_wav = lux_tts.generate_speech(
71
+ text,
72
+ encoded_prompt,
73
+ num_steps=int(num_steps),
74
+ t_shift=t_shift,
75
+ speed=speed,
76
+ return_smooth=return_smooth,
77
+ )
78
+
79
+ duration = round(time.time() - start_time, 2)
80
+
81
+ final_wav = final_wav.cpu().squeeze(0).numpy()
82
+ final_wav = (np.clip(final_wav, -1.0, 1.0) * 32767).astype(np.int16)
83
+
84
+ return final_wav, duration
85
+
86
+
87
+ def wav_bytes_from_array(int16_array, sample_rate=48000):
88
+ # Wraps a raw int16 PCM array in a proper WAV header, in-memory -
89
+ # no temp file on disk, so nothing to serve back over a second
90
+ # HTTP round trip.
91
+ buf = io.BytesIO()
92
+ with wave.open(buf, "wb") as wf:
93
+ wf.setnchannels(1)
94
+ wf.setsampwidth(2)
95
+ wf.setframerate(sample_rate)
96
+ wf.writeframes(int16_array.tobytes())
97
+ return buf.getvalue()
98
+
99
+
100
+ def compress_to_aac(wav_bytes, bitrate="40k"):
101
+ # AAC-in-fragmented-MP4 instead of Opus-in-Ogg: Safari/iOS/WebKit
102
+ # never added Ogg container support (confirmed still true as of
103
+ # 2026), so Opus playback silently fails on iPad/iPhone even
104
+ # though it works fine on Chrome (laptop, Android). AAC in MP4
105
+ # plays everywhere - Safari, Chrome, Firefox, Android - with one
106
+ # code path instead of detecting device/browser and branching.
107
+ # "frag_keyframe+empty_moov+default_base_moof" produces a
108
+ # streamable fragmented MP4 that doesn't need to seek back to
109
+ # write a moov atom at the end, since we're writing to a pipe.
110
+ # Runs entirely via pipes, no temp files.
111
+ proc = subprocess.run(
112
+ [
113
+ "ffmpeg", "-i", "pipe:0",
114
+ "-c:a", "aac", "-b:a", bitrate, "-vn",
115
+ "-movflags", "frag_keyframe+empty_moov+default_base_moof",
116
+ "-f", "mp4", "pipe:1",
117
+ ],
118
+ input=wav_bytes,
119
+ stdout=subprocess.PIPE,
120
+ stderr=subprocess.PIPE,
121
+ check=True,
122
+ )
123
+ return proc.stdout
124
+
125
+
126
+ def infer(
127
+ text,
128
+ audio_prompt,
129
+ rms,
130
+ ref_duration,
131
+ t_shift,
132
+ num_steps,
133
+ speed,
134
+ return_smooth,
135
+ ):
136
+ if not text:
137
+ return None, "Please provide text."
138
+
139
+ final_wav, duration = run_tts(
140
+ text, audio_prompt, rms, ref_duration, t_shift, num_steps, speed, return_smooth,
141
+ )
142
+
143
+ stats_msg = f"✨ Generation complete in **{duration}s**."
144
+ return (48000, final_wav), stats_msg
145
+
146
+
147
+ with gr.Blocks(theme=gr.themes.Soft()) as demo:
148
+ gr.Markdown("# 🎙️ Papa Jerry Voice (LuxTTS)")
149
+
150
+ gr.Markdown(
151
+ """
152
+ > **Note:** This demo runs on a **2-core CPU**, so expect slower inference.
153
+ > **Tip:** If words get cut off, lower **Speed** or increase **Ref Duration**.
154
+ """
155
+ )
156
+
157
+ with gr.Row():
158
+ with gr.Column():
159
+ input_text = gr.Textbox(
160
+ label="Text to Synthesize",
161
+ value="Hey, what's up? I'm feeling really great!",
162
+ )
163
+ input_audio = gr.Audio(
164
+ label="Reference Audio (.wav)",
165
+ type="filepath",
166
+ value="me.wav",
167
+ )
168
+
169
+ with gr.Row():
170
+ rms_val = gr.Number(value=0.01, label="RMS (Loudness)")
171
+ ref_duration_val = gr.Number(
172
+ value=5,
173
+ label="Reference Duration (sec)",
174
+ info="Lower = faster. Set ~1000 if you hear artifacts.",
175
+ )
176
+ t_shift_val = gr.Number(value=0.9, label="T-Shift")
177
+
178
+ with gr.Row():
179
+ steps_val = gr.Slider(1, 10, value=4, step=1, label="Num Steps")
180
+ speed_val = gr.Slider(
181
+ 0.5, 2.0, value=0.8, step=0.1,
182
+ label="Speed (Lower = Longer / Clearer)",
183
+ )
184
+ smooth_val = gr.Checkbox(label="Return Smooth", value=False)
185
+
186
+ btn = gr.Button("Generate Speech", variant="primary")
187
+
188
+ with gr.Column():
189
+ audio_out = gr.Audio(label="Result")
190
+ status_text = gr.Markdown("Ready to generate...")
191
+
192
+ btn.click(
193
+ fn=infer,
194
+ inputs=[
195
+ input_text, input_audio, rms_val, ref_duration_val,
196
+ t_shift_val, steps_val, speed_val, smooth_val,
197
+ ],
198
+ outputs=[audio_out, status_text],
199
+ api_name="predict",
200
+ )
201
+
202
+ app = FastAPI()
203
+
204
+ @app.get("/")
205
+ def read_index():
206
+ return FileResponse("index.html")
207
+
208
+ @app.get("/speak")
209
+ def speak(text: str):
210
+ # GET + query param (instead of POST + JSON body) so the browser's
211
+ # <audio> element can set this URL directly as its src and stream
212
+ # the response progressively, playing as bytes arrive instead of
213
+ # the page having to fetch() the whole body into a blob first.
214
+ #
215
+ # Also compresses to Opus before sending - measured egress out of
216
+ # this Space is throttled to roughly 20-30 KB/s regardless of
217
+ # payload size, and raw WAV needs ~96 KB/s to play in real time.
218
+ # Opus at 32kbps needs ~4 KB/s, so it actually fits the available
219
+ # bandwidth instead of just being smaller.
220
+ if not text:
221
+ raise HTTPException(status_code=400, detail="No text provided")
222
+
223
+ global speak_request_count
224
+ speak_request_count += 1
225
+ request_start = time.time()
226
+
227
+ final_wav, duration = run_tts(text)
228
+ wav_bytes = wav_bytes_from_array(final_wav)
229
+
230
+ try:
231
+ audio_bytes = compress_to_aac(wav_bytes)
232
+ media_type = "audio/mp4"
233
+ except Exception as e:
234
+ print(f"AAC compression failed, falling back to raw wav: {e}")
235
+ audio_bytes = wav_bytes
236
+ media_type = "audio/wav"
237
+
238
+ total_seconds = round(time.time() - request_start, 2)
239
+ uptime_hours = round((time.time() - START_TIME) / 3600, 2)
240
+ print(
241
+ f"[/speak] request #{speak_request_count} | "
242
+ f"generation={duration}s | total={total_seconds}s | "
243
+ f"wav={len(wav_bytes)}B -> {media_type}={len(audio_bytes)}B | "
244
+ f"container_uptime={uptime_hours}h"
245
+ )
246
+
247
+ return Response(
248
+ content=audio_bytes,
249
+ media_type=media_type,
250
+ headers={
251
+ "X-Generation-Seconds": str(duration),
252
+ # Lets the browser reuse an already-downloaded clip for the
253
+ # same exact text (e.g. a "Replay Voice" click) instead of
254
+ # re-requesting and regenerating it from scratch.
255
+ "Cache-Control": "public, max-age=86400",
256
+ },
257
+ )
258
+
259
+
260
+ @app.get("/health")
261
+ def health():
262
+ uptime_seconds = round(time.time() - START_TIME, 1)
263
+ return JSONResponse({
264
+ "status": "ok",
265
+ "device": device,
266
+ "uptime_seconds": uptime_seconds,
267
+ "uptime_hours": round(uptime_seconds / 3600, 2),
268
+ "speak_requests_served": speak_request_count,
269
+ })
270
+
271
+
272
+ @app.get("/{filename}")
273
+ def read_static_file(filename: str):
274
+ # Serves any other flat file next to index.html/app.py in the repo -
275
+ # me.jpg, facts.json, etc. This only matches a single path segment
276
+ # (no slashes), so it won't intercept the /voice/... Gradio routes.
277
+ # IMPORTANT: this catch-all must stay registered AFTER /speak (and
278
+ # any other single-segment route) - FastAPI matches routes in
279
+ # registration order, so an earlier catch-all would shadow it.
280
+ if os.path.isfile(filename):
281
+ return FileResponse(filename)
282
+ raise HTTPException(status_code=404, detail="Not found")
283
+
284
+
285
+ @app.post("/chat")
286
+ def chat(body: ChatBody):
287
+ # Proxies the Groq call server-side so GROQ_API_KEY never has to
288
+ # live in index.html or be visible via "View Page Source".
289
+ # Plain (non-async) def so FastAPI runs this blocking network call
290
+ # in a threadpool instead of freezing the whole server on it.
291
+ if not GROQ_API_KEY:
292
+ raise HTTPException(status_code=500, detail="GROQ_API_KEY not configured on server")
293
+
294
+ r = requests.post(
295
+ "https://api.groq.com/openai/v1/chat/completions",
296
+ headers={
297
+ "Content-Type": "application/json",
298
+ "Authorization": f"Bearer {GROQ_API_KEY}",
299
+ },
300
+ json={
301
+ "model": "llama-3.3-70b-versatile",
302
+ "max_tokens": 300,
303
+ "messages": body.messages,
304
+ },
305
+ timeout=30,
306
+ )
307
+ return JSONResponse(content=r.json(), status_code=r.status_code)
308
+
309
+
310
+ app = gr.mount_gradio_app(app, demo, path="/voice")
311
+
312
+ if __name__ == "__main__":
313
+ import uvicorn
314
+ uvicorn.run(app, host="0.0.0.0", port=7860)