Skip to content
Draft
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
14 changes: 12 additions & 2 deletions mobile/integration_test/pocket_voice_integration_test.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:io';

import 'package:buzz/shared/voice/pocket_model_downloader.dart';
import 'package:buzz/shared/voice/pocket_model_manifest.dart';
import 'package:buzz/shared/voice/pocket_voice_worker.dart';
import 'package:buzz/shared/voice/voice_audio_output.dart';
import 'package:flutter/foundation.dart';
Expand All @@ -16,12 +17,17 @@ void main() {
const configuredModelPath = String.fromEnvironment(
'BUZZ_POCKET_MODEL_DIR',
);
const configuredPrecision = String.fromEnvironment(
'BUZZ_POCKET_PRECISION',
defaultValue: 'fp32',
);
final variant = PocketModelVariant.fromStored(configuredPrecision);
const downloadModel = bool.fromEnvironment('BUZZ_POCKET_DOWNLOAD');
var modelPath = configuredModelPath;
Duration? downloadTime;
if (modelPath.isEmpty && downloadModel) {
final clock = Stopwatch()..start();
final downloader = PocketModelDownloader();
final downloader = PocketModelDownloader(variant: variant);
final directory = await downloader.install((downloaded, total, file) {
if (downloaded == total) {
debugPrint('Pocket model download complete: $file ($total bytes)');
Expand All @@ -40,7 +46,9 @@ void main() {
}

final worker = PocketVoiceWorker();
await worker.start(modelPath);
final loadClock = Stopwatch()..start();
await worker.start(modelPath, precision: variant.nativePrecision);
loadClock.stop();
addTearDown(worker.dispose);

final firstClock = Stopwatch()..start();
Expand Down Expand Up @@ -128,6 +136,8 @@ void main() {
// Emitted in a stable shape so simulator/device runs are easy to compare.
debugPrint(
'BUZZ_POCKET_METRICS '
'precision=${variant.precision} '
'load_ms=${loadClock.elapsedMilliseconds} '
'first_pcm_ms=${firstClock.elapsedMilliseconds} '
'ttfap_ms=${firstPlaybackClock.elapsedMilliseconds} '
'synthesis_ms=${first.synthesisTime.inMilliseconds} '
Expand Down
3 changes: 3 additions & 0 deletions mobile/lib/features/settings/settings_page.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
Expand All @@ -11,6 +13,7 @@ import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/voice/pocket_model_manifest.dart';
import '../../shared/voice/pocket_model_provider.dart';
import '../../shared/voice/pocket_voice_controller.dart';
import '../../shared/widgets/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/frosted_app_bar.dart';
Expand Down
67 changes: 64 additions & 3 deletions mobile/lib/features/settings/settings_page/voice_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ class _VoiceSection extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final model = ref.watch(pocketModelProvider);
final downloading = model.phase == PocketModelPhase.downloading;
final switching =
downloading ||
model.phase == PocketModelPhase.checking ||
model.phase == PocketModelPhase.verifying;
final status = switch (model.phase) {
PocketModelPhase.checking => 'Checking…',
PocketModelPhase.absent => 'Not downloaded',
Expand All @@ -30,13 +34,26 @@ class _VoiceSection extends ConsumerWidget {
return AppListCard(
label: 'Voice',
children: [
AppListRow(
icon: LucideIcons.gauge,
title: 'Model size',
subtitle: model.variant == PocketModelVariant.higherQuality
? 'Best voice quality. Uses more storage and memory.'
: 'Smaller and faster. Uses the supported hybrid INT8 model.',
subtitleMaxLines: 2,
value: model.variant.label,
trailing: switching ? null : const _RowChevron(),
onTap: switching
? null
: () => _showModelSizePicker(context, ref, model.variant),
),
AppListRow(
icon: LucideIcons.audioLines,
title: 'Pocket voice',
subtitle:
model.message ??
'Private, on-device speech. Downloads '
'${pocketModelRuntimeBytes ~/ 1000000} MB after install.',
'${model.variant.runtimeBytes ~/ 1000000} MB after install.',
subtitleMaxLines: 3,
value: status,
trailing: downloading
Expand All @@ -56,14 +73,58 @@ class _VoiceSection extends ConsumerWidget {
),
onTap: action,
),
const AppListRow(
AppListRow(
icon: LucideIcons.hardDriveDownload,
title: 'Model storage',
value: '${pocketModelRuntimeBytes ~/ 1000000} MB',
value: '${model.variant.runtimeBytes ~/ 1000000} MB',
subtitle: 'Stored in application support, not duplicated in assets.',
subtitleMaxLines: 2,
),
],
);
}
}

Future<void> _showModelSizePicker(
BuildContext context,
WidgetRef ref,
PocketModelVariant selected,
) => showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final variant in PocketModelVariant.values)
ListTile(
leading: Icon(
variant == selected
? LucideIcons.circleCheck
: LucideIcons.circle,
),
title: Text(variant.label),
subtitle: Text(
variant == PocketModelVariant.higherQuality
? 'FP32 · ${variant.runtimeBytes ~/ 1000000} MB'
: 'Hybrid INT8 · ${variant.runtimeBytes ~/ 1000000} MB',
),
onTap: () {
Navigator.pop(context);
unawaited(_selectPocketModelVariant(ref, variant));
},
),
const SizedBox(height: Grid.xs),
],
),
),
);

Future<void> _selectPocketModelVariant(
WidgetRef ref,
PocketModelVariant variant,
) async {
if (variant == ref.read(pocketModelProvider).variant) return;
await ref.read(pocketVoiceProvider.notifier).releaseEngineForModelSelection();
await ref.read(pocketModelProvider.notifier).select(variant);
}
Loading
Loading