Supertonic 2
This commit is contained in:
11
py/README.md
11
py/README.md
@@ -4,6 +4,8 @@ This guide provides examples for running TTS inference using `example_onnx.py`.
|
||||
|
||||
## 📰 Update News
|
||||
|
||||
**2026.01.06** - 🎉 **Supertonic 2** released with multilingual support! Now supports English (`en`), Korean (`ko`), Spanish (`es`), Portuguese (`pt`), and French (`fr`). [Demo](https://huggingface.co/spaces/Supertone/supertonic-2) | [Models](https://huggingface.co/Supertone/supertonic-2)
|
||||
|
||||
**2025.12.10** - Added `supertonic` PyPI package! Install via `pip install supertonic` for a streamlined experience. This is a separate usage method from the ONNX examples in this directory. For more details, visit [supertonic-py documentation](https://supertone-inc.github.io/supertonic-py) and see `example_pypi.py` for usage.
|
||||
|
||||
**2025.12.10** - Added [6 new voice styles](https://huggingface.co/Supertone/supertonic/tree/b10dbaf18b316159be75b34d24f740008fddd381) (M3, M4, M5, F3, F4, F5). See [Voices](https://supertone-inc.github.io/supertonic-py/voices/) for details
|
||||
@@ -55,15 +57,16 @@ Process multiple voice styles and texts at once:
|
||||
```bash
|
||||
uv run example_onnx.py \
|
||||
--voice-style assets/voice_styles/M1.json assets/voice_styles/F1.json \
|
||||
--text "The sun sets behind the mountains, painting the sky in shades of pink and orange." "The weather is beautiful and sunny outside. A gentle breeze makes the air feel fresh and pleasant." \
|
||||
--text "The sun sets behind the mountains, painting the sky in shades of pink and orange." "오늘 아침에 공원을 산책했는데, 새소리와 바람 소리가 너무 좋아서 한참을 멈춰 서서 들었어요." \
|
||||
--lang en ko \
|
||||
--batch
|
||||
```
|
||||
|
||||
This will:
|
||||
- Use `--batch` flag to enable batch processing mode
|
||||
- Generate speech for 2 different voice-text pairs
|
||||
- Use male voice style (M1.json) for the first text
|
||||
- Use female voice style (F1.json) for the second text
|
||||
- Use male voice style (M1.json) for the first English text
|
||||
- Use female voice style (F1.json) for the second Korean text
|
||||
- Process both samples in a single batch (automatic text chunking disabled)
|
||||
|
||||
### Example 3: High Quality Inference
|
||||
@@ -128,12 +131,14 @@ This will:
|
||||
| `--n-test` | int | 4 | Number of times to generate each sample |
|
||||
| `--voice-style` | str+ | `assets/voice_styles/M1.json` | Voice style file path(s) |
|
||||
| `--text` | str+ | (long default text) | Text(s) to synthesize |
|
||||
| `--lang` | str+ | `en` | Language(s) for text(s): `en`, `ko`, `es`, `pt`, `fr` |
|
||||
| `--save-dir` | str | `results` | Output directory |
|
||||
| `--batch` | flag | False | Enable batch mode (disables automatic text chunking) |
|
||||
|
||||
## Notes
|
||||
|
||||
- **Batch Processing**: The number of `--voice-style` files must match the number of `--text` entries
|
||||
- **Multilingual Support**: Use `--lang` to specify language(s). Available: `en` (English), `ko` (Korean), `es` (Spanish), `pt` (Portuguese), `fr` (French)
|
||||
- **Long-Form Inference**: Without `--batch` flag, long texts are automatically chunked and combined into a single audio file with natural pauses
|
||||
- **Quality vs Speed**: Higher `--total-step` values produce better quality but take longer
|
||||
- **GPU Support**: GPU mode is not supported yet
|
||||
|
||||
@@ -56,6 +56,13 @@ def parse_args():
|
||||
],
|
||||
help="Text(s) to synthesize. Can specify multiple texts for batch processing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lang",
|
||||
type=str,
|
||||
nargs="+",
|
||||
default=["en"],
|
||||
help="Language(s) of the text(s). Can specify multiple languages for batch processing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save-dir", type=str, default="results", help="Output directory"
|
||||
)
|
||||
@@ -73,6 +80,7 @@ n_test = args.n_test
|
||||
save_dir = args.save_dir
|
||||
voice_style_paths = args.voice_style
|
||||
text_list = args.text
|
||||
lang_list = args.lang
|
||||
batch = args.batch
|
||||
|
||||
assert len(voice_style_paths) == len(
|
||||
@@ -91,9 +99,13 @@ for n in range(n_test):
|
||||
print(f"\n[{n+1}/{n_test}] Starting synthesis...")
|
||||
with timer("Generating speech from text"):
|
||||
if batch:
|
||||
wav, duration = text_to_speech.batch(text_list, style, total_step, speed)
|
||||
wav, duration = text_to_speech.batch(
|
||||
text_list, lang_list, style, total_step, speed
|
||||
)
|
||||
else:
|
||||
wav, duration = text_to_speech(text_list[0], style, total_step, speed)
|
||||
wav, duration = text_to_speech(
|
||||
text_list[0], lang_list[0], style, total_step, speed
|
||||
)
|
||||
if not os.path.exists(save_dir):
|
||||
os.makedirs(save_dir)
|
||||
for b in range(bsz):
|
||||
|
||||
59
py/helper.py
59
py/helper.py
@@ -10,18 +10,18 @@ import onnxruntime as ort
|
||||
|
||||
import re
|
||||
|
||||
AVAILABLE_LANGS = ["en", "ko", "es", "pt", "fr"]
|
||||
|
||||
|
||||
class UnicodeProcessor:
|
||||
def __init__(self, unicode_indexer_path: str):
|
||||
with open(unicode_indexer_path, "r") as f:
|
||||
self.indexer = json.load(f)
|
||||
|
||||
def _preprocess_text(self, text: str) -> str:
|
||||
def _preprocess_text(self, text: str, lang: str) -> str:
|
||||
# TODO: Need advanced normalizer for better performance
|
||||
text = normalize("NFKD", text)
|
||||
|
||||
# FIXME: this should be fixed for non-English languages
|
||||
|
||||
# Remove emojis (wide Unicode range)
|
||||
emoji_pattern = re.compile(
|
||||
"[\U0001f600-\U0001f64f" # emoticons
|
||||
@@ -45,10 +45,9 @@ class UnicodeProcessor:
|
||||
"–": "-",
|
||||
"‑": "-",
|
||||
"—": "-",
|
||||
"¯": " ",
|
||||
"_": " ",
|
||||
"\u201C": '"', # left double quote "
|
||||
"\u201D": '"', # right double quote "
|
||||
"\u201c": '"', # left double quote "
|
||||
"\u201d": '"', # right double quote "
|
||||
"\u2018": "'", # left single quote '
|
||||
"\u2019": "'", # right single quote '
|
||||
"´": "'",
|
||||
@@ -64,13 +63,6 @@ class UnicodeProcessor:
|
||||
for k, v in replacements.items():
|
||||
text = text.replace(k, v)
|
||||
|
||||
# Remove combining diacritics # FIXME: this should be fixed for non-English languages
|
||||
text = re.sub(
|
||||
r"[\u0302\u0303\u0304\u0305\u0306\u0307\u0308\u030A\u030B\u030C\u0327\u0328\u0329\u032A\u032B\u032C\u032D\u032E\u032F]",
|
||||
"",
|
||||
text,
|
||||
)
|
||||
|
||||
# Remove special symbols
|
||||
text = re.sub(r"[♥☆♡©\\]", "", text)
|
||||
|
||||
@@ -107,6 +99,9 @@ class UnicodeProcessor:
|
||||
if not re.search(r"[.!?;:,'\"')\]}…。」』】〉》›»]$", text):
|
||||
text += "."
|
||||
|
||||
if lang not in AVAILABLE_LANGS:
|
||||
raise ValueError(f"Invalid language: {lang}")
|
||||
text = f"<{lang}>" + text + f"</{lang}>"
|
||||
return text
|
||||
|
||||
def _get_text_mask(self, text_ids_lengths: np.ndarray) -> np.ndarray:
|
||||
@@ -119,8 +114,12 @@ class UnicodeProcessor:
|
||||
) # 2 bytes
|
||||
return unicode_values
|
||||
|
||||
def __call__(self, text_list: list[str]) -> tuple[np.ndarray, np.ndarray]:
|
||||
text_list = [self._preprocess_text(t) for t in text_list]
|
||||
def __call__(
|
||||
self, text_list: list[str], lang_list: list[str]
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
text_list = [
|
||||
self._preprocess_text(t, lang) for t, lang in zip(text_list, lang_list)
|
||||
]
|
||||
text_ids_lengths = np.array([len(text) for text in text_list], dtype=np.int64)
|
||||
text_ids = np.zeros((len(text_list), text_ids_lengths.max()), dtype=np.int64)
|
||||
for i, text in enumerate(text_list):
|
||||
@@ -176,13 +175,18 @@ class TextToSpeech:
|
||||
return noisy_latent, latent_mask
|
||||
|
||||
def _infer(
|
||||
self, text_list: list[str], style: Style, total_step: int, speed: float = 1.05
|
||||
self,
|
||||
text_list: list[str],
|
||||
lang_list: list[str],
|
||||
style: Style,
|
||||
total_step: int,
|
||||
speed: float = 1.05,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
assert (
|
||||
len(text_list) == style.ttl.shape[0]
|
||||
), "Number of texts must match number of style vectors"
|
||||
bsz = len(text_list)
|
||||
text_ids, text_mask = self.text_processor(text_list)
|
||||
text_ids, text_mask = self.text_processor(text_list, lang_list)
|
||||
dur_onnx, *_ = self.dp_ort.run(
|
||||
None, {"text_ids": text_ids, "style_dp": style.dp, "text_mask": text_mask}
|
||||
)
|
||||
@@ -213,6 +217,7 @@ class TextToSpeech:
|
||||
def __call__(
|
||||
self,
|
||||
text: str,
|
||||
lang: str,
|
||||
style: Style,
|
||||
total_step: int,
|
||||
speed: float = 1.05,
|
||||
@@ -221,11 +226,12 @@ class TextToSpeech:
|
||||
assert (
|
||||
style.ttl.shape[0] == 1
|
||||
), "Single speaker text to speech only supports single style"
|
||||
text_list = chunk_text(text)
|
||||
max_len = 120 if lang == "ko" else 300
|
||||
text_list = chunk_text(text, max_len=max_len)
|
||||
wav_cat = None
|
||||
dur_cat = None
|
||||
for text in text_list:
|
||||
wav, dur_onnx = self._infer([text], style, total_step, speed)
|
||||
wav, dur_onnx = self._infer([text], [lang], style, total_step, speed)
|
||||
if wav_cat is None:
|
||||
wav_cat = wav
|
||||
dur_cat = dur_onnx
|
||||
@@ -238,9 +244,14 @@ class TextToSpeech:
|
||||
return wav_cat, dur_cat
|
||||
|
||||
def batch(
|
||||
self, text_list: list[str], style: Style, total_step: int, speed: float = 1.05
|
||||
self,
|
||||
text_list: list[str],
|
||||
lang_list: list[str],
|
||||
style: Style,
|
||||
total_step: int,
|
||||
speed: float = 1.05,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
return self._infer(text_list, style, total_step, speed)
|
||||
return self._infer(text_list, lang_list, style, total_step, speed)
|
||||
|
||||
|
||||
def length_to_mask(lengths: np.ndarray, max_len: Optional[int] = None) -> np.ndarray:
|
||||
@@ -365,11 +376,13 @@ def timer(name: str):
|
||||
|
||||
|
||||
def sanitize_filename(text: str, max_len: int) -> str:
|
||||
"""Sanitize filename by replacing non-alphanumeric characters with underscores"""
|
||||
"""Sanitize filename by replacing non-alphanumeric characters with underscores (supports Unicode)"""
|
||||
import re
|
||||
|
||||
prefix = text[:max_len]
|
||||
return re.sub(r"[^a-zA-Z0-9]", "_", prefix)
|
||||
# \w matches Unicode word characters (letters, digits, underscore) with re.UNICODE
|
||||
# We replace non-word characters except keeping existing underscores
|
||||
return re.sub(r"[^\w]", "_", prefix, flags=re.UNICODE)
|
||||
|
||||
|
||||
def chunk_text(text: str, max_len: int = 300) -> list[str]:
|
||||
|
||||
Reference in New Issue
Block a user