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.

local_voice #

On-device, zero-cloud voice commands for Flutter. No API keys, no network calls, no per-request cost — speech recognition runs entirely on the device via SFSpeechRecognizer (iOS, requiresOnDeviceRecognition = true) and SpeechRecognizer (Android, EXTRA_PREFER_OFFLINE). Once the platform's on-device model is downloaded, local_voice works in airplane mode.

🔒 We never send audio anywhere, ever. There is no server component, no analytics, and no third-party SDK in this package. Every method call in local_voice maps directly onto an on-device platform API — if a device can't recognize speech on-device, local_voice fails loudly rather than silently falling back to the network.

Define voice commands the way you'd define routes:

final registry = VoiceCommandRegistry(
  commands: [
    VoiceCommand(
      phrases: ['open settings', 'go to settings', 'settings please'],
      onMatch: () => Navigator.pushNamed(context, '/settings'),
      intent: const NavigateIntent('/settings'),
    ),
    VoiceCommand(
      phrases: ['turn on wifi', 'enable wifi'],
      intent: const ToggleIntent('wifi', value: true),
      onMatch: () => wifi.enable(),
    ),
  ],
);

Phrases are matched fuzzily (Levenshtein distance + token-overlap scoring), not exact-string-matched — "go to settings" and "settings please" both trigger the open settings command above.

Quickstart #

final controller = VoiceListenerController();

VoiceListener(
  registry: registry,
  controller: controller,          // drives push-to-talk
  mode: VoiceListeningMode.pushToTalk,
  child: Scaffold(
    body: ...,
    floatingActionButton: GestureDetector(
      onLongPressStart: (_) => controller.start(),
      onLongPressEnd: (_) => controller.stop(),
      child: const FloatingActionButton(child: Icon(Icons.mic)),
    ),
  ),
)

Before starting a session, check availability/permission:

const localVoice = LocalVoice();

if (await localVoice.isAvailable() && !await localVoice.hasPermission()) {
  await localVoice.requestPermission();
}

Listening modes #

  • VoiceListeningMode.pushToTalk (default) — the app starts/stops each session explicitly (hold a mic button, or toggle it). One session, one final transcript.
  • VoiceListeningMode.continuous — the widget automatically restarts a session after every final transcript, so the app stays listening for the next command. Costs more battery, so VoiceListener also pauses listening while the app is backgrounded and resumes on foreground.

Ambiguity, not silent guesses #

If two or more commands score close together, local_voice refuses to guess. Register onAmbiguous on the registry to show a "did you mean X or Y?" prompt, then call registry.resolve(chosen):

VoiceCommandRegistry(
  onAmbiguous: (candidates) => showDidYouMeanSheet(candidates),
  ...
)

Intents, for a fallback UI #

Commands can carry a typed VoiceIntent (NavigateIntent, ToggleIntent, or your own CustomIntent) alongside — or instead of — an onMatch callback. This lets the same registry that drives voice input also generate a plain menu/button UI for users who can't or won't use voice:

for (final command in registry.commands)
  ListTile(title: Text(command.label), onTap: command.onMatch),

Multi-language phrase sets #

CommonPhrases ships pre-translated open/close/back/next/yes/no/cancel/ confirm phrases in 11 languages (en, es, fr, de, pt, hi, bn, ar, zh, ja, ru) so an internationalized app gets a reasonable command set without sourcing its own translations:

final locale = CommonPhrases.byLocale(Localizations.localeOf(context).languageCode);
registry.register(locale!.backCommand(onMatch: () => Navigator.pop(context)));

Platform setup #

iOS — add to Info.plist:

<key>NSSpeechRecognitionUsageDescription</key>
<string>Used to recognize voice commands, entirely on-device.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Used to capture voice commands, entirely on-device.</string>

AndroidRECORD_AUDIO is declared by the plugin's manifest and merged into your app automatically; no action needed beyond calling requestPermission() at runtime.

Architecture #

  • FuzzyMatcher — pure Dart, Levenshtein + token-overlap scoring. No Flutter dependency, fully unit-testable without a device.
  • VoiceCommandRegistry — holds VoiceCommands, scores transcripts, applies a confidence threshold, and calls onAmbiguous instead of guessing when candidates are close.
  • VoiceListener — wraps a subtree, drives the platform event stream into the registry, and manages push-to-talk/continuous session lifecycle.
  • Platform layer — thin Kotlin/Swift wrappers over SpeechRecognizer / SFSpeechRecognizer, explicitly requesting on-device recognition.

Status #

The example app's native Android/iOS runner projects aren't checked in — generate them locally with flutter create . from inside example/ (this package was authored without the Flutter SDK available in-session). The Dart API, fuzzy matcher, command registry, and VoiceListener widget are complete and unit-tested; the platform channel implementations are written to the documented SpeechRecognizer/SFSpeechRecognizer on-device APIs but need on-device verification before a 1.0 release.

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