feat: integrate Sherpa-ONNX local offline speech recognition engine

This commit is contained in:
shen
2026-09-15 22:22:32 +08:00
parent db7ec7404f
commit be5075fa3d
12 changed files with 786 additions and 4 deletions
+7
View File
@@ -1,3 +1,4 @@
import 'sherpa_stt_service.dart';
import 'dart:io';
import 'dart:convert';
@@ -118,6 +119,12 @@ class AiService {
required String endpoint,
required String model,
}) 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;
final file = File(filePath);
if (!await file.exists()) return null;
+3
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
@@ -11,6 +12,7 @@ import 'local_store.dart';
import 'seed_courses.dart';
import 'ai_config.dart';
import 'ai_service.dart';
import 'sherpa_stt_service.dart';
class AppState extends ChangeNotifier {
static const _storageKey = 'learning_state_v1';
@@ -270,6 +272,7 @@ class AppState extends ChangeNotifier {
Future<void> load() async {
try {
unawaited(SherpaSttService.instance.initialize());
final config = await AiConfigFile.loadFromAsset();
if (config != null) {
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);
final timestamp = DateTime.now().microsecondsSinceEpoch;
await _recorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc),
path: '${recordings.path}/practice_$timestamp.m4a',
const RecordConfig(
encoder: AudioEncoder.wav,
sampleRate: 16000,
numChannels: 1,
),
path: '${recordings.path}/practice_$timestamp.wav',
);
return true;
}
@@ -118,7 +122,7 @@ class VoiceService {
if (!await recordings.exists()) return const [];
final files = await recordings
.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>()
.toList();
files.sort((left, right) => right.path.compareTo(left.path));