diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart index 6122dfde6..320a227e5 100644 --- a/integration_test/app_test.dart +++ b/integration_test/app_test.dart @@ -1,9 +1,11 @@ import 'dart:convert'; import 'dart:io'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:provider/provider.dart'; @@ -99,6 +101,19 @@ void main() { setUpAll(() async { await initializeFirebaseEmulators(); + // These fake users have no production avatars. Seed the image cache so + // account sign-out never races an external request or a delayed 404. + final avatar = await rootBundle.load('assets/providers/twitch.png'); + for (final channel in [ + Channel(Providers.twitch, channelId, 'e2estreamer'), + Channel(Providers.kick, kickChannelId, 'E2EKick'), + ]) { + await CachedNetworkImageProvider.defaultCacheManager.putFile( + channel.profilePictureUrl.toString(), + avatar.buffer.asUint8List(avatar.offsetInBytes, avatar.lengthInBytes), + fileExtension: 'png', + ); + } callables = await CallableStub.start(callableHost, callablePort); }); @@ -500,17 +515,6 @@ void main() { final kickTile = find.widgetWithText(ListTile, 'E2EKick'); await pumpUntil(tester, kickTile); expect(find.widgetWithText(ListTile, 'e2estreamer'), findsOneWidget); - // let the kick avatar finish loading (the emulated channel has none), - // or its error is reported once the tile is gone. - final avatar = - Channel(Providers.kick, kickChannelId, 'E2EKick').profilePicture; - final end = DateTime.now().add(const Duration(seconds: 30)); - do { - await tester.pump(const Duration(milliseconds: 250)); - } while (PaintingBinding.instance.imageCache - .statusForKey(avatar) - .pending && - DateTime.now().isBefore(end)); await tester.tap( find.descendant(of: kickTile, matching: find.byTooltip('Sign out')), ); diff --git a/lib/models/tts.dart b/lib/models/tts.dart index 536ed4233..767fb18a2 100644 --- a/lib/models/tts.dart +++ b/lib/models/tts.dart @@ -756,8 +756,18 @@ class TtsModel extends ChangeNotifier { } } - /// The engine that speaks with a neural voice pack, or null to speak with - /// the platform's voices. + String? _preferredVoicePackId; + + /// The installed pack selected by the user; null uses the default catalog order. + String? get preferredVoicePackId => _preferredVoicePackId; + + set preferredVoicePackId(String? id) { + if (_preferredVoicePackId == id) return; + _preferredVoicePackId = id; + notifyListeners(); + } + + /// The engine that speaks with a neural voice pack, or null for system voices. TtsEngine? get neuralEngine => _neuralEngine; /// Replaces the neural engine; the model owns [engine] from now on. The @@ -898,6 +908,8 @@ class TtsModel extends ChangeNotifier { } void _updateFromJsonInternal(Map json) { + _preferredVoicePackId = + json['voicePack'] is String ? json['voicePack'] as String : null; if (json['isBotMuted'] != null) { _isBotMuted = json['isBotMuted']; } @@ -989,6 +1001,7 @@ class TtsModel extends ChangeNotifier { "pitch": pitch, "speed": speed, "neuralVoices": Map.of(_neuralVoices), + if (_preferredVoicePackId != null) "voicePack": _preferredVoicePackId, 'mutedUsers': _mutedUsers.map((e) => e.toJson()).toList(), }; } diff --git a/lib/models/tts/neural_engine_selector.dart b/lib/models/tts/neural_engine_selector.dart index 28de5e31c..86b265555 100644 --- a/lib/models/tts/neural_engine_selector.dart +++ b/lib/models/tts/neural_engine_selector.dart @@ -26,6 +26,7 @@ class NeuralEngineSelector { // identifies the install the current engine speaks with. String? _installed; var _isDisposed = false; + String? _preferred; NeuralEngineSelector({ required this.repository, @@ -41,6 +42,8 @@ class NeuralEngineSelector { /// whenever the installed packs change. Future start() { if (_changes == null) { + _preferred = tts.preferredVoicePackId; + tts.addListener(_onTtsChanged); _changes = repository.changes.listen((_) => refresh()); _refreshing = _checkDeviceMemory(); } @@ -58,12 +61,34 @@ class NeuralEngineSelector { } } - /// The installed pack to speak with: the first, by id, that a - /// [NeuralTtsEngine] can speak with. - static InstalledVoicePack? choose(Iterable installed) => - (installed.where((pack) => NeuralTtsEngine.canSpeak(pack.pack)).toList() - ..sort((a, b) => a.id.compareTo(b.id))) - .firstOrNull; + /// The selected installed pack, otherwise the catalog's preferred pack. + /// Unknown packs retain deterministic ordering after catalog entries. + static InstalledVoicePack? choose( + Iterable installed, { + String? preferredId, + }) { + final available = + installed.where((pack) => NeuralTtsEngine.canSpeak(pack.pack)).toList(); + for (final pack in available) { + if (pack.id == preferredId) return pack; + } + final priority = { + for (final (i, pack) in bundledVoicePacks.indexed) pack.id: i, + }; + available.sort((a, b) { + final order = (priority[a.id] ?? priority.length).compareTo( + priority[b.id] ?? priority.length, + ); + return order == 0 ? a.id.compareTo(b.id) : order; + }); + return available.firstOrNull; + } + + void _onTtsChanged() { + if (_preferred == tts.preferredVoicePackId) return; + _preferred = tts.preferredVoicePackId; + refresh(); + } /// Looks at the installed packs again and swaps the engine if the pack to /// speak with changed. @@ -74,7 +99,10 @@ class NeuralEngineSelector { Future _refresh() async { if (_isDisposed) return; - final pack = choose((await repository.installedPacks()).values); + final pack = choose( + (await repository.installedPacks()).values, + preferredId: tts.preferredVoicePackId, + ); if (_isDisposed) return; final installed = pack == null @@ -98,6 +126,7 @@ class NeuralEngineSelector { /// disposes it. void dispose() { _isDisposed = true; + tts.removeListener(_onTtsChanged); _changes?.cancel(); _changes = null; } diff --git a/lib/screens/settings/tts/voice_packs.dart b/lib/screens/settings/tts/voice_packs.dart index 6f11210b3..d28f69511 100644 --- a/lib/screens/settings/tts/voice_packs.dart +++ b/lib/screens/settings/tts/voice_packs.dart @@ -3,6 +3,8 @@ import 'package:on_device_tts/on_device_tts.dart'; import 'package:provider/provider.dart'; import 'package:rtchat/l10n/app_localizations.dart'; import 'package:rtchat/models/tts/voice_packs.dart'; +import 'package:rtchat/models/tts.dart'; +import 'package:rtchat/models/tts/neural_engine_selector.dart'; import 'package:rtchat/screens/settings/tts/labels.dart'; import 'package:rtchat/urls.dart'; @@ -28,16 +30,26 @@ class _VoicePacksScreenState extends State { final model = context.watch(); return Scaffold( appBar: AppBar(title: Text(l10n.voicePacks)), - body: ListView( - padding: const EdgeInsets.only(bottom: 16), - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text(l10n.voicePacksDescription), - ), - for (final pack in model.packs) - VoicePackCard(key: ValueKey(pack.id), pack: pack), - ], + body: RadioGroup( + groupValue: + NeuralEngineSelector.choose( + model.installed.values, + preferredId: context.watch().preferredVoicePackId, + )?.id, + onChanged: (id) { + if (id != null) context.read().preferredVoicePackId = id; + }, + child: ListView( + padding: const EdgeInsets.only(bottom: 16), + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text(l10n.voicePacksDescription), + ), + for (final pack in model.packs) + VoicePackCard(key: ValueKey(pack.id), pack: pack), + ], + ), ), ); } @@ -75,7 +87,14 @@ class VoicePackCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: [ - Text(pack.name, style: theme.textTheme.titleMedium), + if (installed != null) + RadioListTile( + value: pack.id, + title: Text(pack.name, style: theme.textTheme.titleMedium), + contentPadding: EdgeInsets.zero, + ) + else + Text(pack.name, style: theme.textTheme.titleMedium), Text( l10n.voicePackDetails(pack.speakers.length, formatBytes(size)), ), diff --git a/packages/on_device_tts/lib/src/voice_packs/bundled_catalog.dart b/packages/on_device_tts/lib/src/voice_packs/bundled_catalog.dart index dfb3e20fb..65eb34d96 100644 --- a/packages/on_device_tts/lib/src/voice_packs/bundled_catalog.dart +++ b/packages/on_device_tts/lib/src/voice_packs/bundled_catalog.dart @@ -1,9 +1,9 @@ // The voice packs the package offers. // // PROVISIONAL. The engines (#1502 on Android, #1517 on iOS) run on ONNX -// Runtime without a phonemizer, so the only model for now is Supertonic 3, -// which needs none. Models that need espeak-ng phonemes (Kitten, Piper, -// Kokoro) come back with #1518. +// Runtime without a phonemizer, so the default model is Supertonic 3, +// which needs none. Supertonic 2 is also available as a smaller five-language +// alternative. Models requiring phonemes need a separate engine integration. // // The files are the originals on Hugging Face (Supertone/supertonic-3), // pinned to revision 3cadd1ee6394adea1bd021217a0e650ede09a323. Hosting them @@ -190,4 +190,197 @@ final bundledVoicePacks = List.unmodifiable([ ], }, ), + + VoicePack( + id: 'supertonic-2', + name: 'Supertonic 2', + version: 1, + modelType: VoicePackModelType.supertonic, + languages: const ['en', 'ko', 'es', 'pt', 'fr'], + speakers: const [ + VoicePackSpeaker(index: 0, name: 'F1', gender: 'female'), + VoicePackSpeaker(index: 1, name: 'F2', gender: 'female'), + VoicePackSpeaker(index: 2, name: 'F3', gender: 'female'), + VoicePackSpeaker(index: 3, name: 'F4', gender: 'female'), + VoicePackSpeaker(index: 4, name: 'F5', gender: 'female'), + VoicePackSpeaker(index: 5, name: 'M1', gender: 'male'), + VoicePackSpeaker(index: 6, name: 'M2', gender: 'male'), + VoicePackSpeaker(index: 7, name: 'M3', gender: 'male'), + VoicePackSpeaker(index: 8, name: 'M4', gender: 'male'), + VoicePackSpeaker(index: 9, name: 'M5', gender: 'male'), + ], + files: [ + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/onnx/duration_predictor.onnx', + ), + size: 1521526, + sha256: + '6d556b3691165c364be91dc0bd894656b5949f5acd2750d8ec2f954010845011', + path: 'onnx/duration_predictor.onnx', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/onnx/text_encoder.onnx', + ), + size: 27431318, + sha256: + 'dd5f535ed629f7df86071043e15f541ce1b2ab7f1bdbce4c7892b307bca79fa3', + path: 'onnx/text_encoder.onnx', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/onnx/tts.json', + ), + size: 8699, + sha256: + 'ee531d9af9b80438a2ed703e22155ee6c83b12595ab22fd3bb6de94c7502fe96', + path: 'onnx/tts.json', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/onnx/unicode_indexer.json', + ), + size: 262196, + sha256: + 'b7662a73a0703f43b97c0f2e089f8e8325e26f5d841aca393b5a54c509c92df1', + path: 'onnx/unicode_indexer.json', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/onnx/vector_estimator.onnx', + ), + size: 132471364, + sha256: + '105e9d66fd8756876b210a6b4aa03fc393b1eaca3a8dadcc8d9a3bc785c86a35', + path: 'onnx/vector_estimator.onnx', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/onnx/vocoder.onnx', + ), + size: 101405066, + sha256: + '19bd51f47a186069c752403518a40f7ea4c647455056d2511f7249691ecddf7c', + path: 'onnx/vocoder.onnx', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/voice_styles/F1.json', + ), + size: 420050, + sha256: + '6106950ebeb8a5da29ea22075f605db659cd07dbc288a68292543d9129aa250f', + path: 'voice_styles/F1.json', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/voice_styles/F2.json', + ), + size: 420293, + sha256: + '8b97feb16d79ac0447136796708feac5f83dbabe92a5be1168212653c38729ae', + path: 'voice_styles/F2.json', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/voice_styles/F3.json', + ), + size: 419887, + sha256: + '7eda5bccb4e6eb7f228fa182462d5fcf982d77628234603599027f0734d70c29', + path: 'voice_styles/F3.json', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/voice_styles/F4.json', + ), + size: 419642, + sha256: + 'e056fc2bee393edc8bff761eb28f33fb461e8dad828c3b05348a010ac1b7bb79', + path: 'voice_styles/F4.json', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/voice_styles/F5.json', + ), + size: 419709, + sha256: + 'ce7645ad7e3c13cca04e0d62bf890ef9ac401988005ba8f5e9c9b59257bc6931', + path: 'voice_styles/F5.json', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/voice_styles/M1.json', + ), + size: 420510, + sha256: + 'a04c823cbda6dd1c7de131ec68fea83bbb70d7f29d61623304eb871e3b83b5a1', + path: 'voice_styles/M1.json', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/voice_styles/M2.json', + ), + size: 420285, + sha256: + '7ddd07bf873a3fd67d09ef4e8293b486beb658158b47e371166198e4c6926072', + path: 'voice_styles/M2.json', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/voice_styles/M3.json', + ), + size: 420005, + sha256: + 'e8e77a56459e4dc8cdfeb88e6f778dc9a0adf22e1184414f4b0e82a5d1edbe72', + path: 'voice_styles/M3.json', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/voice_styles/M4.json', + ), + size: 419957, + sha256: + '95322725e4d25d9ed4e7dcccbf0f3726b0e9a2471d876b7942373218dbd30174', + path: 'voice_styles/M4.json', + ), + VoicePackFile( + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/voice_styles/M5.json', + ), + size: 419619, + sha256: + 'be52f82327da63ff18481ce2dd8060c7df432e0168d748745ef3e21b92d706a5', + path: 'voice_styles/M5.json', + ), + ], + license: VoicePackLicense( + name: 'LicenseRef-OpenRAIL-M', + url: Uri.parse( + 'https://huggingface.co/Supertone/supertonic-2/blob/75e6727618a02f323c720cba9478152d4bc16ca4/LICENSE', + ), + attribution: 'Supertonic 2 by Supertone Inc.', + ), + config: const { + 'duration_predictor': 'onnx/duration_predictor.onnx', + 'text_encoder': 'onnx/text_encoder.onnx', + 'vector_estimator': 'onnx/vector_estimator.onnx', + 'vocoder': 'onnx/vocoder.onnx', + 'tts_json': 'onnx/tts.json', + 'unicode_indexer': 'onnx/unicode_indexer.json', + 'voice_styles': [ + 'voice_styles/F1.json', + 'voice_styles/F2.json', + 'voice_styles/F3.json', + 'voice_styles/F4.json', + 'voice_styles/F5.json', + 'voice_styles/M1.json', + 'voice_styles/M2.json', + 'voice_styles/M3.json', + 'voice_styles/M4.json', + 'voice_styles/M5.json', + ], + }, + ), ]); diff --git a/packages/on_device_tts/test/voice_packs/voice_pack_test.dart b/packages/on_device_tts/test/voice_packs/voice_pack_test.dart index 301c628e6..29ba2fc12 100644 --- a/packages/on_device_tts/test/voice_packs/voice_pack_test.dart +++ b/packages/on_device_tts/test/voice_packs/voice_pack_test.dart @@ -68,6 +68,28 @@ void main() { }); }); + test('Supertonic 2 offers a smaller five-language alternative', () { + final smaller = bundledVoicePacks.singleWhere( + (pack) => pack.id == 'supertonic-2', + ); + final current = bundledVoicePacks.singleWhere( + (pack) => pack.id == 'supertonic-3', + ); + expect(smaller.languages, ['en', 'ko', 'es', 'pt', 'fr']); + expect(smaller.speakers, hasLength(10)); + expect(smaller.downloadSize, 267300126); + expect(smaller.downloadSize, lessThan(current.downloadSize)); + expect(smaller.files, hasLength(16)); + expect( + smaller.files.every( + (file) => file.url.path.contains( + '/resolve/75e6727618a02f323c720cba9478152d4bc16ca4/', + ), + ), + isTrue, + ); + }); + test('round-trips through JSON', () { for (final pack in bundledVoicePacks) { final again = VoicePack.fromJson(pack.toJson()); diff --git a/test/components/image/cached_network_image_test.dart b/test/components/image/cached_network_image_test.dart index 0c170bb94..930336b8a 100644 --- a/test/components/image/cached_network_image_test.dart +++ b/test/components/image/cached_network_image_test.dart @@ -165,8 +165,8 @@ void main() { ); await tester.pump(); await _expectAnimation(tester); - // The 36px button's vertical padding constrains its icon to 20px. - expect(tester.getSize(find.byType(CrossFadeImage)), const Size(36, 20)); + // The 36px button's 2px inset on each side leaves a 32px icon. + expect(tester.getSize(find.byType(CrossFadeImage)), const Size(36, 32)); expect(requests, hasLength(1)); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); diff --git a/test/models/neural_engine_selector_test.dart b/test/models/neural_engine_selector_test.dart index c19295a84..3f86f830d 100644 --- a/test/models/neural_engine_selector_test.dart +++ b/test/models/neural_engine_selector_test.dart @@ -121,4 +121,48 @@ void main() { await pumpEventQueue(); expect(tts.neuralEngine, isNull); }); + test( + 'prefers the catalog default over a smaller pack installed alongside it', + () { + final packs = [ + for (final pack in bundledVoicePacks) installedPackOf(pack), + ]; + expect(NeuralEngineSelector.choose(packs.reversed)?.id, 'supertonic-3'); + expect( + NeuralEngineSelector.choose(packs, preferredId: 'supertonic-2')?.id, + 'supertonic-2', + ); + expect( + NeuralEngineSelector.choose(packs, preferredId: 'missing')?.id, + 'supertonic-3', + ); + }, + ); + + test( + 'switches after selection and falls back when that pack is deleted', + () async { + for (final pack in bundledVoicePacks) { + repository.add(installedPackOf(pack)); + } + await selector.start(); + final first = tts.neuralEngine as FakeTtsEngine; + expect(first.name, 'supertonic-3'); + tts.preferredVoicePackId = 'supertonic-2'; + await pumpEventQueue(); + expect((tts.neuralEngine! as FakeTtsEngine).name, 'supertonic-2'); + expect(first.isDisposed, isTrue); + repository.remove('supertonic-2'); + await pumpEventQueue(); + expect((tts.neuralEngine! as FakeTtsEngine).name, 'supertonic-3'); + }, + ); + + test('persists the preferred pack without changing older settings', () { + expect(tts.toJson(), isNot(contains('voicePack'))); + tts.preferredVoicePackId = 'supertonic-2'; + final restored = TtsModel.fromJson(tts.toJson(), engine: FakeTtsEngine()); + expect(restored.preferredVoicePackId, 'supertonic-2'); + restored.dispose(); + }); } diff --git a/test/screens/settings/voice_packs_test.dart b/test/screens/settings/voice_packs_test.dart index 4839338ed..8f2009ed8 100644 --- a/test/screens/settings/voice_packs_test.dart +++ b/test/screens/settings/voice_packs_test.dart @@ -14,15 +14,25 @@ import '../../fakes/fake_voice_pack_repository.dart'; void main() { late FakeVoicePackRepository repository; + late TtsModel tts; Future open( WidgetTester tester, { Widget home = const VoicePacksScreen(), bool isSupported = true, + List? catalog, }) async { - repository = FakeVoicePackRepository(); + repository = + FakeVoicePackRepository() + ..catalog = + catalog ?? + [ + bundledVoicePacks.singleWhere( + (pack) => pack.id == 'supertonic-3', + ), + ]; final packs = VoicePacksModel(repository, isSupported: isSupported); - final tts = TtsModel.fromJson( + tts = TtsModel.fromJson( const {}, engine: FakeTtsEngine(), reportError: (e, st) => throw e, @@ -80,7 +90,10 @@ void main() { // the size on disk once installed. repository.add( - installedPackOf(bundledVoicePacks.single, sizeOnDisk: 402000000), + installedPackOf( + bundledVoicePacks.singleWhere((pack) => pack.id == 'supertonic-3'), + sizeOnDisk: 402000000, + ), ); await tester.pumpAndSettle(); expect(find.text('10 voices ยท 402 MB'), findsOneWidget); @@ -200,7 +213,9 @@ void main() { testWidgets('offers an update for an outdated pack', (tester) async { await open(tester); - final pack = bundledVoicePacks.single; + final pack = bundledVoicePacks.singleWhere( + (pack) => pack.id == 'supertonic-3', + ); repository.add(installedPackOf(pack)); repository.catalog = [ VoicePack.fromJson({...pack.toJson(), 'version': 2}), @@ -210,4 +225,38 @@ void main() { await tap(tester, find.text('Update')); expect(repository.tasks, contains('supertonic-3')); }); + testWidgets('lists both packs and selects the installed pack explicitly', ( + tester, + ) async { + await open(tester, catalog: bundledVoicePacks); + final current = bundledVoicePacks.singleWhere( + (pack) => pack.id == 'supertonic-3', + ); + final smaller = bundledVoicePacks.singleWhere( + (pack) => pack.id == 'supertonic-2', + ); + repository.add(installedPackOf(current)); + repository.add(installedPackOf(smaller)); + await tester.pumpAndSettle(); + expect( + tester + .widget>(find.byType(RadioGroup)) + .groupValue, + current.id, + ); + await tester.scrollUntilVisible( + find.text('Supertonic 2'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tap(tester, find.text('Supertonic 2')); + await tester.pumpAndSettle(); + expect(tts.preferredVoicePackId, smaller.id); + expect( + tester + .widget>(find.byType(RadioGroup)) + .groupValue, + smaller.id, + ); + }); }