84 lines
4.4 KiB
Python
84 lines
4.4 KiB
Python
#!/usr/bin/env python3
|
|
"""tts.py - text to speech with Kokoro-82M (open weights, Apache 2.0).
|
|
|
|
python tts.py "Hello, this is a test." --out hello.wav # female, American English
|
|
python tts.py "Hello." --voice am_michael --out hello_m.wav # male
|
|
python tts.py --file chapter.txt --voice bf_emma --lang b --out chapter.wav
|
|
python tts.py "Mixed." --voice af_heart,am_michael --out mix.wav # blend of two voices
|
|
python tts.py --list-voices
|
|
|
|
Setup (once): pip install kokoro soundfile (downloads ~330 MB of weights on first run)
|
|
Languages other than English need: apt/brew install espeak-ng
|
|
Output: 24 kHz mono 16-bit WAV. Long texts are split at blank lines / sentences,
|
|
synthesized chunk by chunk and concatenated, so there is no length limit.
|
|
"""
|
|
import argparse, sys, time
|
|
|
|
# Kokoro-82M v1.0 voices (prefix: a/b = American/British English, f/m = female/male)
|
|
VOICES = {
|
|
"female American": ["af_heart", "af_bella", "af_nicole", "af_sarah", "af_sky", "af_aoede", "af_kore", "af_nova", "af_river", "af_jessica", "af_alloy"],
|
|
"male American": ["am_michael", "am_adam", "am_fenrir", "am_puck", "am_echo", "am_eric", "am_liam", "am_onyx", "am_santa"],
|
|
"female British": ["bf_emma", "bf_isabella", "bf_alice", "bf_lily"],
|
|
"male British": ["bm_george", "bm_lewis", "bm_daniel", "bm_fable"],
|
|
"other languages": ["ef_dora em_alex (es)", "ff_siwis (fr)", "hf_alpha hm_omega (hi)", "if_sara im_nicola (it)",
|
|
"jf_alpha jm_kumo (ja)", "pf_dora pm_alex (pt-br)", "zf_xiaobei zm_yunjian (zh)"],
|
|
}
|
|
LANGS = {"a": "American English", "b": "British English", "e": "Spanish", "f": "French", "h": "Hindi",
|
|
"i": "Italian", "j": "Japanese", "p": "Portuguese (BR)", "z": "Mandarin"} # no German in Kokoro-82M v1.0
|
|
|
|
|
|
def synthesize(text: str, voice: str = "af_heart", lang: str = "a", speed: float = 1.0):
|
|
"""Returns (samples: float32 numpy array, sample_rate=24000). Loads the model on first call."""
|
|
import numpy as np
|
|
from kokoro import KPipeline # heavy import (torch); keep it inside the function
|
|
global _PIPELINES
|
|
try:
|
|
_PIPELINES
|
|
except NameError:
|
|
_PIPELINES = {}
|
|
if lang not in _PIPELINES:
|
|
_PIPELINES[lang] = KPipeline(lang_code=lang)
|
|
chunks = []
|
|
# the pipeline yields (graphemes, phonemes, audio) per text chunk; split_pattern decides the chunks
|
|
for _graphemes, _phonemes, audio in _PIPELINES[lang](text, voice=voice, speed=speed, split_pattern=r"\n+|(?<=[.!?])\s+"):
|
|
if audio is not None:
|
|
chunks.append(audio.numpy() if hasattr(audio, "numpy") else np.asarray(audio))
|
|
chunks.append(np.zeros(int(24000 * 0.25), dtype=np.float32)) # 250 ms pause between chunks
|
|
if not chunks:
|
|
raise ValueError("no audio produced - empty text?")
|
|
return np.concatenate(chunks).astype(np.float32), 24000
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("text", nargs="?", help="text to speak (or use --file)")
|
|
ap.add_argument("--file", help="read the text from this file")
|
|
ap.add_argument("--voice", default="af_heart", help="voice name or 'name1,name2' blend (default af_heart = female)")
|
|
ap.add_argument("--lang", default="a", choices=LANGS, help="language/G2P: a=American, b=British English, ...")
|
|
ap.add_argument("--speed", type=float, default=1.0, help="0.5 .. 2.0")
|
|
ap.add_argument("--out", default="out.wav", help="WAV file to write")
|
|
ap.add_argument("--play", action="store_true", help="also play it (needs 'pip install sounddevice')")
|
|
ap.add_argument("--list-voices", action="store_true")
|
|
a = ap.parse_args()
|
|
if a.list_voices:
|
|
for group, names in VOICES.items():
|
|
print(f"{group:17s} {' '.join(names)}")
|
|
print("languages:", ", ".join(f"{k}={v}" for k, v in LANGS.items()))
|
|
return 0
|
|
text = open(a.file, encoding="utf-8").read() if a.file else a.text
|
|
if not text or not text.strip():
|
|
ap.error("give a text or --file")
|
|
t0 = time.time()
|
|
samples, sr = synthesize(text, a.voice, a.lang, a.speed)
|
|
import soundfile as sf
|
|
sf.write(a.out, samples, sr)
|
|
print(f"wrote {a.out}: {len(samples) / sr:.1f} s of audio, voice {a.voice}, {time.time() - t0:.1f} s")
|
|
if a.play:
|
|
import sounddevice as sd
|
|
sd.play(samples, sr); sd.wait()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|