Supertonic 2
This commit is contained in:
@@ -4,6 +4,8 @@ Node.js implementation for TTS inference. Uses ONNX Runtime to generate speech f
|
||||
|
||||
## 📰 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 [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
|
||||
|
||||
**2025.12.08** - Optimized ONNX models via [OnnxSlim](https://github.com/inisis/OnnxSlim) now available on [Hugging Face Models](https://huggingface.co/Supertone/supertonic)
|
||||
@@ -51,15 +53,16 @@ Process multiple voice styles and texts at once:
|
||||
```bash
|
||||
node example_onnx.js \
|
||||
--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
|
||||
@@ -98,15 +101,18 @@ This will:
|
||||
| `--use-gpu` | flag | False | Use GPU for inference (not supported yet) |
|
||||
| `--onnx-dir` | str | `assets/onnx` | Path to ONNX model directory |
|
||||
| `--total-step` | int | 5 | Number of denoising steps (higher = better quality, slower) |
|
||||
| `--speed` | float | 1.05 | Speech speed factor (higher = faster, lower = slower) |
|
||||
| `--n-test` | int | 4 | Number of times to generate each sample |
|
||||
| `--voice-style` | str+ | `assets/voice_styles/M1.json` | Voice style file path(s). Separate multiple files with commas |
|
||||
| `--text` | str+ | (long default text) | Text(s) to synthesize. Separate multiple texts with pipes |
|
||||
| `--lang` | str+ | `en` | Language(s) for text(s): `en`, `ko`, `es`, `pt`, `fr`. Separate multiple with commas |
|
||||
| `--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 texts. Use commas to separate files and pipes to separate texts
|
||||
- **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
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { loadTextToSpeech, loadVoiceStyle, timer, writeWavFile } from './helper.js';
|
||||
import { loadTextToSpeech, loadVoiceStyle, timer, writeWavFile, sanitizeFilename } from './helper.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -19,6 +19,7 @@ function parseArgs() {
|
||||
nTest: 4,
|
||||
voiceStyle: ['assets/voice_styles/M1.json'],
|
||||
text: ['This morning, I took a walk in the park, and the sound of the birds and the breeze was so pleasant that I stopped for a long time just to listen.'],
|
||||
lang: ['en'],
|
||||
saveDir: 'results',
|
||||
batch: false
|
||||
};
|
||||
@@ -41,6 +42,8 @@ function parseArgs() {
|
||||
args.voiceStyle = process.argv[++i].split(',');
|
||||
} else if (arg === '--text' && i + 1 < process.argv.length) {
|
||||
args.text = process.argv[++i].split('|');
|
||||
} else if (arg === '--lang' && i + 1 < process.argv.length) {
|
||||
args.lang = process.argv[++i].split(',');
|
||||
} else if (arg === '--save-dir' && i + 1 < process.argv.length) {
|
||||
args.saveDir = process.argv[++i];
|
||||
}
|
||||
@@ -63,6 +66,7 @@ async function main() {
|
||||
const saveDir = args.saveDir;
|
||||
const voiceStylePaths = args.voiceStyle.map(p => path.resolve(__dirname, p));
|
||||
const textList = args.text;
|
||||
const langList = args.lang;
|
||||
const batch = args.batch;
|
||||
|
||||
if (voiceStylePaths.length !== textList.length) {
|
||||
@@ -83,9 +87,9 @@ async function main() {
|
||||
|
||||
const { wav, duration } = await timer('Generating speech from text', async () => {
|
||||
if (batch) {
|
||||
return await textToSpeech.batch(textList, style, totalStep, speed);
|
||||
return await textToSpeech.batch(textList, langList, style, totalStep, speed);
|
||||
} else {
|
||||
return await textToSpeech.call(textList[0], style, totalStep, speed);
|
||||
return await textToSpeech.call(textList[0], langList[0], style, totalStep, speed);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -95,7 +99,7 @@ async function main() {
|
||||
|
||||
const wavShape = [bsz, wav.length / bsz];
|
||||
for (let b = 0; b < bsz; b++) {
|
||||
const fname = `${textList[b].substring(0, 20).replace(/[^a-zA-Z0-9]/g, '_')}_${n + 1}.wav`;
|
||||
const fname = `${sanitizeFilename(textList[b], 20)}_${n + 1}.wav`;
|
||||
const wavLen = Math.floor(textToSpeech.sampleRate * duration[b]);
|
||||
const wavOut = wav.slice(b * wavShape[1], b * wavShape[1] + wavLen);
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import * as ort from 'onnxruntime-node';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
const AVAILABLE_LANGS = ["en", "ko", "es", "pt", "fr"];
|
||||
|
||||
/**
|
||||
* Unicode text processor
|
||||
*/
|
||||
@@ -13,12 +15,10 @@ class UnicodeProcessor {
|
||||
this.indexer = JSON.parse(fs.readFileSync(unicodeIndexerJsonPath, 'utf8'));
|
||||
}
|
||||
|
||||
_preprocessText(text) {
|
||||
_preprocessText(text, lang) {
|
||||
// TODO: Need advanced normalizer for better performance
|
||||
text = text.normalize('NFKD');
|
||||
|
||||
// FIXME: this should be fixed for non-English languages
|
||||
|
||||
// Remove emojis (wide Unicode range)
|
||||
const emojiPattern = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F1E6}-\u{1F1FF}]+/gu;
|
||||
text = text.replace(emojiPattern, '');
|
||||
@@ -28,7 +28,6 @@ class UnicodeProcessor {
|
||||
'–': '-',
|
||||
'‑': '-',
|
||||
'—': '-',
|
||||
'¯': ' ',
|
||||
'_': ' ',
|
||||
'\u201C': '"', // left double quote "
|
||||
'\u201D': '"', // right double quote "
|
||||
@@ -48,9 +47,6 @@ class UnicodeProcessor {
|
||||
text = text.replaceAll(k, v);
|
||||
}
|
||||
|
||||
// Remove combining diacritics // FIXME: this should be fixed for non-English languages
|
||||
text = text.replace(/[\u0302\u0303\u0304\u0305\u0306\u0307\u0308\u030A\u030B\u030C\u0327\u0328\u0329\u032A\u032B\u032C\u032D\u032E\u032F]/g, '');
|
||||
|
||||
// Remove special symbols
|
||||
text = text.replace(/[♥☆♡©\\]/g, '');
|
||||
|
||||
@@ -92,6 +88,14 @@ class UnicodeProcessor {
|
||||
text += '.';
|
||||
}
|
||||
|
||||
// Validate language
|
||||
if (!AVAILABLE_LANGS.includes(lang)) {
|
||||
throw new Error(`Invalid language: ${lang}. Available: ${AVAILABLE_LANGS.join(', ')}`);
|
||||
}
|
||||
|
||||
// Wrap text with language tags
|
||||
text = `<${lang}>` + text + `</${lang}>`;
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -103,8 +107,8 @@ class UnicodeProcessor {
|
||||
return lengthToMask(textIdsLengths);
|
||||
}
|
||||
|
||||
call(textList) {
|
||||
const processedTexts = textList.map(t => this._preprocessText(t));
|
||||
call(textList, langList) {
|
||||
const processedTexts = textList.map((t, i) => this._preprocessText(t, langList[i]));
|
||||
const textIdsLengths = processedTexts.map(t => t.length);
|
||||
const maxLen = Math.max(...textIdsLengths);
|
||||
|
||||
@@ -191,12 +195,12 @@ class TextToSpeech {
|
||||
return { noisyLatent, latentMask };
|
||||
}
|
||||
|
||||
async _infer(textList, style, totalStep, speed = 1.05) {
|
||||
async _infer(textList, langList, style, totalStep, speed = 1.05) {
|
||||
if (textList.length !== style.ttl.dims[0]) {
|
||||
throw new Error('Number of texts must match number of style vectors');
|
||||
}
|
||||
const bsz = textList.length;
|
||||
const { textIds, textMask } = this.textProcessor.call(textList);
|
||||
const { textIds, textMask } = this.textProcessor.call(textList, langList);
|
||||
const textIdsShape = [bsz, textIds[0].length];
|
||||
const textMaskShape = [bsz, 1, textMask[0][0].length];
|
||||
|
||||
@@ -267,16 +271,17 @@ class TextToSpeech {
|
||||
return { wav, duration: durOnnx };
|
||||
}
|
||||
|
||||
async call(text, style, totalStep, speed = 1.05, silenceDuration = 0.3) {
|
||||
async call(text, lang, style, totalStep, speed = 1.05, silenceDuration = 0.3) {
|
||||
if (style.ttl.dims[0] !== 1) {
|
||||
throw new Error('Single speaker text to speech only supports single style');
|
||||
}
|
||||
const textList = chunkText(text);
|
||||
const maxLen = lang === 'ko' ? 120 : 300;
|
||||
const textList = chunkText(text, maxLen);
|
||||
let wavCat = null;
|
||||
let durCat = 0;
|
||||
|
||||
for (const chunk of textList) {
|
||||
const { wav, duration } = await this._infer([chunk], style, totalStep, speed);
|
||||
const { wav, duration } = await this._infer([chunk], [lang], style, totalStep, speed);
|
||||
|
||||
if (wavCat === null) {
|
||||
wavCat = wav;
|
||||
@@ -292,8 +297,8 @@ class TextToSpeech {
|
||||
return { wav: wavCat, duration: [durCat] };
|
||||
}
|
||||
|
||||
async batch(textList, style, totalStep, speed = 1.05) {
|
||||
return await this._infer(textList, style, totalStep, speed);
|
||||
async batch(textList, langList, style, totalStep, speed = 1.05) {
|
||||
return await this._infer(textList, langList, style, totalStep, speed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,6 +507,15 @@ export async function timer(name, fn) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename by replacing non-alphanumeric characters with underscores (supports Unicode)
|
||||
*/
|
||||
export function sanitizeFilename(text, maxLen) {
|
||||
const prefix = text.substring(0, maxLen);
|
||||
// \p{L} matches any Unicode letter, \p{N} matches any Unicode number
|
||||
return prefix.replace(/[^\p{L}\p{N}_]/gu, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk text into manageable segments
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user