feat: integrate Sherpa-ONNX local offline speech recognition engine

This commit is contained in:
shen
2026-09-15 22:22:32 +08:00
parent 82b1ecdf84
commit 9215931b13
12 changed files with 786 additions and 4 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"provider": "compatible", "provider": "compatible",
"endpoint": "https://codex.slcydia.fun/v1/responses", "endpoint": "https://kmwq8ckvr0ehsgqyudcer1.slcydia.fun/v1/responses",
"model": "gemini-3.7-flash-high", "model": "gemini-3.7-flash-high",
"reasoningEffort": "low", "reasoningEffort": "low",
"apiKey": "sk-242EMNuXYjxSEktp91E8QqS8ejGs9XImrDddIA5JHXdeCKLSUcB91vrSmhyv45pf", "apiKey": "sk-242EMNuXYjxSEktp91E8QqS8ejGs9XImrDddIA5JHXdeCKLSUcB91vrSmhyv45pf",
@@ -0,0 +1,502 @@
<blk> 0
<sos/eos> 1
<unk> 2
S 3
▁THE 4
▁A 5
T 6
▁AND 7
ED 8
▁OF 9
▁TO 10
E 11
D 12
N 13
ING 14
▁IN 15
Y 16
M 17
C 18
▁I 19
A 20
P 21
▁HE 22
R 23
O 24
L 25
RE 26
I 27
U 28
ER 29
▁IT 30
LY 31
▁THAT 32
▁WAS 33
▁ 34
▁S 35
AR 36
▁BE 37
F 38
▁C 39
IN 40
B 41
▁FOR 42
OR 43
LE 44
' 45
▁HIS 46
▁YOU 47
AL 48
▁RE 49
V 50
▁B 51
G 52
RI 53
▁E 54
▁WITH 55
▁T 56
▁AS 57
LL 58
▁P 59
▁HER 60
ST 61
▁HAD 62
▁SO 63
▁F 64
W 65
CE 66
▁IS 67
ND 68
▁NOT 69
TH 70
▁BUT 71
EN 72
▁SHE 73
▁ON 74
VE 75
ON 76
SE 77
▁DE 78
UR 79
▁G 80
CH 81
K 82
TER 83
▁AT 84
IT 85
▁ME 86
RO 87
NE 88
RA 89
ES 90
IL 91
NG 92
IC 93
▁NO 94
▁HIM 95
ENT 96
IR 97
▁WE 98
H 99
▁DO 100
▁ALL 101
▁HAVE 102
LO 103
▁BY 104
▁MY 105
▁MO 106
▁THIS 107
LA 108
▁ST 109
▁WHICH 110
▁CON 111
▁THEY 112
CK 113
TE 114
▁SAID 115
▁FROM 116
▁GO 117
▁WHO 118
▁TH 119
▁OR 120
▁D 121
▁W 122
VER 123
LI 124
▁SE 125
▁ONE 126
▁CA 127
▁AN 128
▁LA 129
▁WERE 130
EL 131
▁HA 132
▁MAN 133
▁FA 134
▁EX 135
AD 136
▁SU 137
RY 138
▁MI 139
AT 140
▁BO 141
▁WHEN 142
AN 143
THER 144
PP 145
ATION 146
▁FI 147
▁WOULD 148
▁PRO 149
OW 150
ET 151
▁O 152
▁THERE 153
▁HO 154
ION 155
▁WHAT 156
▁FE 157
▁PA 158
US 159
MENT 160
▁MA 161
UT 162
▁OUT 163
▁THEIR 164
▁IF 165
▁LI 166
▁K 167
▁WILL 168
▁ARE 169
ID 170
▁RO 171
DE 172
TION 173
▁WA 174
PE 175
▁UP 176
▁SP 177
▁PO 178
IGHT 179
▁UN 180
RU 181
▁LO 182
AS 183
OL 184
▁LE 185
▁BEEN 186
▁SH 187
▁RA 188
▁SEE 189
KE 190
UL 191
TED 192
▁SA 193
UN 194
UND 195
ANT 196
▁NE 197
IS 198
▁THEM 199
CI 200
GE 201
▁COULD 202
▁DIS 203
OM 204
ISH 205
HE 206
EST 207
▁SOME 208
ENCE 209
ITY 210
IVE 211
▁US 212
▁MORE 213
▁EN 214
ARD 215
ATE 216
▁YOUR 217
▁INTO 218
▁KNOW 219
▁CO 220
ANCE 221
▁TIME 222
▁WI 223
▁YE 224
AGE 225
▁NOW 226
TI 227
FF 228
ABLE 229
▁VERY 230
▁LIKE 231
AM 232
HI 233
Z 234
▁OTHER 235
▁THAN 236
▁LITTLE 237
▁DID 238
▁LOOK 239
TY 240
ERS 241
▁CAN 242
▁CHA 243
▁AR 244
X 245
FUL 246
UGH 247
▁BA 248
▁DAY 249
▁ABOUT 250
TEN 251
IM 252
▁ANY 253
▁PRE 254
▁OVER 255
IES 256
NESS 257
ME 258
BLE 259
▁M 260
ROW 261
▁HAS 262
▁GREAT 263
▁VI 264
TA 265
▁AFTER 266
PER 267
▁AGAIN 268
HO 269
SH 270
▁UPON 271
▁DI 272
▁HAND 273
▁COM 274
IST 275
TURE 276
▁STA 277
▁THEN 278
▁SHOULD 279
▁GA 280
OUS 281
OUR 282
▁WELL 283
▁ONLY 284
MAN 285
▁GOOD 286
▁TWO 287
▁MAR 288
▁SAY 289
▁HU 290
TING 291
▁OUR 292
RESS 293
▁DOWN 294
IOUS 295
▁BEFORE 296
▁DA 297
▁NA 298
QUI 299
▁MADE 300
▁EVERY 301
▁OLD 302
▁EVEN 303
IG 304
▁COME 305
▁GRA 306
▁RI 307
▁LONG 308
OT 309
SIDE 310
WARD 311
▁FO 312
▁WHERE 313
MO 314
LESS 315
▁SC 316
▁MUST 317
▁NEVER 318
▁HOW 319
▁CAME 320
▁SUCH 321
▁RU 322
▁TAKE 323
▁WO 324
▁CAR 325
UM 326
AK 327
▁THINK 328
▁MUCH 329
▁MISTER 330
▁MAY 331
▁JO 332
▁WAY 333
▁COMP 334
▁THOUGHT 335
▁STO 336
▁MEN 337
▁BACK 338
▁DON 339
J 340
▁LET 341
▁TRA 342
▁FIRST 343
▁JUST 344
▁VA 345
▁OWN 346
▁PLA 347
▁MAKE 348
ATED 349
▁HIMSELF 350
▁WENT 351
▁PI 352
GG 353
RING 354
▁DU 355
▁MIGHT 356
▁PART 357
▁GIVE 358
▁IMP 359
▁BU 360
▁PER 361
▁PLACE 362
▁HOUSE 363
▁THROUGH 364
IAN 365
▁SW 366
▁UNDER 367
QUE 368
▁AWAY 369
▁LOVE 370
QUA 371
▁LIFE 372
▁GET 373
▁WITHOUT 374
▁PASS 375
▁TURN 376
IGN 377
▁HEAD 378
▁MOST 379
▁THOSE 380
▁SHALL 381
▁EYES 382
▁COL 383
▁STILL 384
▁NIGHT 385
▁NOTHING 386
ITION 387
HA 388
▁TELL 389
▁WORK 390
▁LAST 391
▁NEW 392
▁FACE 393
▁HI 394
▁WORD 395
▁FOUND 396
▁COUNT 397
▁OB 398
▁WHILE 399
▁SHA 400
▁MEAN 401
▁SAW 402
▁PEOPLE 403
▁FRIEND 404
▁THREE 405
▁ROOM 406
▁SAME 407
▁THOUGH 408
▁RIGHT 409
▁CHILD 410
▁FATHER 411
▁ANOTHER 412
▁HEART 413
▁WANT 414
▁TOOK 415
OOK 416
▁LIGHT 417
▁MISSUS 418
▁OPEN 419
▁JU 420
▁ASKED 421
PORT 422
▁LEFT 423
▁JA 424
▁WORLD 425
▁HOME 426
▁WHY 427
▁ALWAYS 428
▁ANSWER 429
▁SEEMED 430
▁SOMETHING 431
▁GIRL 432
▁BECAUSE 433
▁NAME 434
▁TOLD 435
▁NI 436
▁HIGH 437
IZE 438
▁WOMAN 439
▁FOLLOW 440
▁RETURN 441
▁KNEW 442
▁EACH 443
▁KIND 444
▁JE 445
▁ACT 446
▁LU 447
▁CERTAIN 448
▁YEARS 449
▁QUITE 450
▁APPEAR 451
▁BETTER 452
▁HALF 453
▁PRESENT 454
▁PRINCE 455
SHIP 456
▁ALSO 457
▁BEGAN 458
▁HAVING 459
▁ENOUGH 460
▁PERSON 461
▁LADY 462
▁WHITE 463
▁COURSE 464
▁VOICE 465
▁SPEAK 466
▁POWER 467
▁MORNING 468
▁BETWEEN 469
▁AMONG 470
▁KEEP 471
▁WALK 472
▁MATTER 473
▁TEA 474
▁BELIEVE 475
▁SMALL 476
▁TALK 477
▁FELT 478
▁HORSE 479
▁MYSELF 480
▁SIX 481
▁HOWEVER 482
▁FULL 483
▁HERSELF 484
▁POINT 485
▁STOOD 486
▁HUNDRED 487
▁ALMOST 488
▁SINCE 489
▁LARGE 490
▁LEAVE 491
▁PERHAPS 492
▁DARK 493
▁SUDDEN 494
▁REPLIED 495
▁ANYTHING 496
▁WONDER 497
▁UNTIL 498
Q 499
#0 500
#1 501
+7
View File
@@ -1,3 +1,4 @@
import 'sherpa_stt_service.dart';
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
@@ -118,6 +119,12 @@ class AiService {
required String endpoint, required String endpoint,
required String model, required String model,
}) async { }) async {
if (filePath.toLowerCase().endsWith('.wav')) {
final localText = await SherpaSttService.instance.transcribeWav(filePath);
if (localText != null && localText.trim().isNotEmpty) {
return localText.trim();
}
}
if (provider == AiProviderType.mock) return null; if (provider == AiProviderType.mock) return null;
final file = File(filePath); final file = File(filePath);
if (!await file.exists()) return null; if (!await file.exists()) return null;
+3
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -11,6 +12,7 @@ import 'local_store.dart';
import 'seed_courses.dart'; import 'seed_courses.dart';
import 'ai_config.dart'; import 'ai_config.dart';
import 'ai_service.dart'; import 'ai_service.dart';
import 'sherpa_stt_service.dart';
class AppState extends ChangeNotifier { class AppState extends ChangeNotifier {
static const _storageKey = 'learning_state_v1'; static const _storageKey = 'learning_state_v1';
@@ -270,6 +272,7 @@ class AppState extends ChangeNotifier {
Future<void> load() async { Future<void> load() async {
try { try {
unawaited(SherpaSttService.instance.initialize());
final config = await AiConfigFile.loadFromAsset(); final config = await AiConfigFile.loadFromAsset();
if (config != null) { if (config != null) {
if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) { if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) {
@@ -0,0 +1,135 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx;
class SherpaSttService {
SherpaSttService._();
static final instance = SherpaSttService._();
sherpa_onnx.OfflineRecognizer? _recognizer;
bool _isInitialized = false;
bool _isInitializing = false;
bool get isReady => _isInitialized && _recognizer != null;
/// Initializes Sherpa-ONNX bindings and unpacks bundled model assets to local disk if needed.
Future<bool> initialize({String? nativeLibDir}) async {
if (_isInitialized) return true;
if (_isInitializing) return false;
_isInitializing = true;
try {
try {
sherpa_onnx.initBindings(nativeLibDir);
} catch (e) {
debugPrint('[SherpaSttService] initBindings warning: $e');
}
final docDir = await getApplicationDocumentsDirectory();
final modelDir = Directory('${docDir.path}/sherpa_models');
if (!await modelDir.exists()) {
await modelDir.create(recursive: true);
}
final modelFiles = [
'encoder-epoch-99-avg-1.int8.onnx',
'decoder-epoch-99-avg-1.int8.onnx',
'joiner-epoch-99-avg-1.int8.onnx',
'tokens.txt',
];
for (final filename in modelFiles) {
final targetFile = File('${modelDir.path}/$filename');
if (!await targetFile.exists() || (await targetFile.length()) == 0) {
final ByteData data = await rootBundle.load('assets/models/sherpa/$filename');
final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
await targetFile.writeAsBytes(bytes, flush: true);
}
}
final modelConfig = sherpa_onnx.OfflineModelConfig(
transducer: sherpa_onnx.OfflineTransducerModelConfig(
encoder: '${modelDir.path}/encoder-epoch-99-avg-1.int8.onnx',
decoder: '${modelDir.path}/decoder-epoch-99-avg-1.int8.onnx',
joiner: '${modelDir.path}/joiner-epoch-99-avg-1.int8.onnx',
),
tokens: '${modelDir.path}/tokens.txt',
numThreads: 2,
debug: false,
);
final recognizerConfig = sherpa_onnx.OfflineRecognizerConfig(
model: modelConfig,
feat: const sherpa_onnx.FeatureConfig(sampleRate: 16000, featureDim: 80),
);
_recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig);
_isInitialized = true;
_isInitializing = false;
debugPrint('[SherpaSttService] Local ONNX ASR engine initialized successfully.');
return true;
} catch (e, stack) {
debugPrint('[SherpaSttService] Failed to initialize local ASR engine: $e\n$stack');
_isInitializing = false;
_isInitialized = false;
return false;
}
}
/// Transcribes a local 16kHz mono WAV audio file.
Future<String?> transcribeWav(String wavPath) async {
try {
if (!_isInitialized) {
final ready = await initialize();
if (!ready || _recognizer == null) return null;
}
final file = File(wavPath);
if (!await file.exists()) {
debugPrint('[SherpaSttService] Audio file does not exist: $wavPath');
return null;
}
final wave = sherpa_onnx.readWave(wavPath);
if (wave.samples.isEmpty) {
debugPrint('[SherpaSttService] Read 0 wave samples from: $wavPath');
return null;
}
final stream = _recognizer!.createStream();
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
_recognizer!.decode(stream);
final result = _recognizer!.getResult(stream);
stream.free();
final rawText = result.text.trim();
if (rawText.isEmpty) return null;
return _cleanText(rawText);
} catch (e) {
debugPrint('[SherpaSttService] Transcribe error: $e');
return null;
}
}
/// Cleans and formats raw recognized text into natural English casing.
String _cleanText(String text) {
if (text.isEmpty) return text;
// Lowercase first to normalize uppercase model output
final lower = text.toLowerCase().trim();
if (lower.isEmpty) return lower;
// Capitalize the first letter
return lower[0].toUpperCase() + lower.substring(1);
}
void dispose() {
try {
_recognizer?.free();
} catch (_) {}
_recognizer = null;
_isInitialized = false;
}
}
+7 -3
View File
@@ -77,8 +77,12 @@ class VoiceService {
if (!await recordings.exists()) await recordings.create(recursive: true); if (!await recordings.exists()) await recordings.create(recursive: true);
final timestamp = DateTime.now().microsecondsSinceEpoch; final timestamp = DateTime.now().microsecondsSinceEpoch;
await _recorder.start( await _recorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc), const RecordConfig(
path: '${recordings.path}/practice_$timestamp.m4a', encoder: AudioEncoder.wav,
sampleRate: 16000,
numChannels: 1,
),
path: '${recordings.path}/practice_$timestamp.wav',
); );
return true; return true;
} }
@@ -118,7 +122,7 @@ class VoiceService {
if (!await recordings.exists()) return const []; if (!await recordings.exists()) return const [];
final files = await recordings final files = await recordings
.list() .list()
.where((item) => item is File && item.path.endsWith('.m4a')) .where((item) => item is File && (item.path.endsWith('.wav') || item.path.endsWith('.m4a')))
.cast<File>() .cast<File>()
.toList(); .toList();
files.sort((left, right) => right.path.compareTo(left.path)); files.sort((left, right) => right.path.compareTo(left.path));
+80
View File
@@ -624,6 +624,86 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.1"
sherpa_onnx:
dependency: "direct main"
description:
name: sherpa_onnx
sha256: b7e65d5956f8c9213fd339c68ea058cdec7ad24bd35f9bfc5c18178d3d73b2f9
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_android_arm64:
dependency: transitive
description:
name: sherpa_onnx_android_arm64
sha256: "29db5572735afb1bc29f0f95ab160f7fb38b4b3f926f48e9113457fd740264f4"
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_android_armeabi:
dependency: transitive
description:
name: sherpa_onnx_android_armeabi
sha256: fe81837bd12f67d136a456c6d2b39876caaba09edd77761cb527cb170dac2d2a
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_android_x86:
dependency: transitive
description:
name: sherpa_onnx_android_x86
sha256: "8ddbd35982d4bb15648fc818253f2711be18448db50a2edb05c56a6c745fcea7"
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_android_x86_64:
dependency: transitive
description:
name: sherpa_onnx_android_x86_64
sha256: d20c06c0edc92286609f256d04c295cad510bbb270068c0c79877f4a10f00581
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_ios:
dependency: transitive
description:
name: sherpa_onnx_ios
sha256: "044e3a614d1847c0ff9b6475201c6e5d023cd6ca6cd17f14c16332081c01e1d2"
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_linux:
dependency: transitive
description:
name: sherpa_onnx_linux
sha256: "22fb91a1c50b7bc24d93d0b67c87cf2a3a38f7564d7f14343d704a1201848960"
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_macos:
dependency: transitive
description:
name: sherpa_onnx_macos
sha256: bdedc34ac0acccee7ae73a5599f66e0081137c00467c565a92f72b67428837bb
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_web:
dependency: transitive
description:
name: sherpa_onnx_web
sha256: e25a3813eb080636280b23dd4b0098252902675158b66dceda1efba061adf5d7
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_windows:
dependency: transitive
description:
name: sherpa_onnx_windows
sha256: d91fb9c4baac2594cf1ae6955f5e9f51113d354de8c75543ea8481d431040a71
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
+2
View File
@@ -22,6 +22,7 @@ dependencies:
shared_preferences: ^2.5.4 shared_preferences: ^2.5.4
sqlite3_flutter_libs: ^0.5.39 sqlite3_flutter_libs: ^0.5.39
speech_to_text: ^7.3.0 speech_to_text: ^7.3.0
sherpa_onnx: ^1.13.8
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -32,3 +33,4 @@ flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/config/ - assets/config/
- assets/models/sherpa/
+49
View File
@@ -0,0 +1,49 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/sherpa_stt_service.dart';
import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('SherpaSttService transcribes WAV audio file accurately', () async {
const macosDir = '/Users/shen/.pub-cache/hosted/pub.dev/sherpa_onnx_macos-1.13.8/macos';
// Test direct Sherpa ASR initialization and decoding with bundled test audio
sherpa_onnx.initBindings(macosDir);
final modelConfig = sherpa_onnx.OfflineModelConfig(
transducer: const sherpa_onnx.OfflineTransducerModelConfig(
encoder: 'assets/models/sherpa/encoder-epoch-99-avg-1.int8.onnx',
decoder: 'assets/models/sherpa/decoder-epoch-99-avg-1.int8.onnx',
joiner: 'assets/models/sherpa/joiner-epoch-99-avg-1.int8.onnx',
),
tokens: 'assets/models/sherpa/tokens.txt',
numThreads: 2,
debug: false,
);
final recognizerConfig = sherpa_onnx.OfflineRecognizerConfig(
model: modelConfig,
feat: const sherpa_onnx.FeatureConfig(sampleRate: 16000, featureDim: 80),
);
final recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig);
const testWave = '/tmp/sherpa_test/sherpa-onnx-zipformer-small-en-2023-06-26/test_wavs/0.wav';
if (File(testWave).existsSync()) {
final wave = sherpa_onnx.readWave(testWave);
expect(wave.samples.isNotEmpty, isTrue);
final stream = recognizer.createStream();
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
recognizer.decode(stream);
final result = recognizer.getResult(stream);
print("Transcribed result: ${result.text}");
expect(result.text.contains("NIGHTFALL"), isTrue);
stream.free();
}
recognizer.free();
});
}