feat: upgrade local speech recognition engine to SenseVoice-Small with hardware audio enhancements

This commit is contained in:
shen
2026-09-15 22:36:10 +08:00
parent be5075fa3d
commit 042e867521
10 changed files with 25090 additions and 532 deletions
File diff suppressed because it is too large Load Diff
@@ -1,502 +0,0 @@
<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
+22 -19
View File
@@ -15,7 +15,7 @@ class SherpaSttService {
bool get isReady => _isInitialized && _recognizer != null; bool get isReady => _isInitialized && _recognizer != null;
/// Initializes Sherpa-ONNX bindings and unpacks bundled model assets to local disk if needed. /// Initializes SenseVoice-Small ONNX bindings and unpacks bundled model assets to local disk if needed.
Future<bool> initialize({String? nativeLibDir}) async { Future<bool> initialize({String? nativeLibDir}) async {
if (_isInitialized) return true; if (_isInitialized) return true;
if (_isInitializing) return false; if (_isInitializing) return false;
@@ -29,32 +29,30 @@ class SherpaSttService {
} }
final docDir = await getApplicationDocumentsDirectory(); final docDir = await getApplicationDocumentsDirectory();
final modelDir = Directory('${docDir.path}/sherpa_models'); final modelDir = Directory('${docDir.path}/sense_voice_models');
if (!await modelDir.exists()) { if (!await modelDir.exists()) {
await modelDir.create(recursive: true); await modelDir.create(recursive: true);
} }
final modelFiles = [ final modelFiles = [
'encoder-epoch-99-avg-1.int8.onnx', 'model.int8.onnx',
'decoder-epoch-99-avg-1.int8.onnx',
'joiner-epoch-99-avg-1.int8.onnx',
'tokens.txt', 'tokens.txt',
]; ];
for (final filename in modelFiles) { for (final filename in modelFiles) {
final targetFile = File('${modelDir.path}/$filename'); final targetFile = File('${modelDir.path}/$filename');
if (!await targetFile.exists() || (await targetFile.length()) == 0) { if (!await targetFile.exists() || (await targetFile.length()) == 0) {
final ByteData data = await rootBundle.load('assets/models/sherpa/$filename'); final ByteData data = await rootBundle.load('assets/models/sense_voice/$filename');
final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
await targetFile.writeAsBytes(bytes, flush: true); await targetFile.writeAsBytes(bytes, flush: true);
} }
} }
final modelConfig = sherpa_onnx.OfflineModelConfig( final modelConfig = sherpa_onnx.OfflineModelConfig(
transducer: sherpa_onnx.OfflineTransducerModelConfig( senseVoice: sherpa_onnx.OfflineSenseVoiceModelConfig(
encoder: '${modelDir.path}/encoder-epoch-99-avg-1.int8.onnx', model: '${modelDir.path}/model.int8.onnx',
decoder: '${modelDir.path}/decoder-epoch-99-avg-1.int8.onnx', language: 'auto',
joiner: '${modelDir.path}/joiner-epoch-99-avg-1.int8.onnx', useInverseTextNormalization: true,
), ),
tokens: '${modelDir.path}/tokens.txt', tokens: '${modelDir.path}/tokens.txt',
numThreads: 2, numThreads: 2,
@@ -69,17 +67,17 @@ class SherpaSttService {
_recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig); _recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig);
_isInitialized = true; _isInitialized = true;
_isInitializing = false; _isInitializing = false;
debugPrint('[SherpaSttService] Local ONNX ASR engine initialized successfully.'); debugPrint('[SherpaSttService] SenseVoice-Small ONNX ASR engine initialized successfully.');
return true; return true;
} catch (e, stack) { } catch (e, stack) {
debugPrint('[SherpaSttService] Failed to initialize local ASR engine: $e\n$stack'); debugPrint('[SherpaSttService] Failed to initialize SenseVoice ASR engine: $e\n$stack');
_isInitializing = false; _isInitializing = false;
_isInitialized = false; _isInitialized = false;
return false; return false;
} }
} }
/// Transcribes a local 16kHz mono WAV audio file. /// Transcribes a local 16kHz mono WAV audio file using SenseVoice-Small.
Future<String?> transcribeWav(String wavPath) async { Future<String?> transcribeWav(String wavPath) async {
try { try {
if (!_isInitialized) { if (!_isInitialized) {
@@ -115,14 +113,19 @@ class SherpaSttService {
} }
} }
/// Cleans and formats raw recognized text into natural English casing. /// Cleans and formats raw recognized SenseVoice text (strips emotion/event tags, normalizes casing).
String _cleanText(String text) { String _cleanText(String text) {
if (text.isEmpty) return text; if (text.isEmpty) return text;
// Lowercase first to normalize uppercase model output // Strip SenseVoice special tags like <|zh|>, <|en|>, <|NEUTRAL|>, <|HAPPY|>, <|Speech|>, <|withitn|>, <|woitn|>, etc.
final lower = text.toLowerCase().trim(); var cleaned = text.replaceAll(RegExp(r'<\|[a-zA-Z0-9_\-\s]+\|>'), '').trim();
if (lower.isEmpty) return lower; if (cleaned.isEmpty) return cleaned;
// Capitalize the first letter // Normalize consecutive spaces
return lower[0].toUpperCase() + lower.substring(1); cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
// Capitalize first character if it's a letter
if (cleaned.isNotEmpty && cleaned[0].toLowerCase() != cleaned[0].toUpperCase()) {
cleaned = cleaned[0].toUpperCase() + cleaned.substring(1);
}
return cleaned;
} }
void dispose() { void dispose() {
@@ -81,6 +81,9 @@ class VoiceService {
encoder: AudioEncoder.wav, encoder: AudioEncoder.wav,
sampleRate: 16000, sampleRate: 16000,
numChannels: 1, numChannels: 1,
noiseSuppress: true,
echoCancel: true,
autoGain: true,
), ),
path: '${recordings.path}/practice_$timestamp.wav', path: '${recordings.path}/practice_$timestamp.wav',
); );
+1 -1
View File
@@ -33,4 +33,4 @@ flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/config/ - assets/config/
- assets/models/sherpa/ - assets/models/sense_voice/
+9 -10
View File
@@ -1,5 +1,4 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/sherpa_stt_service.dart'; import 'package:kouyu_english/core/sherpa_stt_service.dart';
import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx; import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx;
@@ -7,19 +6,19 @@ import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx;
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
test('SherpaSttService transcribes WAV audio file accurately', () async { test('SenseVoice-Small ONNX transcribes WAV audio file accurately', () async {
const macosDir = '/Users/shen/.pub-cache/hosted/pub.dev/sherpa_onnx_macos-1.13.8/macos'; 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 // Test direct Sherpa ASR initialization and decoding with SenseVoice
sherpa_onnx.initBindings(macosDir); sherpa_onnx.initBindings(macosDir);
final modelConfig = sherpa_onnx.OfflineModelConfig( final modelConfig = sherpa_onnx.OfflineModelConfig(
transducer: const sherpa_onnx.OfflineTransducerModelConfig( senseVoice: const sherpa_onnx.OfflineSenseVoiceModelConfig(
encoder: 'assets/models/sherpa/encoder-epoch-99-avg-1.int8.onnx', model: 'assets/models/sense_voice/model.int8.onnx',
decoder: 'assets/models/sherpa/decoder-epoch-99-avg-1.int8.onnx', language: 'auto',
joiner: 'assets/models/sherpa/joiner-epoch-99-avg-1.int8.onnx', useInverseTextNormalization: true,
), ),
tokens: 'assets/models/sherpa/tokens.txt', tokens: 'assets/models/sense_voice/tokens.txt',
numThreads: 2, numThreads: 2,
debug: false, debug: false,
); );
@@ -40,8 +39,8 @@ void main() {
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate); stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
recognizer.decode(stream); recognizer.decode(stream);
final result = recognizer.getResult(stream); final result = recognizer.getResult(stream);
print("Transcribed result: ${result.text}"); print("SenseVoice transcribed result: ${result.text}");
expect(result.text.contains("NIGHTFALL"), isTrue); expect(result.text.toLowerCase().contains("nightfall"), isTrue);
stream.free(); stream.free();
} }
recognizer.free(); recognizer.free();