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 manager = _unifiedManager;
final activeModel = manager.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
gemmaLog(
'â ī¸ Active TTS model changed: ${_lastActiveTtsSpec!.name} â ${activeModel.name}',
);
gemmaLog('đ Closing old TTS model and creating new one...');
// Reset the singleton fields BEFORE awaiting close() so a concurrent
// createTtsModel() that interleaves during the await cannot pass the
// "all three non-null" guard 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
gemmaLog(
'âšī¸ Reusing existing TTS model instance for ${activeModel.name}',
);
return _initTtsCompleter!.future;
}
}
// In-progress guard: a concurrent createTtsModel() during the initial
// load â completer set but the model not yet published to
// _initializedTtsModel â must return the existing completer, not fall
// through and spawn a SECOND TtsWorker/native model.
if (_initTtsCompleter case Completer<SpeechSynthesizer> completer) {
return completer.future;
}
final completer = _initTtsCompleter = Completer<SpeechSynthesizer>();
try {
final filePaths = await manager.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) {
_initTtsCompleter = null;
_initializedTtsModel = null;
_lastActiveTtsSpec = null;
// Complete the completer and return its future (rather than
// rethrowing separately) so there is exactly one Future in flight for
// this call â a second, unheeded `completer.future` (as a bare
// rethrow would leave behind whenever no concurrent caller grabbed a
// reference to it first) would otherwise surface as an unhandled
// async error.
completer.completeError(e, st);
return completer.future;
}
}