Supertonic 2

This commit is contained in:
ANLGBOY
2026-01-06 17:15:20 +09:00
parent ceedbbb835
commit e71c516714
44 changed files with 1105 additions and 581 deletions

View File

@@ -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

View File

@@ -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>>

View File

@@ -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

View File

@@ -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