From 3ed82cd91f90328c7b8ce8d1477f8d35c09dc682 Mon Sep 17 00:00:00 2001 From: Sasha Denisov Date: Fri, 7 Aug 2026 19:42:56 +0800 Subject: [PATCH 1/3] feat(example): per-step model selection for the Voice Loop (STT / LLM / TTS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Voice Loop hardcoded its three models (Moonshine Tiny / Gemma 3 1B IT / Matcha-TTS). Let the user pick each step via the standard per-modality list screens — same pattern as everywhere else, no in-screen dropdowns. - SttModelsScreen / TtsModelsScreen / ModelSelectionScreen gain an optional onSelected callback: in pick mode, tapping an entry returns it and pops instead of navigating into the working screen. - new VoiceSetupScreen: three tappable rows (STT/LLM/TTS) each opening the matching list in selection mode, plus a Start button. Defaults reproduce the previous trio, so behaviour is unchanged until a step is swapped. - VoiceScreen now takes required sttModel/llmModel/ttsModel (drops the hardcoded statics). - home_screen: Voice Loop routes to VoiceSetupScreen. - drive-by: wrap a pre-existing multi-line if (!mounted) return; in braces to clear its lint. Example-only; no package/public-API changes. --- .../example/lib/home_screen.dart | 4 +- .../example/lib/model_selection_screen.dart | 20 ++- .../example/lib/stt_models_screen.dart | 32 ++-- .../example/lib/tts_models_screen.dart | 32 ++-- .../example/lib/voice_screen.dart | 67 ++++---- .../example/lib/voice_setup_screen.dart | 153 ++++++++++++++++++ 6 files changed, 256 insertions(+), 52 deletions(-) create mode 100644 packages/flutter_gemma/example/lib/voice_setup_screen.dart 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..5aae46d2 100644 --- a/packages/flutter_gemma/example/lib/model_selection_screen.dart +++ b/packages/flutter_gemma/example/lib/model_selection_screen.dart @@ -22,7 +22,12 @@ 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; + + const ModelSelectionScreen({super.key, this.onSelected}); @override State createState() => _ModelSelectionScreenState(); @@ -335,7 +340,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 +353,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 +508,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..d1e93dbd 100644 --- a/packages/flutter_gemma/example/lib/stt_models_screen.dart +++ b/packages/flutter_gemma/example/lib/stt_models_screen.dart @@ -7,7 +7,12 @@ import 'package:flutter_gemma_example/stt_screen.dart'; /// installs it (idempotent) and sets it active. 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 +46,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 +59,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 +120,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..d3f55759 100644 --- a/packages/flutter_gemma/example/lib/tts_models_screen.dart +++ b/packages/flutter_gemma/example/lib/tts_models_screen.dart @@ -9,7 +9,12 @@ import 'package:flutter_gemma_example/tts_screen.dart'; /// 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 +48,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 +61,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 +140,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..4139bf1e 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; @@ -88,7 +94,7 @@ class _VoiceScreenState extends State { Future _initializeVoiceSession() async { try { // --- STT --- - final sttToken = _sttModel.needsAuth + final sttToken = widget.sttModel.needsAuth ? await AuthTokenService.loadToken() : null; if (!mounted) return; @@ -97,9 +103,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); @@ -118,8 +124,8 @@ class _VoiceScreenState extends State { _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); @@ -129,7 +135,7 @@ class _VoiceScreenState extends State { // --- LLM (no tools — see class doc) --- String? llmToken; - if (_llmModel.needsAuth) { + if (widget.llmModel.needsAuth) { llmToken = await AuthTokenService.loadToken(); } if (!mounted) return; @@ -138,25 +144,27 @@ class _VoiceScreenState extends State { _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, + 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 ' @@ -293,8 +301,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 +440,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..ff579af6 --- /dev/null +++ b/packages/flutter_gemma/example/lib/voice_setup_screen.dart @@ -0,0 +1,153 @@ +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 `*ModelsScreen` in +/// selection mode (its `onSelected` callback) — only entries with a shipped +/// profile are pickable there. 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(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, + ); + } +} From 9141d56c6cf8d4e71a9d292f839a36eb6b732118 Mon Sep 17 00:00:00 2001 From: Sasha Denisov Date: Fri, 7 Aug 2026 20:05:56 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(example):=20voice-loop=20review=20polis?= =?UTF-8?q?h=20=E2=80=94=20dartdoc=20links,=20doc=20accuracy,=20hide=20bui?= =?UTF-8?q?lt-in=20LLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on the Voice Loop commit: - resolve unresolvable [VoiceSetupScreen] dartdoc links (that class isn't imported by these files — a back-import would cycle) by rendering it as inline code, matching the files' own convention for non-imported screens. - clarify VoiceSetupScreen's class doc: the LLM step uses ModelSelectionScreen and is ungated; only STT/TTS gate on a shipped profile. - hide the OS built-in models (Gemini Nano / Apple Foundation Models) from the LLM picker via a new ModelSelectionScreen.hideBuiltIn flag — they can't install via the plain installModel().fromNetwork() path the voice loop uses, so offering them was a dead end. --- .../example/lib/model_selection_screen.dart | 21 +++++++++++++++++-- .../example/lib/stt_models_screen.dart | 2 +- .../example/lib/tts_models_screen.dart | 2 +- .../example/lib/voice_screen.dart | 2 +- .../example/lib/voice_setup_screen.dart | 18 +++++++++------- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/flutter_gemma/example/lib/model_selection_screen.dart b/packages/flutter_gemma/example/lib/model_selection_screen.dart index 5aae46d2..05d3656d 100644 --- a/packages/flutter_gemma/example/lib/model_selection_screen.dart +++ b/packages/flutter_gemma/example/lib/model_selection_screen.dart @@ -24,10 +24,21 @@ enum SortType { class ModelSelectionScreen extends StatefulWidget { /// 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. + /// `VoiceSetupScreen`) instead of navigating into the download/chat flow. final ValueChanged? onSelected; - const ModelSelectionScreen({super.key, this.onSelected}); + /// Hide the OS built-in models (Gemini Nano / Apple Foundation Models) from + /// the list. They can't be installed via the plain + /// `installModel(...).fromNetwork(...)` path a caller like `VoiceSetupScreen` + /// uses, so pickers that don't route through the built-in short-circuit + /// exclude them. + final bool hideBuiltIn; + + const ModelSelectionScreen({ + super.key, + this.onSelected, + this.hideBuiltIn = false, + }); @override State createState() => _ModelSelectionScreenState(); @@ -100,6 +111,12 @@ class _ModelSelectionScreenState extends State { // Show all models on all platforms var models = Model.values.toList(); + // Pickers that can't install a built-in model (e.g. the Voice Loop LLM + // step) opt out of showing them entirely. + if (widget.hideBuiltIn) { + models = models.where((model) => !model.isBuiltIn).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 diff --git a/packages/flutter_gemma/example/lib/stt_models_screen.dart b/packages/flutter_gemma/example/lib/stt_models_screen.dart index d1e93dbd..e7029514 100644 --- a/packages/flutter_gemma/example/lib/stt_models_screen.dart +++ b/packages/flutter_gemma/example/lib/stt_models_screen.dart @@ -9,7 +9,7 @@ import 'package:flutter_gemma_example/stt_screen.dart'; class SttModelsScreen extends StatelessWidget { /// 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]. + /// `VoiceSetupScreen`) instead of navigating into [SttScreen]. final ValueChanged? onSelected; const SttModelsScreen({super.key, this.onSelected}); diff --git a/packages/flutter_gemma/example/lib/tts_models_screen.dart b/packages/flutter_gemma/example/lib/tts_models_screen.dart index d3f55759..26c36fca 100644 --- a/packages/flutter_gemma/example/lib/tts_models_screen.dart +++ b/packages/flutter_gemma/example/lib/tts_models_screen.dart @@ -11,7 +11,7 @@ import 'package:flutter_gemma_example/tts_screen.dart'; class TtsModelsScreen extends StatelessWidget { /// 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]. + /// `VoiceSetupScreen`) instead of navigating into [TtsScreen]. final ValueChanged? onSelected; const TtsModelsScreen({super.key, this.onSelected}); diff --git a/packages/flutter_gemma/example/lib/voice_screen.dart b/packages/flutter_gemma/example/lib/voice_screen.dart index 4139bf1e..9dce3295 100644 --- a/packages/flutter_gemma/example/lib/voice_screen.dart +++ b/packages/flutter_gemma/example/lib/voice_screen.dart @@ -21,7 +21,7 @@ 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 the three models chosen upstream in [VoiceSetupScreen] and passed in: +/// 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 diff --git a/packages/flutter_gemma/example/lib/voice_setup_screen.dart b/packages/flutter_gemma/example/lib/voice_setup_screen.dart index ff579af6..e20d8d63 100644 --- a/packages/flutter_gemma/example/lib/voice_setup_screen.dart +++ b/packages/flutter_gemma/example/lib/voice_setup_screen.dart @@ -11,11 +11,13 @@ import 'package:flutter_gemma_example/voice_screen.dart'; /// (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 `*ModelsScreen` in -/// selection mode (its `onSelected` callback) — only entries with a shipped -/// profile are pickable there. 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. +/// 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}); @@ -39,8 +41,10 @@ class _VoiceSetupScreenState extends State { Future _pickLlm() => Navigator.push( context, MaterialPageRoute( - builder: (_) => - ModelSelectionScreen(onSelected: (m) => setState(() => _llm = m)), + builder: (_) => ModelSelectionScreen( + hideBuiltIn: true, + onSelected: (m) => setState(() => _llm = m), + ), ), ); From 96248f6f93c7c23f947ee0373413e917676194d5 Mon Sep 17 00:00:00 2001 From: Sasha Denisov Date: Wed, 12 Aug 2026 00:31:56 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(example):=20voice-loop=20review=20fixes?= =?UTF-8?q?=20=E2=80=94=20installable-only=20LLM=20picker,=20close=20resou?= =?UTF-8?q?rces=20on=20failed=20init,=20widget=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the full-review findings on #412: - ModelSelectionScreen: replace hideBuiltIn bool with a modelFilter predicate; the Voice Loop LLM picker passes (m) => !m.isBuiltIn && !m.localModel, so it no longer offers localModel asset entries (url is an assets/ path) the fromNetwork install can't handle. - VoiceScreen._initializeVoiceSession: close the already-activated STT/TTS/LLM sessions on a failed init or early unmount (they leaked because dispose only reclaims the state fields). - Add device-free widget tests (voice_setup_test.dart): default trio + modelFilter excludes localModel entries. - Doc: note pick-a-model mode in the STT/TTS models-screen class docs. --- .../example/lib/model_selection_screen.dart | 30 +++++----- .../example/lib/stt_models_screen.dart | 4 +- .../example/lib/tts_models_screen.dart | 4 +- .../example/lib/voice_screen.dart | 37 ++++++++++-- .../example/lib/voice_setup_screen.dart | 5 +- .../example/test/voice_setup_test.dart | 59 +++++++++++++++++++ 6 files changed, 115 insertions(+), 24 deletions(-) create mode 100644 packages/flutter_gemma/example/test/voice_setup_test.dart diff --git a/packages/flutter_gemma/example/lib/model_selection_screen.dart b/packages/flutter_gemma/example/lib/model_selection_screen.dart index 05d3656d..69d6591f 100644 --- a/packages/flutter_gemma/example/lib/model_selection_screen.dart +++ b/packages/flutter_gemma/example/lib/model_selection_screen.dart @@ -27,18 +27,16 @@ class ModelSelectionScreen extends StatefulWidget { /// `VoiceSetupScreen`) instead of navigating into the download/chat flow. final ValueChanged? onSelected; - /// Hide the OS built-in models (Gemini Nano / Apple Foundation Models) from - /// the list. They can't be installed via the plain - /// `installModel(...).fromNetwork(...)` path a caller like `VoiceSetupScreen` - /// uses, so pickers that don't route through the built-in short-circuit - /// exclude them. - final bool hideBuiltIn; + /// 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.hideBuiltIn = false, - }); + const ModelSelectionScreen({super.key, this.onSelected, this.modelFilter}); @override State createState() => _ModelSelectionScreenState(); @@ -111,10 +109,12 @@ class _ModelSelectionScreenState extends State { // Show all models on all platforms var models = Model.values.toList(); - // Pickers that can't install a built-in model (e.g. the Voice Loop LLM - // step) opt out of showing them entirely. - if (widget.hideBuiltIn) { - models = models.where((model) => !model.isBuiltIn).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 diff --git a/packages/flutter_gemma/example/lib/stt_models_screen.dart b/packages/flutter_gemma/example/lib/stt_models_screen.dart index e7029514..47672ecb 100644 --- a/packages/flutter_gemma/example/lib/stt_models_screen.dart +++ b/packages/flutter_gemma/example/lib/stt_models_screen.dart @@ -4,7 +4,9 @@ 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 { /// When set, the screen is in "pick a model" mode: tapping a supported entry diff --git a/packages/flutter_gemma/example/lib/tts_models_screen.dart b/packages/flutter_gemma/example/lib/tts_models_screen.dart index 26c36fca..5bae27f8 100644 --- a/packages/flutter_gemma/example/lib/tts_models_screen.dart +++ b/packages/flutter_gemma/example/lib/tts_models_screen.dart @@ -4,7 +4,9 @@ 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. diff --git a/packages/flutter_gemma/example/lib/voice_screen.dart b/packages/flutter_gemma/example/lib/voice_screen.dart index 9dce3295..cc6b4adb 100644 --- a/packages/flutter_gemma/example/lib/voice_screen.dart +++ b/packages/flutter_gemma/example/lib/voice_screen.dart @@ -92,6 +92,19 @@ 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 = widget.sttModel.needsAuth @@ -115,10 +128,13 @@ 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; @@ -131,14 +147,17 @@ class _VoiceScreenState extends State { setState(() => _downloadPercent = percent); }) .install(); - final synth = await FlutterGemma.getActiveTts(); + synth = await FlutterGemma.getActiveTts(); // --- LLM (no tools — see class doc) --- String? llmToken; if (widget.llmModel.needsAuth) { llmToken = await AuthTokenService.loadToken(); } - if (!mounted) return; + if (!mounted) { + await closePartial(); + return; + } setState(() { _stage = 'Downloading language model'; _downloadPercent = null; @@ -157,7 +176,7 @@ class _VoiceScreenState extends State { maxTokens: widget.llmModel.maxTokens, preferredBackend: widget.llmModel.preferredBackend, ); - final chat = await model.createChat( + chat = await model.createChat( temperature: widget.llmModel.temperature, randomSeed: 1, topK: widget.llmModel.topK, @@ -177,7 +196,10 @@ class _VoiceScreenState extends State { synthesizer: synth, ); - if (!mounted) return; + if (!mounted) { + await closePartial(); + return; + } setState(() { _recognizer = recognizer; _synth = synth; @@ -186,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'); } diff --git a/packages/flutter_gemma/example/lib/voice_setup_screen.dart b/packages/flutter_gemma/example/lib/voice_setup_screen.dart index e20d8d63..43cebec2 100644 --- a/packages/flutter_gemma/example/lib/voice_setup_screen.dart +++ b/packages/flutter_gemma/example/lib/voice_setup_screen.dart @@ -42,7 +42,10 @@ class _VoiceSetupScreenState extends State { context, MaterialPageRoute( builder: (_) => ModelSelectionScreen( - hideBuiltIn: true, + // 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), ), ), 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)', + ); + }, + ); +}