Supertonic 2
This commit is contained in:
@@ -1,18 +1,29 @@
|
||||
# Supertonic Flutter Example
|
||||
|
||||
This example demonstrates how to use Supertonic in a Flutter application using ONNX Runtime.
|
||||
This example demonstrates how to use Supertonic 2 in a Flutter application using ONNX Runtime.
|
||||
|
||||
> **Note:** This project uses the `flutter_onnxruntime` package ([https://pub.dev/packages/flutter_onnxruntime](https://pub.dev/packages/flutter_onnxruntime)). At the moment, only the macOS platform has been tested. Although the flutter_onnxruntime package supports several other platforms, they have not been tested in this project yet and may require additional verification.
|
||||
|
||||
|
||||
## 📰 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)
|
||||
|
||||
**2025.11.23** - Added and tested macos support.
|
||||
|
||||
## Multilingual Support
|
||||
|
||||
Supertonic 2 supports multiple languages. Select the appropriate language from the dropdown:
|
||||
- **English (en)**: Default language
|
||||
- **한국어 (ko)**: Korean
|
||||
- **Español (es)**: Spanish
|
||||
- **Português (pt)**: Portuguese
|
||||
- **Français (fr)**: French
|
||||
|
||||
## Requirements
|
||||
|
||||
- Flutter SDK version ^3.5.0
|
||||
|
||||
@@ -11,6 +11,205 @@ final logger = Logger(
|
||||
printer: PrettyPrinter(methodCount: 0, errorMethodCount: 5, lineLength: 80),
|
||||
);
|
||||
|
||||
// Available languages for multilingual TTS
|
||||
const List<String> availableLangs = ['en', 'ko', 'es', 'pt', 'fr'];
|
||||
|
||||
bool isValidLang(String lang) => availableLangs.contains(lang);
|
||||
|
||||
// Hangul Jamo constants for NFKD decomposition
|
||||
const int _hangulSyllableBase = 0xAC00;
|
||||
const int _hangulSyllableEnd = 0xD7A3;
|
||||
const int _leadingJamoBase = 0x1100;
|
||||
const int _vowelJamoBase = 0x1161;
|
||||
const int _trailingJamoBase = 0x11A7;
|
||||
const int _vowelCount = 21;
|
||||
const int _trailingCount = 28;
|
||||
|
||||
/// Decompose a Hangul syllable into Jamo (NFKD-like decomposition)
|
||||
List<int> _decomposeHangulSyllable(int codePoint) {
|
||||
if (codePoint < _hangulSyllableBase || codePoint > _hangulSyllableEnd) {
|
||||
return [codePoint];
|
||||
}
|
||||
|
||||
final syllableIndex = codePoint - _hangulSyllableBase;
|
||||
final leadingIndex = syllableIndex ~/ (_vowelCount * _trailingCount);
|
||||
final vowelIndex =
|
||||
(syllableIndex % (_vowelCount * _trailingCount)) ~/ _trailingCount;
|
||||
final trailingIndex = syllableIndex % _trailingCount;
|
||||
|
||||
final result = <int>[
|
||||
_leadingJamoBase + leadingIndex,
|
||||
_vowelJamoBase + vowelIndex,
|
||||
];
|
||||
|
||||
if (trailingIndex > 0) {
|
||||
result.add(_trailingJamoBase + trailingIndex);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Common Latin character decompositions (NFKD) for es, pt, fr
|
||||
const Map<int, List<int>> _latinDecompositions = {
|
||||
// Uppercase with acute accent
|
||||
0x00C1: [0x0041, 0x0301], // Á → A + ́
|
||||
0x00C9: [0x0045, 0x0301], // É → E + ́
|
||||
0x00CD: [0x0049, 0x0301], // Í → I + ́
|
||||
0x00D3: [0x004F, 0x0301], // Ó → O + ́
|
||||
0x00DA: [0x0055, 0x0301], // Ú → U + ́
|
||||
// Lowercase with acute accent
|
||||
0x00E1: [0x0061, 0x0301], // á → a + ́
|
||||
0x00E9: [0x0065, 0x0301], // é → e + ́
|
||||
0x00ED: [0x0069, 0x0301], // í → i + ́
|
||||
0x00F3: [0x006F, 0x0301], // ó → o + ́
|
||||
0x00FA: [0x0075, 0x0301], // ú → u + ́
|
||||
// Grave accent
|
||||
0x00C0: [0x0041, 0x0300], // À → A + ̀
|
||||
0x00C8: [0x0045, 0x0300], // È → E + ̀
|
||||
0x00CC: [0x0049, 0x0300], // Ì → I + ̀
|
||||
0x00D2: [0x004F, 0x0300], // Ò → O + ̀
|
||||
0x00D9: [0x0055, 0x0300], // Ù → U + ̀
|
||||
0x00E0: [0x0061, 0x0300], // à → a + ̀
|
||||
0x00E8: [0x0065, 0x0300], // è → e + ̀
|
||||
0x00EC: [0x0069, 0x0300], // ì → i + ̀
|
||||
0x00F2: [0x006F, 0x0300], // ò → o + ̀
|
||||
0x00F9: [0x0075, 0x0300], // ù → u + ̀
|
||||
// Circumflex
|
||||
0x00C2: [0x0041, 0x0302], // Â → A + ̂
|
||||
0x00CA: [0x0045, 0x0302], // Ê → E + ̂
|
||||
0x00CE: [0x0049, 0x0302], // Î → I + ̂
|
||||
0x00D4: [0x004F, 0x0302], // Ô → O + ̂
|
||||
0x00DB: [0x0055, 0x0302], // Û → U + ̂
|
||||
0x00E2: [0x0061, 0x0302], // â → a + ̂
|
||||
0x00EA: [0x0065, 0x0302], // ê → e + ̂
|
||||
0x00EE: [0x0069, 0x0302], // î → i + ̂
|
||||
0x00F4: [0x006F, 0x0302], // ô → o + ̂
|
||||
0x00FB: [0x0075, 0x0302], // û → u + ̂
|
||||
// Tilde
|
||||
0x00C3: [0x0041, 0x0303], // Ã → A + ̃
|
||||
0x00D1: [0x004E, 0x0303], // Ñ → N + ̃
|
||||
0x00D5: [0x004F, 0x0303], // Õ → O + ̃
|
||||
0x00E3: [0x0061, 0x0303], // ã → a + ̃
|
||||
0x00F1: [0x006E, 0x0303], // ñ → n + ̃
|
||||
0x00F5: [0x006F, 0x0303], // õ → o + ̃
|
||||
// Diaeresis/Umlaut
|
||||
0x00C4: [0x0041, 0x0308], // Ä → A + ̈
|
||||
0x00CB: [0x0045, 0x0308], // Ë → E + ̈
|
||||
0x00CF: [0x0049, 0x0308], // Ï → I + ̈
|
||||
0x00D6: [0x004F, 0x0308], // Ö → O + ̈
|
||||
0x00DC: [0x0055, 0x0308], // Ü → U + ̈
|
||||
0x00E4: [0x0061, 0x0308], // ä → a + ̈
|
||||
0x00EB: [0x0065, 0x0308], // ë → e + ̈
|
||||
0x00EF: [0x0069, 0x0308], // ï → i + ̈
|
||||
0x00F6: [0x006F, 0x0308], // ö → o + ̈
|
||||
0x00FC: [0x0075, 0x0308], // ü → u + ̈
|
||||
// Cedilla
|
||||
0x00C7: [0x0043, 0x0327], // Ç → C + ̧
|
||||
0x00E7: [0x0063, 0x0327], // ç → c + ̧
|
||||
};
|
||||
|
||||
/// Apply NFKD-like decomposition (Hangul + Latin accented characters)
|
||||
String _applyNfkdDecomposition(String text) {
|
||||
final result = <int>[];
|
||||
for (final codePoint in text.runes) {
|
||||
// Check Hangul first
|
||||
if (codePoint >= _hangulSyllableBase && codePoint <= _hangulSyllableEnd) {
|
||||
result.addAll(_decomposeHangulSyllable(codePoint));
|
||||
}
|
||||
// Check Latin decomposition
|
||||
else if (_latinDecompositions.containsKey(codePoint)) {
|
||||
result.addAll(_latinDecompositions[codePoint]!);
|
||||
}
|
||||
// Keep as-is
|
||||
else {
|
||||
result.add(codePoint);
|
||||
}
|
||||
}
|
||||
return String.fromCharCodes(result);
|
||||
}
|
||||
|
||||
String preprocessText(String text, String lang) {
|
||||
// Apply NFKD-like decomposition (especially for Hangul syllables → Jamo)
|
||||
text = _applyNfkdDecomposition(text);
|
||||
|
||||
// Remove emojis
|
||||
text = text.replaceAll(
|
||||
RegExp(
|
||||
r'[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|'
|
||||
r'[\u{1F700}-\u{1F77F}]|[\u{1F780}-\u{1F7FF}]|[\u{1F800}-\u{1F8FF}]|'
|
||||
r'[\u{1F900}-\u{1F9FF}]|[\u{1FA00}-\u{1FA6F}]|[\u{1FA70}-\u{1FAFF}]|'
|
||||
r'[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F1E6}-\u{1F1FF}]',
|
||||
unicode: true,
|
||||
),
|
||||
'');
|
||||
|
||||
// Replace various dashes and symbols
|
||||
const replacements = {
|
||||
'–': '-',
|
||||
'‑': '-',
|
||||
'—': '-',
|
||||
'_': ' ',
|
||||
'\u201C': '"',
|
||||
'\u201D': '"',
|
||||
'\u2018': "'",
|
||||
'\u2019': "'",
|
||||
'´': "'",
|
||||
'`': "'",
|
||||
'[': ' ',
|
||||
']': ' ',
|
||||
'|': ' ',
|
||||
'/': ' ',
|
||||
'#': ' ',
|
||||
'→': ' ',
|
||||
'←': ' ',
|
||||
};
|
||||
for (final entry in replacements.entries) {
|
||||
text = text.replaceAll(entry.key, entry.value);
|
||||
}
|
||||
|
||||
// Remove special symbols
|
||||
text = text.replaceAll(RegExp(r'[♥☆♡©\\]'), '');
|
||||
|
||||
// Replace known expressions
|
||||
text = text.replaceAll('@', ' at ');
|
||||
text = text.replaceAll('e.g.,', 'for example, ');
|
||||
text = text.replaceAll('i.e.,', 'that is, ');
|
||||
|
||||
// Fix spacing around punctuation
|
||||
text = text.replaceAll(' ,', ',');
|
||||
text = text.replaceAll(' .', '.');
|
||||
text = text.replaceAll(' !', '!');
|
||||
text = text.replaceAll(' ?', '?');
|
||||
text = text.replaceAll(' ;', ';');
|
||||
text = text.replaceAll(' :', ':');
|
||||
text = text.replaceAll(" '", "'");
|
||||
|
||||
// Remove duplicate quotes
|
||||
while (text.contains('""')) text = text.replaceAll('""', '"');
|
||||
while (text.contains("''")) text = text.replaceAll("''", "'");
|
||||
while (text.contains('``')) text = text.replaceAll('``', '`');
|
||||
|
||||
// Remove extra spaces
|
||||
text = text.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
|
||||
// Add period if needed
|
||||
if (text.isNotEmpty &&
|
||||
!RegExp(r'[.!?;:,\x27\x22\u2018\u2019)\]}…。」』】〉》›»]$').hasMatch(text)) {
|
||||
text += '.';
|
||||
}
|
||||
|
||||
// Validate language
|
||||
if (!isValidLang(lang)) {
|
||||
throw ArgumentError(
|
||||
'Invalid language: $lang. Available: ${availableLangs.join(", ")}');
|
||||
}
|
||||
|
||||
// Wrap text with language tags
|
||||
text = '<$lang>$text</$lang>';
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
class UnicodeProcessor {
|
||||
final Map<int, int> indexer;
|
||||
|
||||
@@ -34,11 +233,17 @@ class UnicodeProcessor {
|
||||
return UnicodeProcessor._(indexer);
|
||||
}
|
||||
|
||||
Map<String, dynamic> call(List<String> textList) {
|
||||
final lengths = textList.map((t) => t.length).toList();
|
||||
Map<String, dynamic> call(List<String> textList, List<String> langList) {
|
||||
// Preprocess texts with language tags
|
||||
final processedTexts = <String>[];
|
||||
for (var i = 0; i < textList.length; i++) {
|
||||
processedTexts.add(preprocessText(textList[i], langList[i]));
|
||||
}
|
||||
|
||||
final lengths = processedTexts.map((t) => t.runes.length).toList();
|
||||
final maxLen = lengths.reduce(math.max);
|
||||
|
||||
final textIds = textList.map((text) {
|
||||
final textIds = processedTexts.map((text) {
|
||||
final row = List<int>.filled(maxLen, 0);
|
||||
final runes = text.runes.toList();
|
||||
for (var i = 0; i < runes.length; i++) {
|
||||
@@ -77,14 +282,18 @@ class TextToSpeech {
|
||||
chunkCompressFactor = cfgs['ttl']['chunk_compress_factor'],
|
||||
ldim = cfgs['ttl']['latent_dim'];
|
||||
|
||||
Future<Map<String, dynamic>> call(String text, Style style, int totalStep,
|
||||
Future<Map<String, dynamic>> call(
|
||||
String text, String lang, Style style, int totalStep,
|
||||
{double speed = 1.05, double silenceDuration = 0.3}) async {
|
||||
final chunks = _chunkText(text);
|
||||
final maxLen = lang == 'ko' ? 120 : 300;
|
||||
final chunks = _chunkText(text, maxLen: maxLen);
|
||||
final langList = List.filled(chunks.length, lang);
|
||||
List<double>? wavCat;
|
||||
double durCat = 0;
|
||||
|
||||
for (final chunk in chunks) {
|
||||
final result = await _infer([chunk], style, totalStep, speed: speed);
|
||||
for (var i = 0; i < chunks.length; i++) {
|
||||
final result = await _infer([chunks[i]], [langList[i]], style, totalStep,
|
||||
speed: speed);
|
||||
final wav = _safeCast<double>(result['wav']);
|
||||
final duration = _safeCast<double>(result['duration']);
|
||||
|
||||
@@ -108,10 +317,10 @@ class TextToSpeech {
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _infer(
|
||||
List<String> textList, Style style, int totalStep,
|
||||
List<String> textList, List<String> langList, Style style, int totalStep,
|
||||
{double speed = 1.05}) async {
|
||||
final bsz = textList.length;
|
||||
final result = textProcessor.call(textList);
|
||||
final result = textProcessor.call(textList, langList);
|
||||
|
||||
final textIdsRaw = result['textIds'];
|
||||
final textIds = textIdsRaw is List<List<int>>
|
||||
|
||||
@@ -14,7 +14,7 @@ class SupertonicApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Supertonic',
|
||||
title: 'Supertonic 2',
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
@@ -44,6 +44,7 @@ class _TTSPageState extends State<TTSPage> {
|
||||
String _status = 'Not initialized';
|
||||
int _totalSteps = 5;
|
||||
double _speed = 1.05;
|
||||
String _selectedLang = 'en';
|
||||
bool _isPlaying = false;
|
||||
String? _lastGeneratedFilePath;
|
||||
|
||||
@@ -119,6 +120,7 @@ class _TTSPageState extends State<TTSPage> {
|
||||
try {
|
||||
final result = await _textToSpeech!.call(
|
||||
_textController.text,
|
||||
_selectedLang,
|
||||
_style!,
|
||||
_totalSteps,
|
||||
speed: _speed,
|
||||
@@ -208,7 +210,7 @@ class _TTSPageState extends State<TTSPage> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
title: const Text('Supertonic'),
|
||||
title: const Text('Supertonic 2'),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
@@ -310,6 +312,31 @@ class _TTSPageState extends State<TTSPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Language selector
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(flex: 2, child: Text('Language:')),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedLang,
|
||||
isExpanded: true,
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'en', child: Text('English')),
|
||||
DropdownMenuItem(value: 'ko', child: Text('한국어')),
|
||||
DropdownMenuItem(value: 'es', child: Text('Español')),
|
||||
DropdownMenuItem(value: 'pt', child: Text('Português')),
|
||||
DropdownMenuItem(value: 'fr', child: Text('Français')),
|
||||
],
|
||||
onChanged: _isLoading || _isGenerating
|
||||
? null
|
||||
: (value) => setState(() => _selectedLang = value!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Generate button
|
||||
|
||||
@@ -41,11 +41,11 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/objective_c/macos
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e
|
||||
flutter_onnxruntime: 546c7c7630c9259f59021319639b30bdaff148c2
|
||||
audio_session: 728ae3823d914f809c485d390274861a24b0904e
|
||||
flutter_onnxruntime: e6887abc1032d3e5c92f84b912ad42c33e9ce1c9
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
||||
objective_c: ec13431e45ba099cb734eb2829a5c1cd37986cba
|
||||
just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79
|
||||
objective_c: e5f8194456e8fc943e034d1af00510a1bc29c067
|
||||
onnxruntime-c: ac65025f01072d25d7d394a2b43ac30d9397b260
|
||||
onnxruntime-objc: 5fa03134356d47b642ec85b1023d9907a123d201
|
||||
|
||||
|
||||
Reference in New Issue
Block a user