createTtsModel method

  1. @override
Future<SpeechSynthesizer> createTtsModel({
  1. PreferredBackend? preferredBackend,
})
override

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;
  }
}