import io import os import subprocess import time import wave import numpy as np import gradio as gr import requests import torch from functools import lru_cache from fastapi import FastAPI, HTTPException, UploadFile, File from fastapi.responses import FileResponse, JSONResponse, Response from zipvoice.luxvoice import LuxTTS from pydantic import BaseModel GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") # Tracked so /health can report uptime and request volume - useful for # correlating a slow day with "how long has this container been running" # or "how many requests has it handled" without digging through raw logs. START_TIME = time.time() speak_request_count = 0 transcribe_request_count = 0 class ChatBody(BaseModel): messages: list # Init Model (weights already downloaded at build time) device = "cuda" if torch.cuda.is_available() else "cpu" lux_tts = LuxTTS("YatharthS/LuxTTS", device=device, threads=2) @lru_cache(maxsize=8) def get_encoded_prompt(audio_prompt, ref_duration, rms): # encode_prompt() is deterministic for a given (file, duration, rms) # combo, and me.wav never changes between calls from the chat bot - # so we only need to pay this cost once instead of on every message. return lux_tts.encode_prompt(audio_prompt, duration=ref_duration, rms=rms) # Warm the cache at startup so the very first real request doesn't # also have to pay the reference-encoding cost. try: get_encoded_prompt("me.wav", 5, 0.01) print("Reference prompt pre-encoded and cached.") except Exception as e: print(f"Could not pre-warm reference prompt cache: {e}") def run_tts( text, audio_prompt=None, rms=0.01, ref_duration=5, t_shift=0.9, num_steps=4, speed=0.8, return_smooth=False, ): # Shared core used by both the Gradio UI (infer) and the /speak # FastAPI route, so there's one code path for actual generation. if not audio_prompt: audio_prompt = "me.wav" start_time = time.time() encoded_prompt = get_encoded_prompt(audio_prompt, ref_duration, rms) final_wav = lux_tts.generate_speech( text, encoded_prompt, num_steps=int(num_steps), t_shift=t_shift, speed=speed, return_smooth=return_smooth, ) duration = round(time.time() - start_time, 2) final_wav = final_wav.cpu().squeeze(0).numpy() final_wav = (np.clip(final_wav, -1.0, 1.0) * 32767).astype(np.int16) return final_wav, duration def wav_bytes_from_array(int16_array, sample_rate=48000): # Wraps a raw int16 PCM array in a proper WAV header, in-memory - # no temp file on disk, so nothing to serve back over a second # HTTP round trip. buf = io.BytesIO() with wave.open(buf, "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(sample_rate) wf.writeframes(int16_array.tobytes()) return buf.getvalue() def compress_to_aac(wav_bytes, bitrate="40k"): # AAC-in-fragmented-MP4 instead of Opus-in-Ogg: Safari/iOS/WebKit # never added Ogg container support (confirmed still true as of # 2026), so Opus playback silently fails on iPad/iPhone even # though it works fine on Chrome (laptop, Android). AAC in MP4 # plays everywhere - Safari, Chrome, Firefox, Android - with one # code path instead of detecting device/browser and branching. # "frag_keyframe+empty_moov+default_base_moof" produces a # streamable fragmented MP4 that doesn't need to seek back to # write a moov atom at the end, since we're writing to a pipe. # Runs entirely via pipes, no temp files. proc = subprocess.run( [ "ffmpeg", "-i", "pipe:0", "-c:a", "aac", "-b:a", bitrate, "-vn", "-movflags", "frag_keyframe+empty_moov+default_base_moof", "-f", "mp4", "pipe:1", ], input=wav_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, ) return proc.stdout def infer( text, audio_prompt, rms, ref_duration, t_shift, num_steps, speed, return_smooth, ): if not text: return None, "Please provide text." final_wav, duration = run_tts( text, audio_prompt, rms, ref_duration, t_shift, num_steps, speed, return_smooth, ) stats_msg = f"✨ Generation complete in **{duration}s**." return (48000, final_wav), stats_msg with gr.Blocks(theme=gr.themes.Soft()) as demo: gr.Markdown("# 🎙️ Papa Jerry Voice (LuxTTS)") gr.Markdown( """ > **Note:** This demo runs on a **2-core CPU**, so expect slower inference. > **Tip:** If words get cut off, lower **Speed** or increase **Ref Duration**. """ ) with gr.Row(): with gr.Column(): input_text = gr.Textbox( label="Text to Synthesize", value="Hey, what's up? I'm feeling really great!", ) input_audio = gr.Audio( label="Reference Audio (.wav)", type="filepath", value="me.wav", ) with gr.Row(): rms_val = gr.Number(value=0.01, label="RMS (Loudness)") ref_duration_val = gr.Number( value=5, label="Reference Duration (sec)", info="Lower = faster. Set ~1000 if you hear artifacts.", ) t_shift_val = gr.Number(value=0.9, label="T-Shift") with gr.Row(): steps_val = gr.Slider(1, 10, value=4, step=1, label="Num Steps") speed_val = gr.Slider( 0.5, 2.0, value=0.8, step=0.1, label="Speed (Lower = Longer / Clearer)", ) smooth_val = gr.Checkbox(label="Return Smooth", value=False) btn = gr.Button("Generate Speech", variant="primary") with gr.Column(): audio_out = gr.Audio(label="Result") status_text = gr.Markdown("Ready to generate...") btn.click( fn=infer, inputs=[ input_text, input_audio, rms_val, ref_duration_val, t_shift_val, steps_val, speed_val, smooth_val, ], outputs=[audio_out, status_text], api_name="predict", ) app = FastAPI() @app.get("/") def read_index(): return FileResponse("index.html") @app.get("/speak") def speak(text: str): # GET + query param (instead of POST + JSON body) so the browser's #