File size: 21,332 Bytes
3a34d7a | 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 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 | """
generate_sanskrit_v2.py โ Authentic Sanskrit Chanting TTS via IndicF5
=====================================================================
Architecture:
Sanskrit Devanagari
โ Vagdhenu prep_text.py (Devanagari โ Kannada script routing)
โ IndicF5 DiT (flow-matching, 337M params, zero-shot voice cloning)
โ Reference Audio (Vagdhenu reference_bank/*.wav โ real Sanskrit chanting)
โ Vocos Vocoder (mel โ 24kHz audio)
โ Gate + Stitch (Vagdhenu-style head/tail trimming, inter-pada silence)
โ Output WAV
Why Kannada routing?
Devanagari triggers Hindi schwa-deletion in IndicF5's frontend.
Routing through Kannada orthography suppresses that, giving true
Sanskrit phonetics โ every inherent short 'a' is preserved.
Why reference audio?
IndicF5 is a zero-shot voice cloner. The "authentic Indian chanting touch"
is entirely sourced from the 5-12 second reference WAV clips in
vagdhenu/src/reference_bank/, which are recordings of Prof. Prathosh
(IISc) chanting Sanskrit in traditional pฤrฤyaแนa style. The model
clones voice timbre, swara (pitch contour), pace, and chanting rhythm.
CPU Optimization:
Default nfe_step=12 instead of 64 gives ~5x speedup with minimal
quality loss. At 12 steps, a 10-second verse takes ~40-60s on CPU.
Usage:
python generate_sanskrit_v2.py "เคงเคฐเฅเคฎเคเฅเคทเฅเคคเฅเคฐเฅ เคเฅเคฐเฅเคเฅเคทเฅเคคเฅเคฐเฅ เคธเคฎเคตเฅเคคเคพ เคฏเฅเคฏเฅเคคเฅเคธเคตเค"
python generate_sanskrit_v2.py --text "..." --meter anushtubh --output out.wav
python generate_sanskrit_v2.py --text "..." --nfe 16 --voice-model vagdhenu
Credits:
Vagdhenu (github.com/prathoshap/vagdhenu) โ Prof. Prathosh, IISc
IndicF5 (github.com/ai4bharat/IndicF5) โ AI4Bharat
Kokoro (github.com/hexgrad/kokoro) โ hexgrad
"""
import os
import sys
import json
import time
import argparse
import numpy as np
import torch
# Force UTF-8 output on Windows (prevents cp1252 UnicodeEncodeError for Devanagari/macrons)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
# โโ Repo-relative path setup (must come first) โโโโโโโโโโโโโโโโโโโโโโโโโโโ
HERE = os.path.dirname(os.path.abspath(__file__))
VAGDHENU_SRC = os.path.join(HERE, "vagdhenu", "src")
BANK_DIR = os.path.join(VAGDHENU_SRC, "reference_bank")
BANK_JSON = os.path.join(BANK_DIR, "bank.json")
VOCAB_FILE = os.path.join(BANK_DIR, "vocab.txt")
# Add vagdhenu/src to path so we can import its modules directly
if VAGDHENU_SRC not in sys.path:
sys.path.insert(0, VAGDHENU_SRC)
# Import Vagdhenu's SanskritโKannada text converter (module-level so IDEs can resolve it)
import prep_text as PT # noqa: E402 (sys.path must be extended first)
# โโ Load .env file (HF token, etc.) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(HERE, '.env'))
except ImportError:
pass # python-dotenv optional โ set HF_TOKEN as system env var instead
# Inject HF_TOKEN so huggingface_hub picks it up automatically
_hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HF_API_KEYS")
if _hf_token:
os.environ["HF_TOKEN"] = _hf_token
os.environ["HUGGING_FACE_HUB_TOKEN"] = _hf_token
# โโ Constants โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
SR = 24_000
FALLBACK_METER = "vasantatilaka"
# IndicF5 DiT architecture (must match the pre-trained weights exactly)
DIT_CFG = dict(dim=1024, depth=22, heads=16, ff_mult=2, text_dim=512, conv_layers=4)
# โโ Phonology helpers (ported verbatim from Vagdhenu render_core.py) โโโโโโโโโโ
def _n_aksharas(s: str) -> int:
"""Count the number of syllables (akแนฃaras) in a Devanagari or Kannada string."""
n = 0
L = len(s)
for i, c in enumerate(s):
o = ord(c)
indep = (0x0905 <= o <= 0x0914) or (0x0C85 <= o <= 0x0C94)
cons = (0x0915 <= o <= 0x0939) or (0x0C95 <= o <= 0x0CB9)
if indep:
n += 1
elif cons:
nxt = s[i + 1] if i + 1 < L else ""
if nxt not in ("เฅ", "เณ"):
n += 1
return n
def _ends_halant(txt: str) -> bool:
"""True if the text ends with a virama (halant consonant cluster closer)."""
t = txt.rstrip(" เฅคเฅฅ|.,;:!?โ")
return bool(t) and t[-1] in "เฅเณ"
def _gate(au: np.ndarray,
voice: float = 0.08, sil: float = 0.012,
fin: float = 0.015, fout: float = 0.040,
lead: float = 0.03, keep: float = 0.06,
fric: bool = False, halant: bool = False) -> np.ndarray:
"""
Trim head and tail silence from synthesized audio (Vagdhenu gate function).
Applies a short fade-in and cosine fade-out for clean transitions.
"""
win = int(0.02 * SR)
r = [float(np.sqrt((au[i:i + win] ** 2).mean()))
for i in range(0, len(au) - win, win)]
n = len(r)
if n == 0:
return au
vs = next((i for i in range(n - 1) if r[i] > voice and r[i + 1] > sil),
int(np.argmax(r)))
s = vs
while s > 0 and r[s - 1] > sil:
s -= 1
ve_thr = 0.012 if halant else 0.035
ve = max((i for i in range(n) if r[i] > ve_thr), default=vs)
keep_s = 0.12 if halant else keep
start = max(0, s * win - int(lead * SR))
end = min(len(au), ve * win + int(keep_s * SR))
out = au[start:end].copy()
fi = 0 if fric else int(fin * SR)
fo = int((0.018 if halant else fout) * SR)
if fi and len(out) > fi:
out[:fi] *= np.linspace(0, 1, fi)
if fo and len(out) > fo:
out[-fo:] *= (np.cos(np.linspace(0, np.pi, fo)) * 0.5 + 0.5)
return out
def _split_padas(text: str) -> list[str]:
"""
Split a Sanskrit ลloka into hemistich/pฤda segments.
Splits on newlines first, then on daแนแธas (เฅค). Empty segments are dropped.
"""
pieces = []
for line in text.replace("เฅฅ", "เฅค").replace("|", "เฅค").splitlines():
for seg in line.split("เฅค"):
seg = seg.strip()
if seg:
pieces.append(seg)
return pieces or ([text.strip()] if text.strip() else [])
# โโ Model class โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
class SanskritChantEngine:
"""
Full pipeline for authentic Sanskrit chanting TTS.
On construction, it:
1. Loads the IndicF5 DiT model (downloaded from HuggingFace or a local path)
2. Loads the Vocos vocoder
3. Parses the Vagdhenu reference bank (bank.json)
synthesize() then:
1. Splits text into pฤdas
2. Converts each pฤda from Devanagari โ Kannada via prep_text.py
3. Looks up the per-meter reference WAV + text from bank.json
4. Runs IndicF5 inference at nfe_step (default 12) per pฤda
5. Gates each segment and stitches with silence gaps
6. Returns (sample_rate, audio_float32)
"""
def __init__(
self,
voice_model: str = "indicf5", # "indicf5" or "vagdhenu"
device: str = "cpu",
nfe_step: int = 12,
cfg_strength:float= 3.0,
speed: float= 0.90,
gap: float= 0.55,
gap_halant: float= 0.20,
):
try:
from f5_tts.infer.utils_infer import load_model, load_vocoder
from f5_tts.model import DiT
except ImportError:
raise ImportError(
"\n[EdgeSanskrit] IndicF5 is not installed.\n"
"Run: pip install git+https://github.com/ai4bharat/IndicF5.git"
"@13f7c4d627cc10111aea8fe9c0039462cacacdc7\n"
)
self.device = device
self.nfe_step = nfe_step
self.cfg_strength = cfg_strength
self.speed = speed
self.gap = gap
self.gap_halant = gap_halant
# โโ 1. Load reference bank โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
if not os.path.exists(BANK_JSON):
raise FileNotFoundError(
f"Vagdhenu reference bank not found at {BANK_JSON}\n"
"Make sure you have cloned vagdhenu/ beside this script:\n"
" git clone https://github.com/prathoshap/vagdhenu vagdhenu"
)
with open(BANK_JSON, encoding="utf-8") as f:
self._bank = json.load(f)
self._lut: dict = {}
for k, v in self._bank.items():
if k.startswith("_") or not isinstance(v, dict) or "wav" not in v:
continue
self._lut[k.lower()] = v
self._lut[v["wav"].replace(".wav", "").lower()] = v
print(f"[EdgeSanskrit] Reference bank loaded: {len(self._lut)//2} meters")
# โโ 2. Load DiT (IndicF5 base or Vagdhenu fine-tune) โโโโโโโโโโโ
if not os.path.exists(VOCAB_FILE):
raise FileNotFoundError(
f"IndicF5 vocab.txt not found at {VOCAB_FILE}\n"
"It ships with the Vagdhenu clone in reference_bank/vocab.txt"
)
print(f"[EdgeSanskrit] Loading IndicF5 DiT on device='{device}'...")
self.cfm = load_model(DiT, DIT_CFG, mel_spec_type="vocos",
vocab_file=VOCAB_FILE, device=device)
if voice_model == "vagdhenu":
self._load_vagdhenu_weights()
else:
self._load_indicf5_weights()
self.cfm.eval()
print("[EdgeSanskrit] DiT loaded.")
# โโ 3. Load Vocos vocoder โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
print("[EdgeSanskrit] Loading Vocos vocoder...")
vocos_local = os.path.join(HERE, "models", "vocos")
if os.path.exists(vocos_local):
from vocos import Vocos
self.vocoder = Vocos.from_pretrained(vocos_local).to(device)
print("[EdgeSanskrit] Loaded Vocos from local bundled models/vocos")
else:
self.vocoder = load_vocoder("vocos")
self._refcache: dict = {}
print("[EdgeSanskrit] Vocos loaded. Engine ready.\n")
# โโ Weight loaders โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
def _load_indicf5_weights(self):
"""Load base IndicF5 weights from local bundle or HuggingFace."""
from safetensors.torch import load_file
local_ckpt = os.path.join(HERE, "models", "IndicF5", "model.safetensors")
if os.path.exists(local_ckpt):
print(f"[EdgeSanskrit] Loading bundled IndicF5 from {local_ckpt}")
ckpt = local_ckpt
else:
from huggingface_hub import hf_hub_download
print("[EdgeSanskrit] Downloading ai4bharat/IndicF5 weights from HuggingFace...")
ckpt = hf_hub_download(repo_id="ai4bharat/IndicF5", filename="model.safetensors")
print(f"[EdgeSanskrit] Checkpoint: {ckpt}")
sd = load_file(ckpt, device="cpu")
# Strip the EMA module prefix that IndicF5 uses during training
sd = {
k.replace("ema_model._orig_mod.", "").replace("ema_model.", ""): v
for k, v in sd.items()
}
missing, unexpected = self.cfm.load_state_dict(sd, strict=False)
if missing:
print(f"[EdgeSanskrit] Missing keys (expected if small): {len(missing)}")
def _load_vagdhenu_weights(self):
"""Download and load Vagdhenu voice-steered checkpoint from prathoshap/vagdhenu."""
from huggingface_hub import hf_hub_download
print("[EdgeSanskrit] Downloading prathoshap/vagdhenu voice-steered weights...")
ckpt = hf_hub_download(repo_id="prathoshap/vagdhenu",
filename="voice_steer_ema.pt")
print(f"[EdgeSanskrit] Checkpoint: {ckpt}")
ck = torch.load(ckpt, map_location="cpu", weights_only=True)
ema = {k.replace("ema_model.", ""): v
for k, v in ck["ema_model_state_dict"].items()
if k not in ("initted", "step")}
self.cfm.load_state_dict(ema, strict=False)
# โโ Reference audio cache โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
def _get_ref(self, meter: str):
"""
Look up reference audio and metadata for a given meter.
Results are cached after the first load.
Returns (ref_audio_path, ref_text, sec_per_syll, ref_duration_s)
"""
from f5_tts.infer.utils_infer import preprocess_ref_audio_text
import torchaudio as ta
key = meter.lower().replace(".wav", "").replace("ฤ", "a").replace("แนญ", "t").replace("ลซ", "u")
# Try exact key first, then normalized version
matched_key = None
for k in self._lut:
if k == key or k.replace("ฤ", "a") == key or meter.lower() in k:
matched_key = k
break
if matched_key is None:
print(f"[EdgeSanskrit] Meter '{meter}' not found in bank โ fallback '{FALLBACK_METER}'")
matched_key = FALLBACK_METER
if matched_key in self._refcache:
return self._refcache[matched_key]
e = self._lut[matched_key]
wav_path = os.path.join(BANK_DIR, e["wav"])
ref_text = e["ref_text"]
sps = float(e.get("sec_per_syll", 0.26))
ref_audio, ref_t = preprocess_ref_audio_text(wav_path, ref_text, clip_short=True)
ra, wav_sr = ta.load(ref_audio)
ref_len = ra.shape[-1] / wav_sr
val = (ref_audio, ref_t, sps, ref_len)
self._refcache[matched_key] = val
print(f"[EdgeSanskrit] Reference: {e['wav']} ({ref_len:.1f}s) โ sec/syl={sps}")
return val
# โโ Main synthesis โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
def synthesize(
self,
text: str,
meter: str = "anushtubh",
seed: int = 60,
speed: float = None,
sps: float = None,
) -> tuple[int, np.ndarray]:
"""
Synthesize Sanskrit Devanagari text with authentic chanting prosody.
Args:
text: Sanskrit text in Devanagari (supports multi-line, daแนแธas)
meter: Vแนtta/meter name from bank.json (e.g. 'anushtubh', 'vasantatilaka')
seed: Random seed for reproducibility
speed: Speed multiplier override (default: self.speed = 0.90)
sps: Seconds-per-syllable override
Returns:
(sample_rate, audio_float32_array)
"""
from f5_tts.infer.utils_infer import infer_process
padas = _split_padas(text)
if not padas:
raise ValueError("Input text is empty after splitting.")
ref_audio, ref_t, ref_sps, ref_len = self._get_ref(meter)
if sps is not None:
ref_sps = float(sps)
spd = float(speed) if speed is not None else self.speed
print(f"[EdgeSanskrit] Synthesizing {len(padas)} pฤda(s) nfe={self.nfe_step} device={self.device}")
# Convert each pada: Devanagari โ Kannada routed text
pieces = [PT.model_text(p) for p in padas]
nsylls = [_n_aksharas(p) for p in pieces]
print(f"[EdgeSanskrit] Pฤdas:")
for i, (orig, kan) in enumerate(zip(padas, pieces)):
print(f" [{i+1}] {orig}")
print(f" โ {kan} ({nsylls[i]} akแนฃaras)")
bseg = []
for i, piece in enumerate(pieces):
print(f"\n[EdgeSanskrit] Generating pฤda {i+1}/{len(pieces)}...")
t0 = time.perf_counter()
au = None
for attempt in range(4):
torch.manual_seed(seed + attempt)
fix_dur = (ref_len + nsylls[i] * ref_sps) if ref_sps > 0 else None
w, sr, _ = infer_process(
ref_audio, ref_t, piece, self.cfm, self.vocoder,
mel_spec_type = "vocos",
speed = spd,
nfe_step = self.nfe_step,
cfg_strength = self.cfg_strength,
device = self.device,
fix_duration = fix_dur,
)
w = np.array(w, dtype=np.float32)
if np.abs(w).max() > 1.5:
w = w / 32768.0
rms = float(np.sqrt((w ** 2).mean()))
elapsed = time.perf_counter() - t0
dur = len(w) / SR
rtf = elapsed / dur if dur > 0 else float("inf")
print(f" attempt {attempt+1}: {len(w)/SR:.2f}s audio, "
f"RMS={rms:.4f}, RTF={rtf:.2f}x, elapsed={elapsed:.1f}s")
if rms > 0.04:
au = w
break
if au is None:
print(f" [WARN] All attempts had low RMS โ using last result anyway.")
au = w
# Vagdhenu-style gate: trim head/tail silence
halant = _ends_halant(pieces[i])
au = _gate(au, halant=halant)
bseg.append(au)
# Stitch pฤdas with silence gaps
gap_samples = int(self.gap * SR)
halant_gap_samples = int(self.gap_halant * SR)
result = []
for i, seg in enumerate(bseg):
result.append(seg)
if i < len(bseg) - 1:
extra = halant_gap_samples if _ends_halant(pieces[i]) else 0
result.append(np.zeros(gap_samples + extra, dtype=np.float32))
final = np.concatenate(result)
# Normalize to 97% peak
mx = np.abs(final).max()
if mx > 0:
final = final / mx * 0.97
print(f"\n[EdgeSanskrit] Final audio: {len(final)/SR:.2f}s")
return SR, final
# โโ CLI โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
def main():
parser = argparse.ArgumentParser(
description="EdgeSanskrit v2 โ Authentic Sanskrit Chanting TTS via IndicF5",
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
"text", nargs="?",
default="เคงเคฐเฅเคฎเคเฅเคทเฅเคคเฅเคฐเฅ เคเฅเคฐเฅเคเฅเคทเฅเคคเฅเคฐเฅ เคธเคฎเคตเฅเคคเคพ เคฏเฅเคฏเฅเคคเฅเคธเคตเค เฅค\nเคฎเคพเคฎเคเคพเค เคชเคพเคฃเฅเคกเคตเคพเคถเฅเคเฅเคต เคเคฟเคฎเคเฅเคฐเฅเคตเคค เคธเคเคเคฏ เฅฅ",
help="Sanskrit Devanagari input text (default: BG 1.1)",
)
parser.add_argument("-o", "--output", default="sanskrit_chant_v2.wav",
help="Output WAV file path")
parser.add_argument("-m", "--meter", default="anushtubh",
help="Sanskrit meter/vแนtta (e.g. anushtubh, vasantatilaka)")
parser.add_argument("--nfe", type=int, default=12,
help="NFE diffusion steps (lower=faster, default=12)")
parser.add_argument("--speed", type=float, default=0.90,
help="Speech rate multiplier (default=0.90 for chant pacing)")
parser.add_argument("--voice-model", default="indicf5",
choices=["indicf5", "vagdhenu"],
help="'indicf5' = base zero-shot model (default)\n"
"'vagdhenu' = Prof. Prathosh's voice-steered model (~5GB)")
parser.add_argument("--seed", type=int, default=60,
help="Random seed (default=60)")
args = parser.parse_args()
import soundfile as sf
engine = SanskritChantEngine(
voice_model = args.voice_model,
device = "cpu",
nfe_step = args.nfe,
speed = args.speed,
)
t_start = time.perf_counter()
sr, audio = engine.synthesize(
text = args.text,
meter = args.meter,
seed = args.seed,
)
total_elapsed = time.perf_counter() - t_start
audio_dur = len(audio) / sr
rtf = total_elapsed / audio_dur if audio_dur > 0 else float("inf")
sf.write(args.output, audio, sr, subtype="PCM_16")
print(f"\n{'='*60}")
print(f" Output : {args.output}")
print(f" Audio : {audio_dur:.2f}s at {sr}Hz")
print(f" Elapsed: {total_elapsed:.1f}s")
print(f" RTF : {rtf:.2f}x ({'faster' if rtf < 1 else 'slower'} than real-time)")
print(f"{'='*60}\n")
if __name__ == "__main__":
main()
|