createTtsModel method
Creates and returns a new SpeechSynthesizer for the active TTS model.
Uses the active TTS model set via FlutterGemma.installTts() /
modelManager.setActiveModel(). Native-only — throws on web.
Implementation
@override
Future<SpeechSynthesizer> createTtsModel({
PreferredBackend? preferredBackend,
}) async {
final activeModel = _modelManager.activeTtsModel;
if (activeModel is! TtsModelSpec) {
throw StateError(
'No active TTS model set. Use FlutterGemma.installTts() first.',
);
}
// Check if singleton exists and matches the active model
if (_initTtsCompleter != null &&
_initializedTtsModel != null &&
_lastActiveTtsSpec != null) {
if (_lastActiveTtsSpec!.name != activeModel.name) {
// Active model changed - close old model and create new one.
// Reset the singleton fields BEFORE awaiting close() so a concurrent
// createTtsModel() can't pass the guard mid-close and spawn a duplicate
// worker (which would then be orphaned → native leak). Capture the old
// model first, then close it after the reset.
final old = _initializedTtsModel;
_initTtsCompleter = null;
_initializedTtsModel = null;
_lastActiveTtsSpec = null;
await old?.close();
} else {
// Same model - return existing singleton
return _initTtsCompleter!.future;
}
}
// Return existing if initialization in progress
if (_initTtsCompleter case Completer<SpeechSynthesizer> completer) {
return completer.future;
}
final completer = _initTtsCompleter = Completer<SpeechSynthesizer>();
try {
final filePaths = await _modelManager.getModelFilePaths(activeModel);
if (filePaths == null || filePaths.isEmpty) {
throw StateError(
'Active TTS model files not found on disk. Reinstall via installTts().',
);
}
final config = RuntimeConfig(
maxTokens: 0,
modelPath: filePaths
.values
.first, // representative; TTS backend uses artifactPaths
artifactPaths: filePaths,
preferredBackend: preferredBackend,
);
final backend = TtsRegistry.instance.findFor(activeModel);
if (backend == null) {
throw StateError(
TtsRegistry.instance.hasAny
? 'No registered TTS backend can handle this model '
'(${activeModel.ttsModelType}). Registered: '
'${TtsRegistry.instance.registered.map((b) => b.name).join(", ")}.'
: 'No TTS backend registered. Pass ttsBackends: to FlutterGemma.initialize().',
);
}
gemmaLog(
'Using active TTS model: ${activeModel.name} (${filePaths.length} files)',
);
final synth = await backend.createModel(activeModel, config);
// Core owns the singleton lifecycle: track it + reset on close. The
// package-built model fires this via CloseNotifier (addCloseListener).
_initializedTtsModel = synth;
_lastActiveTtsSpec = activeModel;
synth.addCloseListener(() {
// Only reset if this close-listener still belongs to the current
// singleton — a newer model may already have replaced it (the
// model-changed branch above resets the fields synchronously).
if (identical(_initializedTtsModel, synth)) {
_initializedTtsModel = null;
_initTtsCompleter = null;
_lastActiveTtsSpec = null;
}
});
completer.complete(synth);
return synth;
} catch (e, st) {
completer.completeError(e, st);
_initTtsCompleter = null;
_initializedTtsModel = null;
_lastActiveTtsSpec = null;
// Return the completer's future (rather than a bare rethrow) so there
// is exactly one Future in flight for this call — an unheeded
// `completer.future` (left behind whenever no concurrent caller
// grabbed a reference to it first) would otherwise surface as an
// unhandled async error.
return completer.future;
}
}