flutter_local_voice_agent 0.1.2 copy "flutter_local_voice_agent: ^0.1.2" to clipboard
flutter_local_voice_agent: ^0.1.2 copied to clipboard

Complete on-device speech-to-text and text-to-speech for Flutter. One local voice agent handles microphone, recognition, reply, and playback.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_local_voice_agent/flutter_local_voice_agent.dart';

import 'voice_screen_controller.dart';

void main() => runApp(const VoiceExample());

class VoiceExample extends StatelessWidget {
  const VoiceExample({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'Local voice catalog',
    debugShowCheckedModeBanner: false,
    theme: ThemeData(
      colorSchemeSeed: const Color(0xff4458a7),
      useMaterial3: true,
      inputDecorationTheme: const InputDecorationTheme(
        border: OutlineInputBorder(),
      ),
    ),
    home: const VoiceScreen(),
  );
}

class VoiceScreen extends StatefulWidget {
  const VoiceScreen({super.key, this.controller});

  final VoiceScreenController? controller;

  @override
  State<VoiceScreen> createState() => _VoiceScreenState();
}

class _VoiceScreenState extends State<VoiceScreen> with WidgetsBindingObserver {
  late final VoiceScreenController _controller =
      widget.controller ?? VoiceScreenController.production();

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _controller.addListener(_refresh);
    unawaited(_controller.initialize());
  }

  void _refresh() {
    if (mounted) setState(() {});
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    switch (state) {
      case AppLifecycleState.resumed:
        _controller.onResume();
      case AppLifecycleState.paused:
      case AppLifecycleState.hidden:
      case AppLifecycleState.detached:
        _controller.onBackground();
      case AppLifecycleState.inactive:
        // Permission prompts and temporary focus loss must not invalidate Start.
        break;
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _controller.removeListener(_refresh);
    unawaited(_controller.close());
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final controller = _controller;
    final selected = controller.selected;
    final progress = controller.progress;
    return Scaffold(
      appBar: AppBar(title: const Text('Local voice')),
      body: SafeArea(
        child: ListView(
          padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
          children: [
            Text(
              'Choose a voice for this device',
              style: Theme.of(context).textTheme.headlineSmall,
            ),
            const SizedBox(height: 8),
            const Text(
              'Download once, then speech recognition and playback run offline. Listening pauses while a reply plays.',
            ),
            const SizedBox(height: 20),
            DropdownButtonFormField<VoiceModelOption>(
              key: ValueKey('model-picker-${selected?.id ?? 'none'}'),
              initialValue: selected,
              isExpanded: true,
              decoration: const InputDecoration(
                labelText: 'English voice model',
              ),
              items: controller.catalog
                  .map(
                    (option) => DropdownMenuItem(
                      value: option,
                      child: Text(
                        option.title,
                        overflow: TextOverflow.ellipsis,
                      ),
                    ),
                  )
                  .toList(),
              onChanged: controller.operationBusy
                  ? null
                  : (option) => unawaited(controller.select(option)),
            ),
            if (selected != null) ...[
              const SizedBox(height: 12),
              Card.filled(
                child: Padding(
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        selected.title,
                        style: Theme.of(context).textTheme.titleMedium,
                      ),
                      const SizedBox(height: 4),
                      Text(
                        '${selected.language} • ${_downloadSize(selected.downloadBytes)} download',
                      ),
                      const SizedBox(height: 8),
                      Text(selected.description),
                      if (controller.phase == ExampleSetupPhase.ready) ...[
                        const SizedBox(height: 10),
                        const Chip(
                          avatar: Icon(Icons.verified, size: 18),
                          label: Text('Installed and verified'),
                        ),
                      ],
                    ],
                  ),
                ),
              ),
            ],
            const SizedBox(height: 12),
            Semantics(
              liveRegion: true,
              child: Text(controller.status, key: const Key('status')),
            ),
            if (_showsTransferProgress(controller.phase) &&
                progress != null) ...[
              const SizedBox(height: 10),
              LinearProgressIndicator(
                key: const Key('download-progress'),
                value: progress.totalBytes > 0
                    ? (progress.receivedBytes / progress.totalBytes).clamp(0, 1)
                    : null,
              ),
              const SizedBox(height: 6),
              Text(
                '${_downloadSize(progress.receivedBytes)} of ${_downloadSize(progress.totalBytes)}',
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ] else if (controller.operationBusy) ...[
              const SizedBox(height: 10),
              const LinearProgressIndicator(),
            ],
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.icon(
                  key: const Key('prepare-button'),
                  onPressed: controller.canPrepare
                      ? () => unawaited(controller.downloadAndPrepare())
                      : null,
                  icon: const Icon(Icons.download),
                  label: Text(
                    controller.phase == ExampleSetupPhase.failed
                        ? 'Retry download'
                        : 'Download and prepare',
                  ),
                ),
                if (controller.canCancel)
                  OutlinedButton(
                    key: const Key('cancel-button'),
                    onPressed: controller.cancelPreparation,
                    child: const Text('Cancel'),
                  ),
                if (controller.allowRepairRemoval)
                  OutlinedButton.icon(
                    key: const Key('remove-copy-button'),
                    onPressed: controller.operationBusy
                        ? null
                        : () => unawaited(controller.removeDamagedCopy()),
                    icon: const Icon(Icons.delete_outline),
                    label: const Text('Remove damaged copy'),
                  ),
              ],
            ),
            const SizedBox(height: 16),
            const _DisclosureCard(),
            const SizedBox(height: 20),
            Text('Conversation', style: Theme.of(context).textTheme.titleLarge),
            const SizedBox(height: 6),
            const Text(
              'Replies are fixed demo rules, not an LLM. Try “hello”, “what is your name?”, or “thank you”.',
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton(
                  key: const Key('start-button'),
                  onPressed: controller.canStart && !controller.operationBusy
                      ? () => unawaited(controller.start())
                      : null,
                  child: const Text('Start'),
                ),
                OutlinedButton(
                  key: const Key('interrupt-button'),
                  onPressed: controller.hasSession && !controller.operationBusy
                      ? () => unawaited(controller.interrupt())
                      : null,
                  child: const Text('Interrupt'),
                ),
                OutlinedButton(
                  key: const Key('stop-button'),
                  onPressed: controller.hasSession && !controller.operationBusy
                      ? () => unawaited(controller.stop())
                      : null,
                  child: const Text('Stop'),
                ),
              ],
            ),
            const SizedBox(height: 20),
            _TranscriptCard(
              title: 'You said',
              value: controller.heard,
              placeholder: 'Your partial and final transcript appears here.',
            ),
            const SizedBox(height: 12),
            _TranscriptCard(
              title: 'Local reply',
              value: controller.reply,
              placeholder: 'The fixed local demo reply appears here.',
            ),
          ],
        ),
      ),
    );
  }
}

