Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 15 additions & 11 deletions integration_test/app_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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')),
);
Expand Down
17 changes: 15 additions & 2 deletions lib/models/tts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -898,6 +908,8 @@ class TtsModel extends ChangeNotifier {
}

void _updateFromJsonInternal(Map<String, dynamic> json) {
_preferredVoicePackId =
json['voicePack'] is String ? json['voicePack'] as String : null;
if (json['isBotMuted'] != null) {
_isBotMuted = json['isBotMuted'];
}
Expand Down Expand Up @@ -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(),
};
}
43 changes: 36 additions & 7 deletions lib/models/tts/neural_engine_selector.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -41,6 +42,8 @@ class NeuralEngineSelector {
/// whenever the installed packs change.
Future<void> start() {
if (_changes == null) {
_preferred = tts.preferredVoicePackId;
tts.addListener(_onTtsChanged);
_changes = repository.changes.listen((_) => refresh());
_refreshing = _checkDeviceMemory();
}
Expand All @@ -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<InstalledVoicePack> 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<InstalledVoicePack> 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.
Expand All @@ -74,7 +99,10 @@ class NeuralEngineSelector {

Future<void> _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
Expand All @@ -98,6 +126,7 @@ class NeuralEngineSelector {
/// disposes it.
void dispose() {
_isDisposed = true;
tts.removeListener(_onTtsChanged);
_changes?.cancel();
_changes = null;
}
Expand Down
41 changes: 30 additions & 11 deletions lib/screens/settings/tts/voice_packs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -28,16 +30,26 @@ class _VoicePacksScreenState extends State<VoicePacksScreen> {
final model = context.watch<VoicePacksModel>();
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<String>(
groupValue:
NeuralEngineSelector.choose(
model.installed.values,
preferredId: context.watch<TtsModel>().preferredVoicePackId,
)?.id,
onChanged: (id) {
if (id != null) context.read<TtsModel>().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),
],
),
),
);
}
Expand Down Expand Up @@ -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<String>(
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)),
),
Expand Down
Loading
Loading