jcode/skills/speech-synthesis/tts.py

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())