class _DisclosureCard extends StatelessWidget {
  const _DisclosureCard();

  @override
  Widget build(BuildContext context) => const Card.outlined(
    child: ExpansionTile(
      dense: true,
      title: Text('Files, sources, and licenses'),
      childrenPadding: EdgeInsets.fromLTRB(16, 0, 16, 16),
      expandedCrossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'The catalog pins each source, file size, and checksum. License records download with the model. Preparation needs internet; conversations do not.',
        ),
      ],
    ),
  );
}

class _TranscriptCard extends StatelessWidget {
  const _TranscriptCard({
    required this.title,
    required this.value,
    required this.placeholder,
  });

  final String title;
  final String value;
  final String placeholder;

  @override
  Widget build(BuildContext context) => Card.outlined(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(title, style: Theme.of(context).textTheme.titleMedium),
          const SizedBox(height: 6),
          SelectableText(value.isEmpty ? placeholder : value),
        ],
      ),
    ),
  );
}

String _downloadSize(int bytes) {
  final megabytes = bytes / 1000000;
  return '${megabytes.toStringAsFixed(1)} MB';
}

bool _showsTransferProgress(ExampleSetupPhase phase) =>
    phase == ExampleSetupPhase.checking ||
    phase == ExampleSetupPhase.downloading ||
    phase == ExampleSetupPhase.verifying;
2
likes
0
points
279
downloads

Documentation

Documentation

Publisher

verified publisherortal83cohen.is-a.dev

Weekly Downloads

Complete on-device speech-to-text and text-to-speech for Flutter. One local voice agent handles microphone, recognition, reply, and playback.

Repository (GitHub)
View/report issues

Topics

#speech-to-text #text-to-speech #speech-recognition #offline #voice

License

unknown (license)

Dependencies

crypto, flutter

More

Packages that depend on flutter_local_voice_agent

Packages that implement flutter_local_voice_agent