feat: integrate Sherpa-ONNX local offline speech recognition engine
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user