eg_autonomous_tts_studio / tts_engine.py
aliSaac510's picture
feat: integrate Supertonic-3 TTS support
081af21
Raw
History Blame Contribute Delete
8.23 kB
import argparse
import asyncio
import wave
from pathlib import Path
from typing import Any
from urllib.request import urlopen
import edge_tts
import numpy as np
from kokoro_onnx import Kokoro
from supertonic import TTS
DEFAULT_TEXT = "Hello. This is the default sample used to validate edge-tts output."
DEFAULT_VOICE = "en-US-AriaNeural"
DEFAULT_KOKORO_VOICE = "af_heart"
DEFAULT_KOKORO_LANG = "en-us"
KOKORO_MODEL_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx"
KOKORO_VOICES_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin"
KOKORO_MODELS_DIR = Path(__file__).resolve().parent / "models" / "kokoro"
KOKORO_MODEL_FILE = KOKORO_MODELS_DIR / "kokoro-v1.0.onnx"
KOKORO_VOICES_FILE = KOKORO_MODELS_DIR / "voices-v1.0.bin"
KOKORO_VOICE_NAMES = [
"af_alloy", "af_aoede", "af_bella", "af_heart", "af_jessica", "af_kore", "af_nicole", "af_nova", "af_river",
"af_sarah", "af_sky", "am_adam", "am_echo", "am_eric", "am_fenrir", "am_liam", "am_michael", "am_onyx",
"am_puck", "am_santa", "bf_alice", "bf_emma", "bf_isabella", "bf_lily", "bm_daniel", "bm_fable", "bm_george",
"bm_lewis", "ef_dora", "em_alex", "em_santa", "ff_siwis", "hf_alpha", "hf_beta", "hm_omega", "hm_psi",
"if_sara", "im_nicola", "jf_alpha", "jf_gongitsune", "jf_nezumi", "jf_tebukuro", "jm_kumo", "pf_dora",
"pm_alex", "pm_santa", "zf_xiaobei", "zf_xiaoni", "zf_xiaoxiao", "zf_xiaoyi", "zm_yunjian", "zm_yunxi",
"zm_yunxia", "zm_yunyang",
]
SUPERTONIC_VOICE_NAMES = [
"F1", "F2", "F3", "F4", "F5",
"M1", "M2", "M3", "M4", "M5",
]
_KOKORO_ENGINES: dict[str, Kokoro] = {}
_SUPERTONIC_ENGINE: TTS | None = None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Edge TTS test utility.")
parser.add_argument("--text", help="Text content to synthesize.")
parser.add_argument("--file", type=Path, help="Load synthesis text from a file.")
parser.add_argument("--voice", default=DEFAULT_VOICE, help="Edge TTS voice name.")
parser.add_argument("--rate", default="+0%", help="Speech rate, for example: +10%%")
parser.add_argument("--volume", default="+0%", help="Speech volume, for example: +0%%")
parser.add_argument("--pitch", default="+0Hz", help="Speech pitch, for example: +0Hz")
parser.add_argument("--output", type=Path, default=Path("output.mp3"), help="Output audio file path.")
parser.add_argument("--list-voices", action="store_true", help="List available voices.")
parser.add_argument("--filter", help="Filter voices by keyword, for example: en-US")
return parser.parse_args()
def load_text(args: argparse.Namespace) -> str:
if args.text:
return args.text.strip()
if args.file:
return args.file.read_text(encoding="utf-8").strip()
return DEFAULT_TEXT
async def get_voices(keyword: str | None = None) -> list[dict[str, Any]]:
voices = await edge_tts.list_voices()
if keyword:
keyword = keyword.lower()
voices = [
voice
for voice in voices
if keyword in voice["ShortName"].lower() or keyword in voice["Locale"].lower()
]
return voices
def _kokoro_group_from_voice(voice_name: str) -> str:
normalized = (voice_name or DEFAULT_KOKORO_VOICE).strip().lower()
return normalized.split("_", 1)[0] if "_" in normalized else "af"
def _download_file(url: str, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.is_file():
return
with urlopen(url, timeout=90) as response:
destination.write_bytes(response.read())
def _ensure_kokoro_engine(voice_name: str = DEFAULT_KOKORO_VOICE) -> Kokoro:
group = _kokoro_group_from_voice(voice_name)
cached = _KOKORO_ENGINES.get(group)
if cached is not None:
return cached
_download_file(KOKORO_MODEL_URL, KOKORO_MODEL_FILE)
_download_file(KOKORO_VOICES_URL, KOKORO_VOICES_FILE)
engine = Kokoro(model_path=str(KOKORO_MODEL_FILE), voices_path=str(KOKORO_VOICES_FILE))
_KOKORO_ENGINES[group] = engine
return engine
def get_kokoro_voices(keyword: str | None = None) -> list[dict[str, Any]]:
names = KOKORO_VOICE_NAMES
voices = [
{
"ShortName": name,
"Locale": "multi",
"Gender": "Unknown",
"FriendlyName": f"Kokoro {name}",
}
for name in names
]
if not keyword:
return voices
needle = keyword.lower()
return [v for v in voices if needle in v["ShortName"].lower()]
def _write_float_audio_to_wav(output: Path, samples: np.ndarray, sample_rate: int) -> None:
pcm = np.clip(samples, -1.0, 1.0)
pcm_int16 = (pcm * 32767.0).astype(np.int16)
with wave.open(str(output), "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
wav_file.writeframes(pcm_int16.tobytes())
async def synthesize_kokoro_to_file(
*,
text: str,
output: Path,
voice: str = DEFAULT_KOKORO_VOICE,
speed: float = 1.0,
lang: str = DEFAULT_KOKORO_LANG,
) -> Path:
if not text:
raise ValueError("Input text cannot be empty.")
output.parent.mkdir(parents=True, exist_ok=True)
engine = _ensure_kokoro_engine(voice)
audio, sample_rate = engine.create(text=text, voice=voice, speed=speed, lang=lang)
_write_float_audio_to_wav(output=output, samples=audio, sample_rate=sample_rate)
return output.resolve()
def prewarm_kokoro() -> None:
_ensure_kokoro_engine(DEFAULT_KOKORO_VOICE)
def _ensure_supertonic_engine() -> TTS:
global _SUPERTONIC_ENGINE
if _SUPERTONIC_ENGINE is None:
_SUPERTONIC_ENGINE = TTS(auto_download=True)
return _SUPERTONIC_ENGINE
def get_supertonic_voices(keyword: str | None = None) -> list[dict[str, Any]]:
names = SUPERTONIC_VOICE_NAMES
voices = [
{
"ShortName": name,
"Locale": "en",
"Gender": "Female" if name.startswith("F") else "Male",
"FriendlyName": f"Supertonic {name}",
}
for name in names
]
if not keyword:
return voices
needle = keyword.lower()
return [v for v in voices if needle in v["ShortName"].lower()]
async def synthesize_supertonic_to_file(
*,
text: str,
output: Path,
voice: str = "M1",
lang: str = "en",
) -> Path:
if not text:
raise ValueError("Input text cannot be empty.")
output.parent.mkdir(parents=True, exist_ok=True)
engine = _ensure_supertonic_engine()
style = engine.get_voice_style(voice_name=voice)
wav, duration = engine.synthesize(text, voice_style=style, lang=lang)
engine.save_audio(wav, str(output))
return output.resolve()
async def list_voices(keyword: str | None) -> None:
voices = await get_voices(keyword)
if not voices:
print("No matching voice was found.")
return
for voice in voices:
print(f'{voice["ShortName"]} | {voice["Locale"]} | {voice["Gender"]}')
async def synthesize_to_file(
*,
text: str,
output: Path,
voice: str = DEFAULT_VOICE,
rate: str = "+0%",
volume: str = "+0%",
pitch: str = "+0Hz",
) -> Path:
if not text:
raise ValueError("Input text cannot be empty.")
output.parent.mkdir(parents=True, exist_ok=True)
communicate = edge_tts.Communicate(
text=text,
voice=voice,
rate=rate,
volume=volume,
pitch=pitch,
)
await communicate.save(str(output))
return output.resolve()
async def generate_tts(args: argparse.Namespace) -> None:
output_path = await synthesize_to_file(
text=load_text(args),
output=args.output,
voice=args.voice,
rate=args.rate,
volume=args.volume,
pitch=args.pitch,
)
print(f"Audio file generated: {output_path}")
print(f"Voice: {args.voice}")
async def async_main() -> None:
args = parse_args()
if args.list_voices:
await list_voices(args.filter)
return
await generate_tts(args)
if __name__ == "__main__":
asyncio.run(async_main())