createSttModel method
- String? modelPath,
- String? tokenizerPath,
- PreferredBackend? preferredBackend,
- String? language,
Creates and returns a new SpeechRecognizer instance.
Modern API: If paths are not provided, uses the active STT model set via
FlutterGemma.installStt() or modelManager.setActiveModel().
modelPath — path to the STT model file (optional if active model set).
tokenizerPath — path to the tokenizer file (optional if active model set).
preferredBackend — backend preference (e.g., CPU, GPU).
language — the OUTPUT language for transcripts, Whisper only: a bare
lowercase code ('en', 'de', …). It sets SpeechRecognizer.language,
the default for calls to SpeechRecognizer.transcribe that pass none, and
it RETARGETS the recognizer when one already exists — this returns a
process-wide singleton, so a language fixed at construction would take
effect only on the first call in a process. Throws ArgumentError for a
malformed code, or for any language on a model with no language token
(moonshine, parakeet). null leaves the model's own default in place.
Implementation
@override
Future<SpeechRecognizer> createSttModel({
String? modelPath,
String? tokenizerPath,
PreferredBackend? preferredBackend,
String? language,
}) async {
// Modern API: Use active STT model if paths not provided
if (modelPath == null || tokenizerPath == null) {
final manager = _unifiedManager;
final activeModel = manager.activeSttModel;
// No active STT model - user must set one first
if (activeModel == null) {
throw StateError(
'No active STT model set. Use `FlutterGemma.installStt()` or `modelManager.setActiveModel()` to set a model first',
);
}
// Get the actual model file paths through unified system
final modelFilePaths = await manager.getModelFilePaths(activeModel);
if (modelFilePaths == null || modelFilePaths.isEmpty) {
throw StateError(
'STT model file paths not found. Use the `modelManager` to load the model first',
);
}
// Extract model and tokenizer paths from spec
final activeModelPath = modelFilePaths[PreferencesKeys.sttModelFile];
final activeTokenizerPath =
modelFilePaths[PreferencesKeys.sttTokenizerFile];
if (activeModelPath == null || activeTokenizerPath == null) {
throw StateError(
'Could not find model or tokenizer path in active STT model',
);
}
// Check if singleton exists and matches the active model
if (_initSttCompleter != null &&
_initializedSttModel != null &&
_lastActiveSttSpec != null) {
final currentSpec = _lastActiveSttSpec!;
final requestedSpec = activeModel as SttModelSpec;
if (currentSpec.name != requestedSpec.name) {
// Active model changed - close old model and create new one
gemmaLog(
'⚠️ Active STT model changed: ${currentSpec.name} → ${requestedSpec.name}',
);
gemmaLog('🔄 Closing old STT model and creating new one...');
await _initializedSttModel?.close();
// Reset explicitly (mirror the desktop shell) instead of relying on
// the async close-listener, so the in-progress guard below cannot
// return the completer that is being torn down.
_initSttCompleter = null;
_initializedSttModel = null;
_lastActiveSttSpec = null;
} else {
// Same model - return existing singleton, RETARGETED to the requested
// language.
//
// Load-bearing, and the reason `SpeechRecognizer.language` is
// settable: this branch is the common case (a recognizer built at
// startup, a language picked later), and without the assignment the
// caller gets back the recognizer built for the FIRST language and
// transcribes into it with no error — a documented parameter that
// works exactly once per process. Whisper's decoder prompt is rebuilt
// per transcription, so this is a field write, not a reload.
gemmaLog(
'ℹ️ Reusing existing STT model instance for ${requestedSpec.name}',
);
final cached = await _initSttCompleter!.future;
cached.language = language;
return cached;
}
}
modelPath = activeModelPath;
tokenizerPath = activeTokenizerPath;
gemmaLog('Using active STT model: $modelPath, tokenizer: $tokenizerPath');
} else {
// Legacy API with explicit paths - check if singleton exists
if (_initSttCompleter case Completer<SpeechRecognizer> completer) {
gemmaLog('ℹ️ Reusing existing STT model instance (Legacy API)');
// `createSttModel`'s dartdoc promises it retargets an existing
// recognizer. On this arm it did not, so a second explicit-paths call
// with a new language returned the first one's, silently.
final cached = await completer.future;
cached.language = language;
return cached;
}
}
// In-progress guard (Modern-API path): a concurrent createSttModel() during
// the initial load — completer set but the model not yet published to
// _initializedSttModel — must return the existing completer, not fall
// through and spawn a SECOND SttWorker/native model. Mirrors the desktop
// shell (which the Modern-API branch above otherwise lacked).
if (_initSttCompleter case Completer<SpeechRecognizer> completer) {
// A caller arriving DURING the first load gets that load's recognizer —
// so it must still be retargeted, or a language picked while the model is
// still loading is dropped with no error. The window is wide: an isolate
// spawn plus a ~51k-entry tokenizer parse plus a model compile.
final cached = await completer.future;
cached.language = language;
return cached;
}
final completer = _initSttCompleter = Completer<SpeechRecognizer>();
// Verify the active model is still installed (for Modern API path)
final manager = _unifiedManager;
final activeModel = manager.activeSttModel;
if (activeModel != null) {
final isModelInstalled = await manager.isModelInstalled(activeModel);
if (!isModelInstalled) {
completer.completeError(
Exception(
'Active STT model is no longer installed. Use the `modelManager` to load the model first',
),
);
return completer.future;
}
}
try {
// Dispatches construction through the SttRegistry (probe-chain, mirrors
// EmbeddingRegistry). The backend reads spec.sttModelType to select its
// runtime profile, and ONLY config.modelPath/config.tokenizerPath for
// path resolution — it ignores the spec arg for path resolution (see
// LiteRtSttBackend.createModel).
final activeSpec =
activeModel as SttModelSpec?; // null on legacy explicit-paths
final SttBackendProvider? backend = activeSpec != null
? SttRegistry.instance.findFor(activeSpec)
: (SttRegistry.instance.registered.isNotEmpty
? SttRegistry.instance.registered.first
: null);
if (backend == null) {
throw StateError(
'No STT backend registered. Add flutter_gemma_speech to '
'pubspec.yaml and pass it in sttBackends: of '
'FlutterGemma.initialize(...). Registered backends: '
'${SttRegistry.instance.registered.map((b) => b.name).join(", ")}.',
);
}
// modelPath/tokenizerPath are non-null here (resolved in the preamble or
// passed by the legacy API). maxTokens is unused by STT.
final sttConfig = RuntimeConfig(
maxTokens: 0,
modelPath: modelPath,
tokenizerPath: tokenizerPath,
preferredBackend: preferredBackend,
// Whisper's output language. Dropping it here is invisible: the
// recognizer still works and still returns text, just always in
// English, because the profile falls back to its `<|en|>` default.
language: language,
);
// The backend's createModel(spec, config) signature requires a non-null
// spec, but it resolves paths exclusively from config. On the legacy
// explicit-paths path there is no active spec, so synthesize one from the
// resolved file paths (FileSource, mobile/desktop only — web swaps in its
// own plugin) purely to satisfy the signature. sttModelType defaults to
// moonshine — the only shipped profile — for this legacy-path fallback.
final specForBackend =
activeSpec ??
SttModelSpec(
name: 'legacy:${path.basename(modelPath)}',
modelSource: ModelSource.file(modelPath),
tokenizerSource: ModelSource.file(tokenizerPath),
sttModelType: SttModelType.moonshine,
);
final model = await backend.createModel(specForBackend, sttConfig);
// Core owns the singleton lifecycle: track it + reset on close. The
// package-built model fires this via CloseNotifier (addCloseListener).
_initializedSttModel = model;
model.addCloseListener(() {
_initializedSttModel = null;
_initSttCompleter = null;
_lastActiveSttSpec = null;
});
// Save the spec that was used to create this model (Modern API path only)
if (activeSpec != null) {
_lastActiveSttSpec = activeSpec;
}
completer.complete(model);
return model;
} catch (e, st) {
// FIX #170: Reset state to allow retry with different model
_initSttCompleter = null;
_initializedSttModel = null;
_lastActiveSttSpec = null;
completer.completeError(e, st);
// Return the error-completed completer future (not a separate throw) so
// exactly one Future is in flight — a bare throw would orphan
// completer.future (no listener in the single-caller path) → spurious
// unhandled-async. Mirrors createTtsModel. See #394.
return completer.future;
}
}