casawolice commited on
Commit
0f6cfdd
·
verified ·
1 Parent(s): 7249d07

Add Flutter/Dart example (production-verified) + pure-Dart BPE tokenizer

Browse files

flutter/small100_translator.dart is the actual translator class from a shipped Android/iOS S2S translator app (encoder+merged-decoder KV-cache greedy decode, verified on real devices). flutter/bpe_tokenizer.dart is a small pure-Dart SentencePiece-BPE tokenizer reading tokenizer.json directly, since Dart has no off-the-shelf HuggingFace-tokenizers binding. examples/README.md table updated to list Flutter as runnable/verified alongside Python and transformers.js.

examples/README.md CHANGED
@@ -8,14 +8,22 @@ cache), decode. 所有示例都遵循同一套 [`../USAGE.md`](../USAGE.md) 流
8
  |---|---|---|---|
9
  | **Python** | optimum / onnxruntime + `tokenizers` | [`python/translate.py`](python/translate.py) | ✅ runnable |
10
  | **transformers.js** | `@huggingface/transformers` v3 | [`transformers-js/translate.mjs`](transformers-js/translate.mjs) | ✅ runnable (Node) |
 
11
  | **Android** | onnxruntime-android + DJL `tokenizers` | [`android/Small100Translator.kt`](android/Small100Translator.kt) | 📝 reference |
12
  | **iOS** | onnxruntime-swift + swift-transformers | [`ios/Small100Translator.swift`](ios/Small100Translator.swift) | 📝 reference |
13
 
14
- Python and transformers.js are fully runnable and verified. The Android/iOS files
15
- are **reference implementations** of the same algorithm (the encoder/greedy-decode
16
- loop with KV cache is written out); wire them to your asset loading and pin the
17
- dependency versions in the file headers. Android/iOS 为参考实现,算法完整,接入时
18
- 按文件头的依赖版本对齐即可。
 
 
 
 
 
 
 
19
 
20
  The tokenizer (`tokenizer.json`) and the language-token map (`lang_tokens.json`)
21
  are identical across all platforms — that is the point of this repo.
 
8
  |---|---|---|---|
9
  | **Python** | optimum / onnxruntime + `tokenizers` | [`python/translate.py`](python/translate.py) | ✅ runnable |
10
  | **transformers.js** | `@huggingface/transformers` v3 | [`transformers-js/translate.mjs`](transformers-js/translate.mjs) | ✅ runnable (Node) |
11
+ | **Flutter** | `onnxruntime` (Dart) + bundled pure-Dart BPE tokenizer | [`flutter/small100_translator.dart`](flutter/small100_translator.dart) | ✅ runnable, shipped in production |
12
  | **Android** | onnxruntime-android + DJL `tokenizers` | [`android/Small100Translator.kt`](android/Small100Translator.kt) | 📝 reference |
13
  | **iOS** | onnxruntime-swift + swift-transformers | [`ios/Small100Translator.swift`](ios/Small100Translator.swift) | 📝 reference |
14
 
15
+ Python, transformers.js, and Flutter are fully runnable and verified the
16
+ Flutter one is the actual translator class from a shipped Android/iOS S2S
17
+ translator app (real devices, not a simulator). It also ships
18
+ [`flutter/bpe_tokenizer.dart`](flutter/bpe_tokenizer.dart): a small pure-Dart
19
+ SentencePiece-BPE tokenizer reading this repo's `tokenizer.json` directly,
20
+ since Dart has no off-the-shelf HuggingFace-tokenizers binding the way
21
+ DJL (Android) or swift-transformers (iOS) provide.
22
+
23
+ The Android/iOS files are **reference implementations** of the same algorithm
24
+ (the encoder/greedy-decode loop with KV cache is written out); wire them to
25
+ your asset loading and pin the dependency versions in the file headers.
26
+ Android/iOS 为参考实现,算法完整,接入时按文件头的依赖版本对齐即可。
27
 
28
  The tokenizer (`tokenizer.json`) and the language-token map (`lang_tokens.json`)
29
  are identical across all platforms — that is the point of this repo.
