diff --git a/packages/flutter_gemma/example/lib/home_screen.dart b/packages/flutter_gemma/example/lib/home_screen.dart index d73ad583..d0b7f1ee 100644 --- a/packages/flutter_gemma/example/lib/home_screen.dart +++ b/packages/flutter_gemma/example/lib/home_screen.dart @@ -8,7 +8,7 @@ import 'package:flutter_gemma_example/stt_models_screen.dart'; import 'package:flutter_gemma_example/translate_models_screen.dart'; import 'package:flutter_gemma_example/tts_models_screen.dart'; import 'package:flutter_gemma_example/utils/installed_model_lookup.dart'; -import 'package:flutter_gemma_example/voice_screen.dart'; +import 'package:flutter_gemma_example/voice_setup_screen.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -148,7 +148,7 @@ class _HomeScreenState extends State { subtitle: 'Speak → on-device STT → LLM → TTS → hear the reply', icon: Icons.record_voice_over, color: Colors.teal, - onTap: () => _push(const VoiceScreen()), + onTap: () => _push(const VoiceSetupScreen()), ), const SizedBox(height: 16), _NavigationCard( diff --git a/packages/flutter_gemma/example/lib/model_selection_screen.dart b/packages/flutter_gemma/example/lib/model_selection_screen.dart index a81f8f3d..69d6591f 100644 --- a/packages/flutter_gemma/example/lib/model_selection_screen.dart +++ b/packages/flutter_gemma/example/lib/model_selection_screen.dart @@ -22,7 +22,21 @@ enum SortType { } class ModelSelectionScreen extends StatefulWidget { - const ModelSelectionScreen({super.key}); + /// When set, the screen is in "pick a model" mode: tapping an entry invokes + /// this and pops (returning the choice to the caller, e.g. + /// `VoiceSetupScreen`) instead of navigating into the download/chat flow. + final ValueChanged? onSelected; + + /// Optional eligibility filter — when set, only models satisfying it are + /// listed. A pick-mode caller whose install path can't handle every catalog + /// entry uses this to offer only installable models. The Voice Loop LLM step + /// installs via `installModel(...).fromNetwork(url)` in `VoiceScreen`, which + /// can handle neither OS built-in models (no file) nor `localModel` asset + /// entries (their `url` is an `assets/...` path), so it passes + /// `(m) => !m.isBuiltIn && !m.localModel`. + final bool Function(Model model)? modelFilter; + + const ModelSelectionScreen({super.key, this.onSelected, this.modelFilter}); @override State createState() => _ModelSelectionScreenState(); @@ -95,6 +109,14 @@ class _ModelSelectionScreenState extends State { // Show all models on all platforms var models = Model.values.toList(); + // A pick-mode caller restricts the list to models its install path can + // actually handle (e.g. the Voice Loop LLM step excludes built-in + local + // asset models it cannot network-install). + final modelFilter = widget.modelFilter; + if (modelFilter != null) { + models = models.where(modelFilter).toList(); + } + // Platform-filter the OS built-in models: Gemini Nano is Android-only (ML // Kit GenAI / AICore), Apple Foundation Models are iOS/macOS-only. Both // carry localModel: true (so they escape the web/network filters and stay @@ -335,7 +357,7 @@ class _ModelSelectionScreenState extends State { itemCount: models.length, itemBuilder: (context, index) { final model = models[index]; - return ModelCard(model: model); + return ModelCard(model: model, onSelected: widget.onSelected); }, ), ), @@ -348,8 +370,9 @@ class _ModelSelectionScreenState extends State { class ModelCard extends StatefulWidget { final Model model; + final ValueChanged? onSelected; - const ModelCard({super.key, required this.model}); + const ModelCard({super.key, required this.model, this.onSelected}); @override State createState() => _ModelCardState(); @@ -502,6 +525,14 @@ class _ModelCardState extends State { ), trailing: Icon(Icons.arrow_forward_ios, color: Colors.grey[400]), onTap: () { + // Selection mode (e.g. picking the LLM step in VoiceSetupScreen): + // return the model to the caller instead of navigating into the + // download/chat flow. + if (widget.onSelected != null) { + widget.onSelected!(widget.model); + Navigator.pop(context); + return; + } // Built-in OS models: route through the download screen so its // builtIn short-circuit runs (instant bundled install + // BuiltInAi.ensureReady), surfacing availability errors before diff --git a/packages/flutter_gemma/example/lib/stt_models_screen.dart b/packages/flutter_gemma/example/lib/stt_models_screen.dart index 777759d5..47672ecb 100644 --- a/packages/flutter_gemma/example/lib/stt_models_screen.dart +++ b/packages/flutter_gemma/example/lib/stt_models_screen.dart @@ -4,10 +4,17 @@ import 'package:flutter_gemma_example/stt_screen.dart'; /// STT model selection screen — mirrors [EmbeddingModelsScreen]. Lists the /// [SttModel] catalog; picking a supported entry pushes [SttScreen], which -/// installs it (idempotent) and sets it active. Unsupported entries (need a +/// installs it (idempotent) and sets it active — unless [onSelected] is set +/// (pick-a-model mode), which returns the choice and pops instead. Unsupported +/// entries (need a /// log-mel frontend, see [SttModel.unsupportedReason]) are shown but disabled. class SttModelsScreen extends StatelessWidget { - const SttModelsScreen({super.key}); + /// When set, the screen is in "pick a model" mode: tapping a supported entry + /// invokes this and pops (returning the choice to the caller, e.g. + /// `VoiceSetupScreen`) instead of navigating into [SttScreen]. + final ValueChanged? onSelected; + + const SttModelsScreen({super.key, this.onSelected}); @override Widget build(BuildContext context) { @@ -41,7 +48,7 @@ class SttModelsScreen extends StatelessWidget { itemCount: SttModel.values.length, itemBuilder: (context, index) { final model = SttModel.values[index]; - return _SttModelCard(model: model); + return _SttModelCard(model: model, onSelected: onSelected); }, ), ), @@ -54,8 +61,9 @@ class SttModelsScreen extends StatelessWidget { class _SttModelCard extends StatelessWidget { final SttModel model; + final ValueChanged? onSelected; - const _SttModelCard({required this.model}); + const _SttModelCard({required this.model, this.onSelected}); @override Widget build(BuildContext context) { @@ -114,12 +122,20 @@ class _SttModelCard extends StatelessWidget { ? Icon(Icons.arrow_forward_ios, color: Colors.grey[400]) : const Icon(Icons.lock_outline, color: Colors.white24), onTap: model.isSupported - ? () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SttScreen(model: model), - ), - ) + ? () { + final cb = onSelected; + if (cb != null) { + cb(model); + Navigator.pop(context); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SttScreen(model: model), + ), + ); + } + } : null, ), ); diff --git a/packages/flutter_gemma/example/lib/tts_models_screen.dart b/packages/flutter_gemma/example/lib/tts_models_screen.dart index 2e50d6ef..5bae27f8 100644 --- a/packages/flutter_gemma/example/lib/tts_models_screen.dart +++ b/packages/flutter_gemma/example/lib/tts_models_screen.dart @@ -4,12 +4,19 @@ import 'package:flutter_gemma_example/tts_screen.dart'; /// TTS model selection screen — mirrors [SttModelsScreen]. Lists the /// [TtsModel] catalog; picking a supported entry pushes [TtsScreen], which -/// installs it (idempotent) and sets it active. Unsupported entries (need +/// installs it (idempotent) and sets it active — unless [onSelected] is set +/// (pick-a-model mode), which returns the choice and pops instead. Unsupported +/// entries (need /// their own `TtsModelProfile`, see [TtsModel.unsupportedReason]) are shown /// but disabled. Model selection lives here — the standard list screen — /// not in an in-screen dropdown, so TTS matches STT / Inference / Translate. class TtsModelsScreen extends StatelessWidget { - const TtsModelsScreen({super.key}); + /// When set, the screen is in "pick a model" mode: tapping a supported entry + /// invokes this and pops (returning the choice to the caller, e.g. + /// `VoiceSetupScreen`) instead of navigating into [TtsScreen]. + final ValueChanged? onSelected; + + const TtsModelsScreen({super.key, this.onSelected}); @override Widget build(BuildContext context) { @@ -43,7 +50,7 @@ class TtsModelsScreen extends StatelessWidget { itemCount: TtsModel.values.length, itemBuilder: (context, index) { final model = TtsModel.values[index]; - return _TtsModelCard(model: model); + return _TtsModelCard(model: model, onSelected: onSelected); }, ), ), @@ -56,8 +63,9 @@ class TtsModelsScreen extends StatelessWidget { class _TtsModelCard extends StatelessWidget { final TtsModel model; + final ValueChanged? onSelected; - const _TtsModelCard({required this.model}); + const _TtsModelCard({required this.model, this.onSelected}); @override Widget build(BuildContext context) { @@ -134,12 +142,20 @@ class _TtsModelCard extends StatelessWidget { ? Icon(Icons.arrow_forward_ios, color: Colors.grey[400]) : const Icon(Icons.lock_outline, color: Colors.white24), onTap: model.isSupported - ? () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => TtsScreen(model: model), - ), - ) + ? () { + final cb = onSelected; + if (cb != null) { + cb(model); + Navigator.pop(context); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TtsScreen(model: model), + ), + ); + } + } : null, ), ); diff --git a/packages/flutter_gemma/example/lib/voice_screen.dart b/packages/flutter_gemma/example/lib/voice_screen.dart index 1370ef87..cc6b4adb 100644 --- a/packages/flutter_gemma/example/lib/voice_screen.dart +++ b/packages/flutter_gemma/example/lib/voice_screen.dart @@ -21,22 +21,28 @@ import 'package:record/record.dart'; /// [VoiceSession]. Mirrors `SttScreen` for capture (AudioRecorder + 5-second /// timer + WAV parse) and `TtsScreen` for playback (AudioConverter.pcmToWav + /// just_audio), and wires both through one [VoiceSession.fromChat] built -/// from three fixed models: [SttModel.moonshineTiny] (the only STT catalog -/// entry with a shipped profile today), [_llmModel] (a small text-only chat -/// model, no tools — `VoiceSession.fromChat` requires `chat.tools.isEmpty`), -/// and [TtsModel.matcha] (the only TTS catalog entry with a shipped profile). +/// from the three models chosen upstream in `VoiceSetupScreen` and passed in: +/// [VoiceScreen.sttModel], [VoiceScreen.llmModel] (a text-only chat model — +/// no tools, since `VoiceSession.fromChat` requires `chat.tools.isEmpty`) and +/// [VoiceScreen.ttsModel]. The upstream defaults reproduce the original trio +/// (Moonshine Tiny / Gemma 3 1B IT / Matcha-TTS). class VoiceScreen extends StatefulWidget { - const VoiceScreen({super.key}); + final SttModel sttModel; + final Model llmModel; + final TtsModel ttsModel; + + const VoiceScreen({ + super.key, + required this.sttModel, + required this.llmModel, + required this.ttsModel, + }); @override State createState() => _VoiceScreenState(); } class _VoiceScreenState extends State { - static const _sttModel = SttModel.moonshineTiny; - static const _llmModel = Model.gemma3_1B; - static const _ttsModel = TtsModel.matcha; - SpeechRecognizer? _recognizer; SpeechSynthesizer? _synth; InferenceChat? _chat; @@ -86,9 +92,22 @@ class _VoiceScreenState extends State { /// the [VoiceSession]. Mirrors `SttScreen._initializeSttModel` / /// `TtsScreen._initializeTtsModel`. Future _initializeVoiceSession() async { + // These are promoted to the state fields only in the terminal setState + // below; until then dispose() can't reclaim them, so an early return / a + // failure must close whatever was already activated — otherwise the + // STT/TTS/LLM sessions leak on every failed init and retry. + SpeechRecognizer? recognizer; + SpeechSynthesizer? synth; + InferenceChat? chat; + Future closePartial() async { + await recognizer?.close(); + await synth?.close(); + await chat?.close(); + } + try { // --- STT --- - final sttToken = _sttModel.needsAuth + final sttToken = widget.sttModel.needsAuth ? await AuthTokenService.loadToken() : null; if (!mounted) return; @@ -97,9 +116,9 @@ class _VoiceScreenState extends State { _downloadPercent = null; }); await FlutterGemma.installStt() - .modelFromNetwork(_sttModel.modelUrl, token: sttToken) - .tokenizerFromNetwork(_sttModel.tokenizerUrl, token: sttToken) - .ofType(_sttModel.sttModelType) + .modelFromNetwork(widget.sttModel.modelUrl, token: sttToken) + .tokenizerFromNetwork(widget.sttModel.tokenizerUrl, token: sttToken) + .ofType(widget.sttModel.sttModelType) .withModelProgress((percent) { if (!mounted) return; setState(() => _downloadPercent = percent); @@ -109,54 +128,62 @@ class _VoiceScreenState extends State { setState(() => _downloadPercent = percent); }) .install(); - final recognizer = await FlutterGemma.getActiveStt(); + recognizer = await FlutterGemma.getActiveStt(); // --- TTS --- - if (!mounted) return; + if (!mounted) { + await closePartial(); + return; + } setState(() { _stage = 'Downloading voice model'; _downloadPercent = null; }); await FlutterGemma.installTts() - .fromNetwork(_ttsModel.baseUrl) - .ofType(_ttsModel.ttsModelType) + .fromNetwork(widget.ttsModel.baseUrl) + .ofType(widget.ttsModel.ttsModelType) .withProgress((percent) { if (!mounted) return; setState(() => _downloadPercent = percent); }) .install(); - final synth = await FlutterGemma.getActiveTts(); + synth = await FlutterGemma.getActiveTts(); // --- LLM (no tools — see class doc) --- String? llmToken; - if (_llmModel.needsAuth) { + if (widget.llmModel.needsAuth) { llmToken = await AuthTokenService.loadToken(); } - if (!mounted) return; + if (!mounted) { + await closePartial(); + return; + } setState(() { _stage = 'Downloading language model'; _downloadPercent = null; }); await FlutterGemma.installModel( - modelType: _llmModel.modelType, - fileType: _llmModel.fileType, - ).fromNetwork(_llmModel.url, token: llmToken).withProgress((percent) { + modelType: widget.llmModel.modelType, + fileType: widget.llmModel.fileType, + ).fromNetwork(widget.llmModel.url, token: llmToken).withProgress(( + percent, + ) { if (!mounted) return; setState(() => _downloadPercent = percent); }).install(); final model = await FlutterGemma.getActiveModel( - maxTokens: _llmModel.maxTokens, - preferredBackend: _llmModel.preferredBackend, + maxTokens: widget.llmModel.maxTokens, + preferredBackend: widget.llmModel.preferredBackend, ); - final chat = await model.createChat( - temperature: _llmModel.temperature, + chat = await model.createChat( + temperature: widget.llmModel.temperature, randomSeed: 1, - topK: _llmModel.topK, - topP: _llmModel.topP, + topK: widget.llmModel.topK, + topP: widget.llmModel.topP, tokenBuffer: 256, tools: const [], - modelType: _llmModel.modelType, + modelType: widget.llmModel.modelType, maxOutputTokens: 128, systemInstruction: 'Reply concisely in one or two short sentences; your reply will ' @@ -169,7 +196,10 @@ class _VoiceScreenState extends State { synthesizer: synth, ); - if (!mounted) return; + if (!mounted) { + await closePartial(); + return; + } setState(() { _recognizer = recognizer; _synth = synth; @@ -178,6 +208,9 @@ class _VoiceScreenState extends State { _isInitializing = false; }); } catch (e) { + // Close whatever activated before the failure (dispose() only reclaims + // resources once they've been promoted to the state fields). + await closePartial(); if (kDebugMode) { debugPrint('[VoiceScreen] Could not initialize voice session: $e'); } @@ -293,8 +326,9 @@ class _VoiceScreenState extends State { if (!kIsWeb && (platformIsAndroid || platformIsIOS)) { final status = await Permission.microphone.request(); - if (!mounted) + if (!mounted) { return; // a permission dialog is a classic navigate-away gap + } if (!status.isGranted) { scaffoldMessenger.showSnackBar( const SnackBar( @@ -431,9 +465,9 @@ class _VoiceScreenState extends State { ), ), const SizedBox(height: 12), - _buildInfoRow('STT:', _sttModel.displayName), - _buildInfoRow('LLM:', _llmModel.displayName), - _buildInfoRow('TTS:', _ttsModel.displayName), + _buildInfoRow('STT:', widget.sttModel.displayName), + _buildInfoRow('LLM:', widget.llmModel.displayName), + _buildInfoRow('TTS:', widget.ttsModel.displayName), ], ), ), diff --git a/packages/flutter_gemma/example/lib/voice_setup_screen.dart b/packages/flutter_gemma/example/lib/voice_setup_screen.dart new file mode 100644 index 00000000..43cebec2 --- /dev/null +++ b/packages/flutter_gemma/example/lib/voice_setup_screen.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gemma_example/model_selection_screen.dart'; +import 'package:flutter_gemma_example/models/model.dart'; +import 'package:flutter_gemma_example/models/stt_model.dart'; +import 'package:flutter_gemma_example/models/tts_model.dart'; +import 'package:flutter_gemma_example/stt_models_screen.dart'; +import 'package:flutter_gemma_example/tts_models_screen.dart'; +import 'package:flutter_gemma_example/voice_screen.dart'; + +/// Voice Loop setup — pick the model for each of the three pipeline steps +/// (STT -> LLM -> TTS) via the standard per-modality list screens, then start +/// the loop. Mirrors how model selection works everywhere else in the example +/// (Inference / STT / TTS / Translate): selection lives in a list screen, not +/// an in-screen dropdown. Each row pushes the matching selection screen +/// (`SttModelsScreen` / `ModelSelectionScreen` / `TtsModelsScreen`) in +/// selection mode (its `onSelected` callback); for STT/TTS only entries with a +/// shipped profile are pickable, whereas any LLM is pickable. Defaults +/// reproduce the previously-hardcoded trio (Moonshine Tiny / Gemma 3 1B IT / +/// Matcha-TTS), so the loop's behaviour is unchanged until the user swaps a +/// step. +class VoiceSetupScreen extends StatefulWidget { + const VoiceSetupScreen({super.key}); + + @override + State createState() => _VoiceSetupScreenState(); +} + +class _VoiceSetupScreenState extends State { + SttModel _stt = SttModel.moonshineTiny; + Model _llm = Model.gemma3_1B; + TtsModel _tts = TtsModel.matcha; + + Future _pickStt() => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + SttModelsScreen(onSelected: (m) => setState(() => _stt = m)), + ), + ); + + Future _pickLlm() => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ModelSelectionScreen( + // The loop installs the LLM via installModel(...).fromNetwork(url), so + // offer only network-installable models: no OS built-ins (no file) and + // no localModel asset entries (their url is an assets/... path). + modelFilter: (m) => !m.isBuiltIn && !m.localModel, + onSelected: (m) => setState(() => _llm = m), + ), + ), + ); + + Future _pickTts() => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + TtsModelsScreen(onSelected: (m) => setState(() => _tts = m)), + ), + ); + + void _start() { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + VoiceScreen(sttModel: _stt, llmModel: _llm, ttsModel: _tts), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0b2351), + appBar: AppBar( + title: const Text('Voice Loop'), + backgroundColor: const Color(0xFF0b2351), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Pick a model for each step', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 8), + const Text( + 'STT → LLM → TTS. Tap a step to choose its model, then start the ' + 'loop.', + style: TextStyle(fontSize: 14, color: Colors.white70), + ), + const SizedBox(height: 24), + Card( + color: const Color(0xFF1a3a5c), + child: Column( + children: [ + _stepRow('STT', _stt.displayName, _pickStt), + const Divider(height: 1, color: Colors.white12), + _stepRow('LLM', _llm.displayName, _pickLlm), + const Divider(height: 1, color: Colors.white12), + _stepRow('TTS', _tts.displayName, _pickTts), + ], + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _start, + icon: const Icon(Icons.play_arrow), + label: const Text('Start Voice Loop'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF2a5a8c), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _stepRow(String step, String modelName, VoidCallback onTap) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: SizedBox( + width: 44, + child: Text( + step, + style: const TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w600, + ), + ), + ), + title: Text( + modelName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + trailing: Icon( + Icons.arrow_forward_ios, + size: 16, + color: Colors.grey[400], + ), + onTap: onTap, + ); + } +} diff --git a/packages/flutter_gemma/example/test/voice_setup_test.dart b/packages/flutter_gemma/example/test/voice_setup_test.dart new file mode 100644 index 00000000..8d045c36 --- /dev/null +++ b/packages/flutter_gemma/example/test/voice_setup_test.dart @@ -0,0 +1,59 @@ +// Device-free widget tests for the Voice Loop setup: the default model trio, +// and the LLM picker's `modelFilter` (it must not offer models the loop can't +// network-install — built-in or localModel asset entries). Pure navigation / +// filtering, no model download / FFI / device. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_gemma_example/model_selection_screen.dart'; +import 'package:flutter_gemma_example/voice_setup_screen.dart'; + +void main() { + testWidgets('VoiceSetupScreen renders the default STT / LLM / TTS trio', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: VoiceSetupScreen())); + // Defaults are SttModel.moonshineTiny / Model.gemma3_1B / TtsModel.matcha. + expect(find.text('Moonshine Tiny'), findsOneWidget); + expect(find.text('Gemma 3 1B IT'), findsOneWidget); + expect(find.text('Matcha-TTS'), findsOneWidget); + }); + + testWidgets( + 'ModelSelectionScreen.modelFilter excludes localModel asset entries', + (tester) async { + // `localModel` entries are kept by every platform filter, so no platform + // override is needed. Keep ONLY the excluded categories first: the + // "(Local)" card is in a short list, so it renders — proving the entry + // exists in the catalog. + await tester.pumpWidget( + MaterialApp( + home: ModelSelectionScreen(modelFilter: (m) => m.localModel), + ), + ); + await tester.pumpAndSettle(); + expect( + find.text('Gemma 3 1B IT (Local)'), + findsOneWidget, + reason: 'the local-asset LLM exists in the catalog', + ); + + // The exact filter the Voice Loop LLM picker uses: the "(Local)" entry is + // filtered OUT of the list entirely (never built, regardless of scroll). + await tester.pumpWidget( + MaterialApp( + home: ModelSelectionScreen( + modelFilter: (m) => !m.isBuiltIn && !m.localModel, + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.text('Gemma 3 1B IT (Local)'), + findsNothing, + reason: + 'the Voice Loop LLM picker must not offer non-network-installable ' + 'models — it installs via installModel(...).fromNetwork(url)', + ); + }, + ); +}