"):
out.append(part)
else:
out.append(re.sub(r"\s+\+\s+", repl_space, part))
return "".join(out)
def prepare_text_for_synthesis(text: str, lang: str) -> str:
text = normalize_common_text(text)
text = strip_hebrew_abbreviation_quotes(text, lang)
text = normalize_phonetic_geresh(text, lang)
text = expand_geresh_loanwords(text, lang)
text = expand_dialogue_quotes(text, lang)
text = strip_hebrew_inword_hyphens(text, lang)
text = expand_hebrew_lamed_before_latin(text, lang)
text = expand_alphanumeric_codes(text, lang=lang)
text = expand_list_markers(text, lang=lang)
text = expand_plus_sign(text, lang=lang)
text = expand_phone_numbers(text, lang=lang)
text = expand_times(text, lang=lang)
text = expand_dates(text, lang=lang)
text = expand_percent_symbols(text, lang=lang)
text = expand_ratios(text, lang=lang)
text = expand_numbers(text, lang=lang)
return strip_silent_separator_tokens(text)
def normalize_generated_audio(wav: np.ndarray, target_rms: float = 0.08, peak_limit: float = 0.95) -> np.ndarray:
"""Keep generated audio in a safe playback range without hard clipping."""
wav = np.asarray(wav, dtype=np.float32)
if wav.size == 0 or not np.isfinite(wav).all():
return wav
peak = float(np.max(np.abs(wav)))
if peak < 1e-6:
return wav
active = np.abs(wav) > max(peak * 0.02, 1e-4)
samples = wav[active] if np.any(active) else wav
rms = float(np.sqrt(np.mean(np.square(samples))))
if rms < 1e-6:
return wav
# Cap boosts for quiet output and attenuate loud output before Gradio/browser
# conversion. Leaving samples above ±1.0 causes hard clipping and audible
# metallic distortion.
gain = min(target_rms / rms, peak_limit / peak, 4.0)
if np.isclose(gain, 1.0):
return wav
return (wav * gain).astype(np.float32)
# Cache of styles derived from uploaded reference WAVs, keyed by file hash.
_REF_WAV_CACHE: Dict[str, Style] = {}
def _hash_file(path: str) -> str:
import hashlib
h = hashlib.sha1()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 16), b""):
h.update(chunk)
return h.hexdigest()
def _env_truthy(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def _pt_marker_ok(marker_path: str, repo_id: str, stamp: str) -> bool:
if not os.path.exists(marker_path):
return False
try:
lines = open(marker_path, encoding="utf-8").read().splitlines()
except OSError:
return False
if len(lines) < 2:
return False
return lines[0].strip() == repo_id and lines[1].strip() == stamp
def _ensure_pt_weights() -> dict[str, str]:
"""Make sure v2 PyTorch/safetensors checkpoints are on disk."""
repo_id = os.environ.get("BLUE_PT_REPO", "notmax123/blue-v2")
stamp = os.environ.get("BLUE_PT_BUNDLE_STAMP", "1")
marker = os.path.join("pt_weights", ".repo_id")
force = _env_truthy("BLUE_PT_FORCE_DOWNLOAD") or not _pt_marker_ok(marker, repo_id, stamp)
needed: dict[str, Optional[str]] = {k: _find_pt_weight(v) for k, v in PT_WEIGHT_ALIASES.items()}
if force or any(v is None for v in needed.values()):
from huggingface_hub import hf_hub_download
import shutil
os.makedirs("pt_weights", exist_ok=True)
for fn in ("blue_codec.safetensors", "duration_predictor_final.safetensors",
"vf_estimetor.safetensors", "stats_multilingual.safetensors"):
dest = os.path.join("pt_weights", fn)
print(f"[INFO] Fetching {repo_id}/{fn} …")
cached = hf_hub_download(
repo_id=repo_id, filename=fn, repo_type="model",
token=os.environ.get("HF_TOKEN") or None,
force_download=force,
)
shutil.copy2(cached, dest)
with open(marker, "w", encoding="utf-8") as f:
f.write(repo_id + "\n" + stamp + "\n")
needed = {k: _find_pt_weight(v) for k, v in PT_WEIGHT_ALIASES.items()}
assert all(v is not None for v in needed.values()), f"still missing: {needed}"
return {k: v for k, v in needed.items() if v is not None} # type: ignore[misc]
def style_from_wav(ref_wav: str) -> Style:
"""Derive a voice Style from a reference WAV using export_new_voice.py."""
ckpts = _ensure_pt_weights()
from export_new_voice import export_voice_style
payload = export_voice_style(
ref_wav,
config=CONFIG_PATH,
ae_ckpt=ckpts["ae_ckpt"],
ttl_ckpt=ckpts["ttl_ckpt"],
dp_ckpt=ckpts["dp_ckpt"],
stats=ckpts["stats"],
device="cpu",
)
return style_from_dict(payload)
def _reference_audio_status(ref_wav: Optional[str]):
if not ref_wav:
return (
'No reference uploaded — '
'using the saved voice above. Upload or record a clip to clone a custom voice.
'
)
try:
import soundfile as sf
info = sf.info(ref_wav)
dur = float(info.frames) / float(info.samplerate or 1)
channels = int(info.channels or 1)
if dur < 2.0:
level = "warn"
msg = "Too short for cloning; use at least 3 seconds."
elif dur > 20.0:
level = "warn"
msg = "Long clips work, but only the early frames are used. Trim to the cleanest 3-12 seconds."
elif channels > 2:
level = "warn"
msg = "Many channels detected; mono or stereo speech works best."
else:
level = "ok"
try:
cached = _hash_file(ref_wav) in _REF_WAV_CACHE
except Exception:
cached = False
if cached:
msg = "Cloned voice cached — next generation will be fast."
else:
msg = "Ready. First generation exports the voice (~20-40s); subsequent ones are instant."
return (
f''
f'Reference: {dur:.1f}s, {info.samplerate} Hz, {channels} channel(s). {html.escape(msg)}'
'
'
)
except Exception as e:
return f'Could not inspect uploaded audio: {html.escape(str(e))}
'
def synthesize_text(text: str, voice: str, lang: str, steps: int, speed: float,
ref_wav: Optional[str] = None,
progress: "gr.Progress | None" = gr.Progress()):
t0 = time.time()
using_ref = bool(ref_wav)
export_time = 0.0
if using_ref:
try:
cache_key = _hash_file(ref_wav)
if cache_key in _REF_WAV_CACHE:
if progress is not None:
progress(0.9, desc="Using cached cloned voice")
style = _REF_WAV_CACHE[cache_key]
else:
if progress is not None:
progress(
0.05,
desc="Exporting cloned voice (first time ~20-40s, cached after)",
)
t_exp = time.time()
style = style_from_wav(ref_wav)
export_time = time.time() - t_exp
_REF_WAV_CACHE[cache_key] = style
if progress is not None:
progress(0.6, desc="Synthesizing speech")
except Exception as e:
err = f'❌ voice clone failed: {e}
'
return None, err
else:
if not VOICE_STYLES:
err = (
''
'No saved voices installed. Upload a reference clip to clone a voice.
'
)
return None, err
style = VOICE_STYLES[voice]
try:
wav, sr = TTS.synthesize(
prepare_text_for_synthesis(text, lang=lang), lang=lang, style=style,
total_step=int(steps), speed=float(speed), cfg_scale=4.0,
pace_blend=None,
)
wav = normalize_generated_audio(np.asarray(wav).squeeze())
except Exception as e: # never surface a raw 500 to the UI for odd inputs
msg = (
''
f'❌ synthesis failed: {html.escape(str(e))}
'
)
return None, msg
if wav.size == 0:
msg = (
''
'No speakable text detected — try adding words or letters.
'
)
return None, msg
proc_time = time.time() - t0
audio_dur = len(wav) / sr if len(wav) > 0 else 0.0
rtf = proc_time / audio_dur if audio_dur > 0 else 0
export_pill = (
f'🧬 clone export {export_time:.1f}s'
if using_ref and export_time > 0 else ''
)
stats = (
f''
f'Voice: {"cloned from upload" if using_ref else html.escape(voice)}'
f'{export_pill}'
f'⏱ {proc_time:.2f}s'
f'🔊 {audio_dur:.1f}s audio'
f'⚡ {rtf:.2f}x RTF'
f'
'
)
return (sr, wav), stats
def phonemes_for_display(text: str, lang: str) -> str:
"""Return user-facing phonemes without internal routing tags."""
prepared = strip_reference_code_markers(prepare_text_for_synthesis(text, lang=lang))
tagged = TTS.g2p.phonemize(prepared, lang=lang)
return strip_language_tags_for_display(tagged)
# ============================================================
# Voice-clone tab
# ============================================================
# Accept checkpoints from a handful of common locations (with the filename
# variants we've seen in the wild) so the clone tab works out of the box.
PT_WEIGHTS_SEARCH = [
"pt_weights",
"pt_models",
os.path.join("fonts", "pt_models"),
]
PT_WEIGHT_ALIASES: dict[str, list[str]] = {
"ae_ckpt": ["blue_codec.safetensors"],
"ttl_ckpt": ["vf_estimetor.safetensors"],
"dp_ckpt": ["duration_predictor_final.safetensors"],
"stats": ["stats_multilingual.safetensors"],
}
def _find_pt_weight(aliases: list[str]) -> Optional[str]:
for d in PT_WEIGHTS_SEARCH:
for name in aliases:
p = os.path.join(d, name)
if os.path.exists(p):
return p
return None
def _refresh_voices() -> None:
global VOICES, VOICE_STYLES
VOICES = discover_voices()
VOICE_STYLES = {name: load_voice_style([path]) for name, path in VOICES.items()}
def clone_voice(ref_wav: Optional[str], voice_name: str):
"""Export a new voice JSON from a reference WAV."""
if not ref_wav:
return "Please upload a reference WAV first.", gr.update()
if not voice_name.strip():
voice_name = f"custom_{int(time.time())}"
safe = re.sub(r"[^\w\-]+", "_", voice_name.strip())
out_path = os.path.join(VOICES_DIR, f"{safe}.json")
needed = _ensure_pt_weights()
from export_new_voice import export_voice_style
payload = export_voice_style(
ref_wav,
config=CONFIG_PATH,
ae_ckpt=needed["ae_ckpt"],
ttl_ckpt=needed["ttl_ckpt"],
dp_ckpt=needed["dp_ckpt"],
stats=needed["stats"],
device="cpu",
)
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
with open(out_path, "w") as f:
json.dump(payload, f)
_refresh_voices()
pretty = safe.replace("_", " ").title()
return (
f"Saved {out_path}. New voice '{pretty}' is now selectable in the Synthesize tab.",
gr.update(choices=list(VOICES.keys())),
)
# ============================================================
# Gradio UI (styling retained from previous version)
# ============================================================
EXAMPLES = [
["The power to change begins the moment you believe it's possible!", "en"],
["הכוח לשנות מתחיל ברגע שבו אתה מאמין שזה אפשרי!", "he"],
["¡El poder de cambiar comienza en el momento en que crees que es posible!", "es"],
["Il potere di cambiare inizia nel momento in cui credi che sia possibile!", "it"],
["Die Kraft zur Veränderung beginnt in dem Moment, in dem du glaubst, dass es möglich ist!", "de"],
]
def _load_font_face() -> str:
p = "fonts/EuclidCircularB.woff2"
if os.path.exists(p):
b64 = base64.b64encode(open(p, "rb").read()).decode()
return (
f"@font-face {{ font-family: 'EuclidCircularB'; "
f"src: url(data:font/woff2;base64,{b64}) format('woff2'); "
f"font-weight: 100 900; font-style: normal; }}"
)
return ""
css = _load_font_face() + """
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap');
* { box-sizing: border-box; }
body, .gradio-container { background:#06101f !important; font-family:'EuclidCircularB',sans-serif !important; color:#e6efff !important; }
.gradio-container { max-width:900px !important; margin:0 auto !important; padding:2rem 1.5rem !important; }
.app-header { text-align:center; margin-bottom:2rem; padding:2rem 0 1rem; }
.app-header h1 { font-size:2.8rem; font-weight:600; letter-spacing:-0.03em; background:linear-gradient(135deg,#38bdf8 0%,#3b82f6 50%,#1d4ed8 100%); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; margin:0 0 0.5rem; }
.app-header p { color:#7ea3d4; font-size:1rem; margin:0 0 1rem; }
.app-header .github-link { display:inline-flex; align-items:center; gap:0.4rem; margin-top:0.75rem; padding:0.45rem 1rem; font-size:0.9rem; font-weight:500; text-decoration:none !important; color:#93c5fd !important; border:1px solid #1e40af; border-radius:999px; background:rgba(59,130,246,0.12); }
.card { background:#0b1a30; border:1px solid #163056; border-radius:16px; padding:1.5rem; margin-bottom:1rem; }
.big-input textarea { background:#081327 !important; border:1px solid #1e3a66 !important; border-radius:10px !important; color:#e6efff !important; font-size:1.1rem !important; line-height:1.6 !important; padding:1rem !important; unicode-bidi:plaintext !important; }
.big-input textarea:focus { border-color:#3b82f6 !important; outline:none !important; box-shadow:0 0 0 3px rgba(59,130,246,0.18) !important; }
.controls-row { margin-top:1rem; display:flex !important; flex-direction:column !important; gap:0.75rem !important; }
.ctrl-row1, .ctrl-row2, .ctrl-row3 { display:flex !important; flex-direction:row !important; gap:0.75rem !important; width:100% !important; }
.ctrl-lang { flex:2 !important; min-width:0 !important; } .ctrl-voice { flex:3 !important; min-width:0 !important; }
.ctrl-steps, .ctrl-speed { flex:1 !important; min-width:0 !important; }
.gen-btn { background:linear-gradient(135deg,#2563eb,#1d4ed8) !important; border:none !important; border-radius:10px !important; color:#fff !important; font-size:1rem !important; font-weight:600 !important; padding:0.75rem 2rem !important; width:100% !important; margin-top:1rem !important; box-shadow:0 6px 18px rgba(37,99,235,0.35) !important; }
.gen-btn:hover { opacity:0.9 !important; filter:brightness(1.05); }
.gradio-audio { background:#0b1a30 !important; border:1px solid #163056 !important; border-radius:12px !important; }
.stats-bar { display:flex; gap:0.75rem; flex-wrap:wrap; margin-top:0.75rem; padding:0.75rem 0; }
.stat-pill { background:#0e2545; border:1px solid #1e40af; border-radius:20px; padding:0.3rem 0.9rem; font-family:'JetBrains Mono',monospace; font-size:0.8rem; color:#93c5fd; }
.gradio-dropdown select, .gradio-dropdown input { background:#081327 !important; border:1px solid #1e3a66 !important; color:#e6efff !important; border-radius:8px !important; }
.ref-panel { margin-top:1rem; padding:1rem; border:1px dashed #1e40af; border-radius:12px; background:#091a34; }
.ref-panel label { color:#bfdbfe !important; }
.ref-panel h3 { color:#dbeafe; margin:0 0 0.25rem; font-size:1rem; font-weight:600; }
.ref-status { margin-top:0.6rem; padding:0.75rem 0.9rem; border-radius:10px; font-size:0.9rem; line-height:1.4; }
.ref-status.ok { color:#bae6fd; background:rgba(14,165,233,0.12); border:1px solid rgba(14,165,233,0.35); }
.ref-status.warn { color:#fde68a; background:rgba(245,158,11,0.10); border:1px solid rgba(245,158,11,0.25); }
.ref-status.muted { color:#93a6c4; background:rgba(59,130,246,0.08); border:1px solid rgba(59,130,246,0.20); }
.ref-help { color:#7ea3d4; font-size:0.86rem; line-height:1.45; margin-top:0.5rem; }
"""
with gr.Blocks(title="BlueTTS V3 — Multilingual TTS") as demo:
gr.HTML(
''
)
with gr.Column(elem_classes="card"):
text_input = gr.Textbox(
label="Text", placeholder="Type or paste text here…",
lines=4, elem_classes="big-input",
value="Great ideas become real when a small team keeps building every single day.",
)
with gr.Column(elem_classes="controls-row"):
with gr.Row(elem_classes="ctrl-row1"):
lang_input = gr.Dropdown(
choices=[("English 🇺🇸", "en"), ("Hebrew 🇮🇱", "he"),
("Spanish 🇪🇸", "es"), ("German 🇩🇪", "de"),
("Italian 🇮🇹", "it")],
value="en", label="Language", elem_classes="ctrl-lang",
)
voice_input = gr.Dropdown(
choices=list(VOICES.keys()),
value=next(iter(VOICES.keys()), None),
label="Voice", elem_classes="ctrl-voice",
)
with gr.Row(elem_classes="ctrl-row2"):
steps_input = gr.Slider(5, 16, 8, step=1, label="Quality (steps)", elem_classes="ctrl-steps")
speed_input = gr.Slider(0.8, 1.4, 1.2, step=0.05, label="Speed", elem_classes="ctrl-speed")
with gr.Column(elem_classes="ref-panel"):
gr.HTML(
'Clone a voice (optional)
'
'Upload or record 3-12 seconds of clean speech to clone it. '
'Leave empty to use the saved voice selected above. Generation starts automatically when you upload. '
'Heads up: the first sentence with a new clone takes ~20-40s to export the voice — after that, regeneration is instant.
'
)
ref_wav_input = gr.Audio(
label="Reference audio",
sources=["upload", "microphone"], type="filepath",
)
ref_status = gr.HTML(_reference_audio_status(None))
btn = gr.Button("⚡ Generate Speech", elem_classes="gen-btn")
audio_out = gr.Audio(label="Output", type="numpy", autoplay=True)
stats_out = gr.HTML()
gr.Examples(examples=EXAMPLES, inputs=[text_input, lang_input], label="Examples")
synth_inputs = [text_input, voice_input, lang_input, steps_input, speed_input, ref_wav_input]
synth_outputs = [audio_out, stats_out]
def _auto_synth(text, voice, lang, steps, speed, ref_wav):
if not ref_wav:
return gr.update(), gr.update()
return synthesize_text(text, voice, lang, steps, speed, ref_wav)
ref_wav_input.change(
_reference_audio_status,
inputs=[ref_wav_input],
outputs=[ref_status],
).then(
_auto_synth,
inputs=synth_inputs,
outputs=synth_outputs,
)
btn.click(
synthesize_text,
inputs=synth_inputs,
outputs=synth_outputs,
)
gr.HTML("""
""")
if __name__ == "__main__":
demo.launch(theme=gr.themes.Base(), css=css)