examples/flutter/bpe_tokenizer.dart ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // SentencePiece-BPE tokenizer for SMaLL-100 (M2M-100 vocab), reading this
2
+ // repo's tokenizer.json directly (vocab + merges) — no native/FFI tokenizer
3
+ // binding needed, pure Dart. Verified against the Python `tokenizers` output.
4
+ //
5
+ // pubspec.yaml: no extra dependency (dart:convert + dart:io only).
6
+ import 'dart:convert';
7
+ import 'dart:io';
8
+
9
+ /// [encode] returns the subword ids only — the caller prepends the target
10
+ /// language token and appends `</s>` (id 2, per lang_tokens.json). [decode]
11
+ /// joins content tokens back to text, skipping special tokens and turning the
12
+ /// metaspace marker `▁` into spaces.
13
+ class BpeTokenizer {
14
+ static const String _metaspace = '▁';
15
+
16
+ final Map<String, int> _vocab;
17
+ final List<String> _idToToken;
18
+ final Map<String, int> _mergeRank; // "a b" -> rank (lower = higher priority)
19
+ final Set<int> _specialIds;
20
+ final int _unkId;
21
+
22
+ BpeTokenizer._(this._vocab, this._idToToken, this._mergeRank, this._specialIds, this._unkId);
23
+
24
+ // Fullwidth (U+FFxx) forms the SentencePiece nmt_nfkc normalizer folds to
25
+ // ASCII (NFKC). CJK punctuation like `。`(U+3002) / `、`(U+3001) is NOT
26
+ // folded — leave it as-is.
27
+ static const Map<String, String> _punct = {
28
+ ',': ',', '?': '?', '!': '!', ':': ':', ';': ';',
29
+ '(': '(', ')': ')', '%': '%', ' ': ' ',
30
+ };
31
+
32
+ static Future<BpeTokenizer> fromFile(String tokenizerJsonPath) async {
33
+ final json = jsonDecode(await File(tokenizerJsonPath).readAsString()) as Map<String, dynamic>;
34
+ final model = json['model'] as Map<String, dynamic>;
35
+
36
+ final rawVocab = (model['vocab'] as Map).cast<String, dynamic>();
37
+ final vocab = <String, int>{};
38
+ var maxId = 0;
39
+ rawVocab.forEach((k, v) {
40
+ final id = (v as num).toInt();
41
+ vocab[k] = id;
42
+ if (id > maxId) maxId = id;
43
+ });
44
+ final idToToken = List<String>.filled(maxId + 1, '');
45
+ vocab.forEach((k, v) {
46
+ if (v >= 0 && v <= maxId) idToToken[v] = k;
47
+ });
48
+
49
+ final merges = model['merges'] as List<dynamic>;
50
+ final mergeRank = <String, int>{};
51
+ for (var i = 0; i < merges.length; i++) {
52
+ final m = merges[i];
53
+ final String key;
54
+ if (m is String) {
55
+ key = m; // "a b"
56
+ } else {
57
+ final pair = (m as List).cast<String>();
58
+ key = '${pair[0]} ${pair[1]}';
59
+ }
60
+ mergeRank[key] = i;
61
+ }
62
+
63
+ // Special ids (</s>, <pad>, <unk>, <s>, and the 100 __xx__ language tokens).
64
+ final specialIds = <int>{};
65
+ for (final t in (json['added_tokens'] as List<dynamic>? ?? const [])) {
66
+ final m = t as Map<String, dynamic>;
67
+ if (m['special'] == true) specialIds.add((m['id'] as num).toInt());
68
+ }
69
+ final unkId = vocab['<unk>'] ?? 3;
70
+
71
+ return BpeTokenizer._(vocab, idToToken, mergeRank, specialIds, unkId);
72
+ }
73
+
74
+ String _normalize(String text) {
75
+ final sb = StringBuffer();
76
+ for (final r in text.runes) {
77
+ final ch = String.fromCharCode(r);
78
+ sb.write(_punct[ch] ?? ch);
79
+ }
80
+ return sb.toString();
81
+ }
82
+
83
+ // Greedy BPE: repeatedly merge the adjacent pair with the lowest merge rank.
84
+ List<String> _bpe(String word) {
85
+ final symbols = word.runes.map(String.fromCharCode).toList();
86
+ if (symbols.length < 2) return symbols;
87
+ while (true) {
88
+ var bestRank = 1 << 30;
89
+ var bestI = -1;
90
+ for (var i = 0; i < symbols.length - 1; i++) {
91
+ final r = _mergeRank['${symbols[i]} ${symbols[i + 1]}'];
92
+ if (r != null && r < bestRank) {
93
+ bestRank = r;
94
+ bestI = i;
95
+ }
96
+ }
97
+ if (bestI < 0) break;
98
+ symbols[bestI] = symbols[bestI] + symbols[bestI + 1];
99
+ symbols.removeAt(bestI + 1);
100
+ }
101
+ return symbols;
102
+ }
103
+
104
+ /// Text -> subword ids (no language token, no `</s>`).
105
+ List<int> encode(String text) {
106
+ final ids = <int>[];
107
+ var prevUnk = false;
108
+ for (final piece in _normalize(text).split(RegExp(r'\s+'))) {
109
+ if (piece.isEmpty) continue;
110
+ for (final sym in _bpe(_metaspace + piece)) {
111
+ final id = _vocab[sym];
112
+ if (id == null) {
113
+ if (!prevUnk) ids.add(_unkId); // fuse_unk
114
+ prevUnk = true;
115
+ } else {
116
+ ids.add(id);
117
+ prevUnk = false;
118
+ }
119
+ }
120
+ }
121
+ return ids;
122
+ }
123
+
124
+ /// Content ids -> text (skips special tokens, `▁` -> space).
125
+ String decode(List<int> ids) {
126
+ final sb = StringBuffer();
127
+ for (final id in ids) {
128
+ if (_specialIds.contains(id)) continue;
129
+ if (id >= 0 && id < _idToToken.length) sb.write(_idToToken[id]);
130
+ }
131
+ return sb.toString().replaceAll(_metaspace, ' ').trim();
132
+ }
133
+ }
examples/flutter/small100_translator.dart ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Reference SMaLL-100 translator for Flutter (onnxruntime Dart package).
2
+ // This is the actual implementation shipped in a production S2S translator
3
+ // app (Android + iOS) — fully runnable and verified, not pseudo-code.
4
+ //
5
+ // pubspec.yaml:
6
+ // dependencies:
7
+ // onnxruntime: ^1.4.1
8
+ //
9
+ // Ship onnx/encoder_model.onnx, onnx/decoder_model_merged.onnx, tokenizer.json,
10
+ // lang_tokens.json as assets (or download them into app storage — sherpa/
11
+ // onnxruntime need real filesystem paths, not asset-bundle URIs). Implements
12
+ // the algorithm in ../USAGE.md: encode + prepend target-lang token -> encoder
13
+ // -> greedy merged-decoder loop with KV cache -> decode. Needs bpe_tokenizer.dart
14
+ // (same directory) to read tokenizer.json — Dart has no off-the-shelf
15
+ // HuggingFace-tokenizers binding, so this repo ships a small pure-Dart one.
16
+ import 'dart:convert';
17
+ import 'dart:io';
18
+ import 'dart:typed_data';
19
+
20
+ import 'package:onnxruntime/onnxruntime.dart';
21
+
22
+ import 'bpe_tokenizer.dart';
23
+
24
+ class Small100Translator {
25
+ static const int _nLayers = 3;
26
+ static const int _nHeads = 16;
27
+ static const int _headDim = 64;
28
+ static const int _decoderStart = 2;
29
+ static const int _eos = 2;
30
+ static const int _pad = 1;
31
+ static const int _maxLen = 64;
32
+
33
+ final BpeTokenizer _tok;
34
+ final Map<String, int> _langToId;
35
+ final OrtSession _encoder;
36
+ final OrtSession _decoder;
37
+
38
+ Small100Translator._(this._tok, this._langToId, this._encoder, this._decoder);
39
+
40
+ static Future<Small100Translator> create({
41
+ required String tokenizerJsonPath,
42
+ required String langTokensPath,
43
+ required String encoderPath,
44
+ required String decoderPath,
45
+ }) async {
46
+ OrtEnv.instance.init();
47
+ final tok = await BpeTokenizer.fromFile(tokenizerJsonPath);
48
+ final lt = jsonDecode(await File(langTokensPath).readAsString()) as Map<String, dynamic>;
49
+ final langToId = (lt['lang_to_id'] as Map).map((k, v) => MapEntry(k as String, (v as num).toInt()));
50
+ final opts = OrtSessionOptions()..setIntraOpNumThreads(2);
51
+ // On-device acceleration: unsupported ops silently fall back to CPU.
52
+ // Some emulators (e.g. those that run on qemu but report a real device's
53
+ // Build.FINGERPRINT) crash inside libonnxruntime.so with NNAPI enabled —
54
+ // real devices are unaffected. If you hit that, detect the emulator and
55
+ // skip appendNnapiProvider rather than disabling acceleration everywhere.
56
+ if (Platform.isAndroid) {
57
+ opts.appendNnapiProvider(NnapiFlags.useNone);
58
+ } else if (Platform.isIOS) {
59
+ opts.appendCoreMLProvider(CoreMLFlags.useNone);
60
+ }
61
+ final encoder = OrtSession.fromFile(File(encoderPath), opts);
62
+ final decoder = OrtSession.fromFile(File(decoderPath), opts);
63
+ return Small100Translator._(tok, langToId, encoder, decoder);
64
+ }
65
+
66
+ bool supportsLang(String code) => _langToId.containsKey(code);
67
+
68
+ /// Translate [text] into [tgtLang] (e.g. 'en'). Returns '' for empty input or
69
+ /// an unknown target language.
70
+ Future<String> translate(String text, String tgtLang) async {
71
+ if (text.trim().isEmpty) return '';
72
+ final langId = _langToId[tgtLang];
73
+ if (langId == null) return '';
74
+
75
+ final runOpts = OrtRunOptions();
76
+ // [tgt lang] + subwords + </s> — SMaLL-100 puts the target-language token
77
+ // on the *source* side, not via forced_bos_token_id.
78
+ final ids = <int>[langId, ..._tok.encode(text), _eos];
79
+ final seqLen = ids.length;
80
+
81
+ final inputIds = OrtValueTensor.createTensorWithDataList(Int64List.fromList(ids), [1, seqLen]);
82
+ final attnMask = OrtValueTensor.createTensorWithDataList(
83
+ Int64List.fromList(List<int>.filled(seqLen, 1)), [1, seqLen]);
84
+
85
+ final encOut = _encoder.run(runOpts, {'input_ids': inputIds, 'attention_mask': attnMask});
86
+ final encoderHidden = encOut[0]!; // frozen across steps
87
+
88
+ final past = <String, OrtValue>{};
89
+ for (var i = 0; i < _nLayers; i++) {
90
+ for (final kind in ['decoder', 'encoder']) {
91
+ for (final kv in ['key', 'value']) {
92
+ past['past_key_values.$i.$kind.$kv'] =
93
+ OrtValueTensor.createTensorWithDataList(Float32List(0), [1, _nHeads, 0, _headDim]);
94
+ }
95
+ }
96
+ }
97
+
98
+ final outIds = <int>[];
99
+ var cur = _decoderStart;
100
+ var useCache = false;
101
+
102
+ try {
103
+ for (var step = 0; step < _maxLen; step++) {
104
+ final stepInputIds = OrtValueTensor.createTensorWithDataList(Int64List.fromList([cur]), [1, 1]);
105
+ final useCacheT = OrtValueTensor.createTensorWithDataList([useCache], [1]);
106
+
107
+ final feeds = <String, OrtValue>{
108
+ 'encoder_attention_mask': attnMask,
109
+ 'input_ids': stepInputIds,
110
+ 'encoder_hidden_states': encoderHidden,
111
+ 'use_cache_branch': useCacheT,
112
+ ...past,
113
+ };
114
+
115
+ final outputs = _decoder.run(runOpts, feeds);
116
+ final outByName = <String, OrtValue?>{};
117
+ for (var i = 0; i < _decoder.outputNames.length; i++) {
118
+ outByName[_decoder.outputNames[i]] = outputs[i];
119
+ }
120
+
121
+ final next = _argmaxLastLogits(outByName['logits']!);
122
+ stepInputIds.release();
123
+ useCacheT.release();
124
+ if (next == _eos) {
125
+ _releaseOutputs(outputs, keep: {});
126
+ break;
127
+ }
128
+ outIds.add(next);
129
+ // Greedy decode can degenerate into a repeated token/short cycle on
130
+ // out-of-distribution input; bail out instead of running to _maxLen.
131
+ if (_degenerate(outIds)) {
132
+ _releaseOutputs(outputs, keep: {});
133
+ break;
134
+ }
135
+
136
+ final keep = <OrtValue>{};
137
+ for (var i = 0; i < _nLayers; i++) {
138
+ for (final kv in ['key', 'value']) {
139
+ final decKey = 'past_key_values.$i.decoder.$kv';
140
+ final newDec = outByName['present.$i.decoder.$kv']!;
141
+ past[decKey]!.release();
142
+ past[decKey] = newDec;
143
+ keep.add(newDec);
144
+ if (!useCache) {
145
+ // Encoder KV is only produced on step 1 (use_cache_branch=false),
146
+ // then frozen and reused for every later step.
147
+ final encKey = 'past_key_values.$i.encoder.$kv';
148
+ final newEnc = outByName['present.$i.encoder.$kv']!;
149
+ past[encKey]!.release();
150
+ past[encKey] = newEnc;
151
+ keep.add(newEnc);
152
+ }
153
+ }
154
+ }
155
+ _releaseOutputs(outputs, keep: keep);
156
+
157
+ cur = next;
158
+ useCache = true;
159
+ }
160
+ } finally {
161
+ for (final t in past.values) {
162
+ t.release();
163
+ }
164
+ encoderHidden.release();
165
+ inputIds.release();
166
+ attnMask.release();
167
+ runOpts.release();
168
+ }
169
+
170
+ return _tok.decode(outIds);
171
+ }
172
+
173
+ int _argmaxLastLogits(OrtValue logits) {
174
+ final v = logits.value as List;
175
+ final row = (v[0] as List).last as List; // [1, seq, vocab] -> last position
176
+ var bestIdx = 0;
177
+ var bestVal = double.negativeInfinity;
178
+ for (var i = 0; i < row.length; i++) {
179
+ if (i == _pad) continue; // never emit <pad>
180
+ final x = (row[i] as num).toDouble();
181
+ if (x > bestVal) {
182
+ bestVal = x;
183
+ bestIdx = i;
184
+ }
185
+ }
186
+ return bestIdx;
187
+ }
188
+
189
+ // Bail out of greedy decoding if it degenerates into a repeated token or a
190
+ // short repeating cycle (period 2-4) — cheap safety net beyond ../USAGE.md's
191
+ // base algorithm, worth adopting on any platform.
192
+ static bool _degenerate(List<int> ids) {
193
+ final n = ids.length;
194
+ if (n >= 6) {
195
+ var same = true;
196
+ for (var i = n - 6; i < n - 1; i++) {
197
+ if (ids[i] != ids[i + 1]) {
198
+ same = false;
199
+ break;
200
+ }
201
+ }
202
+ if (same) return true;
203
+ }
204
+ for (var p = 2; p <= 4; p++) {
205
+ if (n >= p * 3) {
206
+ var cyc = true;
207
+ for (var i = 0; i < p * 2; i++) {
208
+ if (ids[n - 1 - i] != ids[n - 1 - i - p]) {
209
+ cyc = false;
210
+ break;
211
+ }
212
+ }
213
+ if (cyc) return true;
214
+ }
215
+ }
216
+ return false;
217
+ }
218
+
219
+ void _releaseOutputs(List<OrtValue?> outputs, {required Set<OrtValue> keep}) {
220
+ for (final o in outputs) {
221
+ if (o != null && !keep.contains(o)) o.release();
222
+ }
223
+ }
224
+
225
+ void dispose() {
226
+ _encoder.release();
227
+ _decoder.release();
228
+ }
229
+ }
230
+
231
+ // Usage (e.g. from a Flutter widget's initState, after copying the asset
232
+ // files to real paths — see ModelManager-style staging in the app examples):
233
+ //
234
+ // final t = await Small100Translator.create(
235
+ // tokenizerJsonPath: '$modelsRoot/tokenizer.json',
236
+ // langTokensPath: '$modelsRoot/lang_tokens.json',
237
+ // encoderPath: '$modelsRoot/onnx/encoder_model.onnx',
238
+ // decoderPath: '$modelsRoot/onnx/decoder_model_merged.onnx',
239
+ // );
240
+ // print(await t.translate('你好,请问最近的地铁站怎么走?', 'en'));
241
+ // t.dispose();