local_voice 0.1.0 copy "local_voice: ^0.1.0" to clipboard
local_voice: ^0.1.0 copied to clipboard

Privacy-first, on-device voice commands for Flutter. No cloud, no API keys, works in airplane mode.

example/lib/main.dart

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

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'local_voice demo',
      theme: ThemeData(colorSchemeSeed: Colors.deepPurple, useMaterial3: true),
      home: const VoiceDemoPage(),
    );
  }
}

class VoiceDemoPage extends StatefulWidget {
  const VoiceDemoPage({super.key});

  @override
  State<VoiceDemoPage> createState() => _VoiceDemoPageState();
}

class _VoiceDemoPageState extends State<VoiceDemoPage> {
  static const _localVoice = LocalVoice();

  final _controller = VoiceListenerController();
  late final VoiceCommandRegistry _registry;

  Color _background = Colors.white;
  String _transcript = '';
  String _lastCommand = 'Say a command, or press and hold the mic.';
  List<VoiceCommandMatch> _ambiguousCandidates = const [];
  bool _permissionGranted = false;
  bool _available = false;
  bool _isListening = false;

  @override
  void initState() {
    super.initState();
    _registry = VoiceCommandRegistry(
      onAmbiguous: (candidates) {
        setState(() => _ambiguousCandidates = candidates);
      },
      commands: [
        VoiceCommand(
          phrases: const ['turn background red', 'make it red', 'red please'],
          intent: const ToggleIntent('background', value: true),
          onMatch: () => _setBackground(Colors.red.shade200, 'red'),
        ),
        VoiceCommand(
          phrases: const [
            'turn background blue',
            'make it blue',
            'blue please',
          ],
          onMatch: () => _setBackground(Colors.blue.shade200, 'blue'),
        ),
        VoiceCommand(
          phrases: const [
            'turn background green',
            'make it green',
            'green please',
          ],
          onMatch: () => _setBackground(Colors.green.shade200, 'green'),
        ),
        VoiceCommand(
          phrases: const ['reset background', 'clear background', 'reset'],
          onMatch: () => _setBackground(Colors.white, 'reset'),
        ),
        // Two intentionally close phrases to demonstrate disambiguation.
        VoiceCommand(
          phrases: const ['open settings'],
          onMatch: () => _announce('Opened settings'),
        ),
        VoiceCommand(
          phrases: const ['open sittings'],
          onMatch: () => _announce('Opened "sittings" (demo command)'),
        ),
        // Built-in localized "back"/"next" phrases, English + Spanish at once.
        VoiceCommand(
          phrases: CommonPhrases.merge(
            [CommonPhrases.en, CommonPhrases.es],
            (locale) => locale.back,
          ),
          onMatch: () => _announce('Back'),
        ),
      ],
    );
    _checkAvailability();
  }

  Future<void> _checkAvailability() async {
    final available = await _localVoice.isAvailable();
    final granted = await _localVoice.hasPermission();
    if (!mounted) return;
    setState(() {
      _available = available;
      _permissionGranted = granted;
    });
  }

  Future<void> _requestPermission() async {
    final granted = await _localVoice.requestPermission();
    if (!mounted) return;
    setState(() => _permissionGranted = granted);
  }

  void _setBackground(Color color, String name) {
    setState(() {
      _background = color;
      _lastCommand = 'Matched: background -> $name';
    });
  }

  void _announce(String message) {
    setState(() => _lastCommand = message);
  }

  void _resolveAmbiguous(VoiceCommandMatch match) {
    _registry.resolve(match);
    setState(() => _ambiguousCandidates = const []);
  }

  @override
  Widget build(BuildContext context) {
    return VoiceListener(
      registry: _registry,
      controller: _controller,
      mode: VoiceListeningMode.pushToTalk,
      onTranscript: (result) => setState(() => _transcript = result.transcript),
      onStatusChanged: (status) => setState(
        () => _isListening = status != VoiceListeningStatus.idle,
      ),
      onError: (error) => setState(
        () => _lastCommand = 'Error: ${error.code} — ${error.message}',
      ),
      child: Scaffold(
        appBar: AppBar(title: const Text('local_voice demo')),
        backgroundColor: _background,
        body: SafeArea(
          child: Padding(
            padding: const EdgeInsets.all(24),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                if (!_available)
                  const _InfoBanner(
                    'On-device speech recognition is not available on this '
                    'device/emulator.',
                  ),
                if (_available && !_permissionGranted)
                  _InfoBanner(
                    'Microphone permission is required.',
                    action: TextButton(
                      onPressed: _requestPermission,
                      child: const Text('Grant permission'),
                    ),
                  ),
                const SizedBox(height: 16),
                Text('Live transcript', style: Theme.of(context).textTheme.labelLarge),
                Text(
                  _transcript.isEmpty ? '…' : _transcript,
                  style: Theme.of(context).textTheme.headlineSmall,
                ),
                const SizedBox(height: 16),
                Text(_lastCommand, style: Theme.of(context).textTheme.bodyLarge),
                const SizedBox(height: 24),
                if (_ambiguousCandidates.isNotEmpty) _DisambiguationPrompt(
                  candidates: _ambiguousCandidates,
                  onPick: _resolveAmbiguous,
                ),
                const Spacer(),
                _MicButton(controller: _controller, isListening: _isListening),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class _MicButton extends StatelessWidget {
  const _MicButton({required this.controller, required this.isListening});

  final VoiceListenerController controller;
  final bool isListening;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: GestureDetector(
        onLongPressStart: (_) => controller.start(),
        onLongPressEnd: (_) => controller.stop(),
        child: CircleAvatar(
          radius: 40,
          backgroundColor: isListening ? Colors.redAccent : null,
          child: Icon(isListening ? Icons.mic : Icons.mic_none, size: 32),
        ),
      ),
    );
  }
}

class _InfoBanner extends StatelessWidget {
  const _InfoBanner(this.message, {this.action});

  final String message;
  final Widget? action;

  @override
  Widget build(BuildContext context) {
    return Card(
      color: Theme.of(context).colorScheme.errorContainer,
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Row(
          children: [
            Expanded(child: Text(message)),
            ?action,
          ],
        ),
      ),
    );
  }
}

class _DisambiguationPrompt extends StatelessWidget {
  const _DisambiguationPrompt({required this.candidates, required this.onPick});

  final List<VoiceCommandMatch> candidates;
  final void Function(VoiceCommandMatch match) onPick;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('Did you mean:'),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              children: [
                for (final candidate in candidates)
                  ActionChip(
                    label: Text(candidate.command.label),
                    onPressed: () => onPick(candidate),
                  ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
0
likes
150
points
79
downloads

Documentation

API reference

Publisher

verified publisherehsanur.com

Weekly Downloads

Privacy-first, on-device voice commands for Flutter. No cloud, no API keys, works in airplane mode.

Repository (GitHub)
View/report issues

Topics

#speech-recognition #voice #offline #privacy #accessibility

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on local_voice

Packages that implement local_voice