Flutter Speech Recognition (STT) Tutorial: Real‑Time Transcription on Android & iOS

Build a Flutter speech-to-text app with speech_to_text: setup, permissions, real-time UI, locales, and pro tips for accuracy, UX, and scalability.

ASOasis
8 min read
Flutter Speech Recognition (STT) Tutorial: Real‑Time Transcription on Android & iOS

Image used for representation purposes only.

Overview

Speech recognition (speech-to-text, or STT) can transform voice into transcripts in real time, powering voice search, note‑taking, captions, and accessibility features. In this tutorial you’ll build a Flutter app that listens to the microphone, shows live partial results, and outputs a final transcript. We’ll use the popular speech_to_text plugin for on‑device recognition and discuss how to switch to cloud engines when you need higher accuracy or custom models.

What you’ll build:

  • A single‑screen Flutter app with a mic button that toggles listening
  • Real‑time transcription with partial and final results
  • Language selection and basic sound‑level visualization
  • Production tips for permissions, UX, latency, and accuracy

Prerequisites

  • Flutter SDK installed and a working emulator or device
  • Basic knowledge of Dart and Flutter widgets
  • A physical device is recommended for microphone tests (desktop simulators can be unreliable for audio)

Choosing an approach: on‑device vs cloud

  • On‑device (speech_to_text): Uses iOS (SFSpeechRecognizer) and Android (SpeechRecognizer). Pros: privacy, low latency, simple setup. Cons: quality depends on OS language packs; offline varies by device/language.
  • Cloud (e.g., Google, Azure, AWS): Pros: higher accuracy, diarization, punctuation, domain adaptation, many languages. Cons: network latency, cost, auth/keys.

This tutorial focuses on on‑device via speech_to_text, with a brief section on cloud options.

Project setup

1) Add dependencies

Use flutter pub add to always pull the latest compatible versions:

flutter pub add speech_to_text

Optional (for a waveform glow):

flutter pub add flutter_animate

2) Android permissions (REQUIRED)

In android/app/src/main/AndroidManifest.xml add:

<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Needed if you’ll call cloud APIs -->
<uses-permission android:name="android.permission.INTERNET" />

The plugin will request microphone runtime permission at first use. Minimum Android SDK 21+ is typical for modern speech plugins.

3) iOS permissions (REQUIRED)

In ios/Runner/Info.plist add usage descriptions:

<key>NSSpeechRecognitionUsageDescription</key>
<string>Speech recognition is used to transcribe your voice.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is required for speech recognition.</string>

Set your iOS deployment target to a modern version (e.g., 12.0 or later) in ios/Podfile if needed.

App structure

We’ll create a single screen with:

  • Initialization and permission checks
  • Locale selection
  • A mic button that starts/stops listening
  • Real‑time text area and a final transcript area
  • Sound level visualization

main.dart

import 'package:flutter/material.dart';
import 'package:speech_to_text/speech_to_text.dart';
import 'package:speech_to_text/speech_recognition_result.dart';

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

class STTApp extends StatelessWidget {
  const STTApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter STT',
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
      home: const STTScreen(),
    );
  }
}

class STTScreen extends StatefulWidget {
  const STTScreen({super.key});
  @override
  State<STTScreen> createState() => _STTScreenState();
}

class _STTScreenState extends State<STTScreen> {
  final SpeechToText _stt = SpeechToText();

  bool _engineReady = false;
  bool _isListening = false;
  String _status = 'idle';
  String _error = '';

  double _soundLevel = 0;
  String _liveText = '';
  String _finalTranscript = '';

  List<LocaleName> _locales = [];
  LocaleName? _selectedLocale;

  @override
  void initState() {
    super.initState();
    _initSTT();
  }

  Future<void> _initSTT() async {
    final available = await _stt.initialize(
      onStatus: (s) => setState(() => _status = s),
      onError: (e) => setState(() => _error = e.errorMsg),
      debugLogging: false,
    );

    if (!mounted) return;
    if (available) {
      _locales = await _stt.locales();
      _selectedLocale = await _stt.systemLocale();
    }
    setState(() => _engineReady = available);
  }

  Future<void> _startListening() async {
    if (!_engineReady) return;
    setState(() {
      _isListening = true;
      _error = '';
      _liveText = '';
    });
    await _stt.listen(
      onResult: _onSpeechResult,
      listenFor: const Duration(minutes: 1), // auto-stop after a minute
      pauseFor: const Duration(seconds: 3),  // stop if silence for 3s
      partialResults: true,
      localeId: _selectedLocale?.localeId,
      onSoundLevelChange: (level) => setState(() => _soundLevel = level),
      cancelOnError: true,
      listenMode: ListenMode.dictation,
    );
  }

  void _onSpeechResult(SpeechRecognitionResult result) {
    setState(() {
      _liveText = result.recognizedWords;
      if (result.finalResult) {
        _finalTranscript += (_finalTranscript.isEmpty ? '' : ' ') + _liveText.trim();
        _liveText = '';
      }
    });
  }

  Future<void> _stopListening() async {
    await _stt.stop();
    if (!mounted) return;
    setState(() => _isListening = false);
  }

  Future<void> _cancelListening() async {
    await _stt.cancel();
    if (!mounted) return;
    setState(() {
      _isListening = false;
      _liveText = '';
    });
  }

  @override
  void dispose() {
    _stt.stop();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final canUse = _engineReady && _error.isEmpty;
    return Scaffold(
      appBar: AppBar(title: const Text('Flutter Speech‑to‑Text')),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: !canUse
            ? null
            : _isListening
                ? _stopListening
                : _startListening,
        label: Text(_isListening ? 'Stop' : 'Listen'),
        icon: Icon(_isListening ? Icons.stop : Icons.mic),
      ),
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Row(
                children: [
                  const Text('Language:'),
                  const SizedBox(width: 12),
                  Expanded(
                    child: DropdownButton<LocaleName>(
                      isExpanded: true,
                      value: _selectedLocale,
                      hint: const Text('System default'),
                      items: _locales
                          .map((l) => DropdownMenuItem(
                                value: l,
                                child: Text('${l.name} (${l.localeId})'),
                              ))
                          .toList(),
                      onChanged: (l) => setState(() => _selectedLocale = l),
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 12),
              _buildLevelMeter(),
              const SizedBox(height: 12),
              _StatusLine(status: _status, error: _error, ready: _engineReady),
              const Divider(height: 24),
              const Text('Live (partial):', style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 6),
              Container(
                width: double.infinity,
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: Colors.indigo.withOpacity(.05),
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Text(_liveText.isEmpty ? '—' : _liveText),
              ),
              const SizedBox(height: 12),
              Row(
                children: [
                  const Text('Final transcript:', style: TextStyle(fontWeight: FontWeight.bold)),
                  const Spacer(),
                  IconButton(
                    tooltip: 'Clear',
                    onPressed: () => setState(() => _finalTranscript = ''),
                    icon: const Icon(Icons.delete_outline),
                  ),
                ],
              ),
              Expanded(
                child: SingleChildScrollView(
                  child: Text(_finalTranscript.isEmpty ? 'Tap Listen and start speaking…' : _finalTranscript),
                ),
              ),
              Row(
                children: [
                  OutlinedButton.icon(
                    onPressed: _cancelListening,
                    icon: const Icon(Icons.cancel_outlined),
                    label: const Text('Cancel'),
                  ),
                  const SizedBox(width: 12),
                  OutlinedButton.icon(
                    onPressed: () => setState(() {
                      _liveText = '';
                      _finalTranscript = '';
                    }),
                    icon: const Icon(Icons.cleaning_services_outlined),
                    label: const Text('Clear All'),
                  ),
                ],
              )
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildLevelMeter() {
    final normalized = (_soundLevel.clamp(0, 60)) / 60.0; // rough normalization
    return Container(
      height: 10,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12),
        color: Colors.grey.shade300,
      ),
      child: FractionallySizedBox(
        alignment: Alignment.centerLeft,
        widthFactor: normalized,
        child: Container(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(12),
            gradient: const LinearGradient(colors: [Colors.indigo, Colors.cyan]),
          ),
        ),
      ),
    );
  }
}

class _StatusLine extends StatelessWidget {
  final String status;
  final String error;
  final bool ready;
  const _StatusLine({required this.status, required this.error, required this.ready});
  @override
  Widget build(BuildContext context) {
    final text = error.isNotEmpty
        ? 'Error: $error'
        : ready
            ? 'Status: $status'
            : 'Speech engine not available';
    return Text(text, style: TextStyle(color: error.isNotEmpty ? Colors.red : null));
  }
}

Run the app on a real device. Tap Listen and start speaking; partial results appear in “Live” and final results are appended to the transcript.

Understanding key options

  • listenFor: Upper bound for a session; auto‑stops when exceeded.
  • pauseFor: Voice activity detection (VAD) timeout; session stops after this duration of silence.
  • partialResults: Enable incremental hypotheses to power real‑time UI.
  • localeId: Choose language/region; present a dropdown of _stt.locales().
  • onSoundLevelChange: Use for visual feedback and debugging mic input levels.
  • listenMode: confirmation (short utterances) vs dictation (long form). Dictation is ideal for notes.

Handling permissions and errors

  • Initialization can fail if the user denies mic/speech permissions. Reflect errors in the UI and provide a retry path in Settings.
  • Use cancelOnError: true so the engine cleans up automatically.
  • Common statuses: notListening, listening, done. Use these to drive button states.

UX best practices

  • Make the listening state obvious: bright mic indicator, glowing ring, or animated waveform.
  • Provide a cancel action separate from stop, so users can abandon a partial utterance.
  • Auto‑punctuation varies by platform; consider post‑processing to add sentence breaks.
  • If you expect long dictations, restart automatically on done to reduce friction (while warning about battery usage).

Example: auto‑restart on silence

// In onStatus callback during initialize:
onStatus: (s) {
  setState(() => _status = s);
  if (s == 'done' && _isListening) {
    // Optionally chain sessions for continuous dictation
    _startListening();
  }
},

Accuracy tips

  • Choose the correct locale (e.g., en_US vs en_GB) and encourage users to install offline language packs on Android for better offline results.
  • Reduce ambient noise; show a sound level meter and prompt users to move closer to the mic.
  • For domain‑specific terms (medical, legal, product names), consider cloud STT with phrase hints or custom language models.

Persisting and exporting transcripts

You can save _finalTranscript to local storage or share it:

// Example: share text (using share_plus)
// flutter pub add share_plus
import 'package:share_plus/share_plus.dart';
...
ElevatedButton.icon(
  onPressed: _finalTranscript.isEmpty ? null : () => Share.share(_finalTranscript),
  icon: const Icon(Icons.share_outlined),
  label: const Text('Share'),
)

Testing checklist

  • Test both partial and final results.
  • Toggle airplane mode to observe offline behavior.
  • Switch locales and verify character sets and punctuation.
  • Try long dictations to confirm memory and lifecycle handling.

Going beyond: Cloud speech (conceptual quick‑start)

When you need higher accuracy, punctuation, word‑time offsets, or diarization, use a cloud engine. The recommended production architecture is:

  • The Flutter app streams raw audio to your backend (WebSocket or gRPC).
  • The backend authenticates with the cloud provider and performs streaming recognition.
  • The backend returns partial/final transcripts to the app.

Why avoid using cloud keys directly in the app? Embedding service credentials in a client app is insecure; they can be extracted from the bundle.

If you still want a quick prototype (not for production), you can try a client‑side plugin that accepts a service account JSON. Keep secrets out of version control and rotate often. For production, move credentials to your server.

Troubleshooting

  • initialize returns false: Ensure permissions are granted, Info.plist and AndroidManifest.xml entries are present, and test on a physical device.
  • No transcription, only silence: Validate that onSoundLevelChange shows activity; if not, check mic permission, Bluetooth routes, or external mics.
  • Locale not supported: Some locales may not be available on a device; fall back to system default.
  • Session ends too quickly: Increase pauseFor and listenFor, or switch to ListenMode.dictation.

Performance and privacy

  • On‑device STT keeps audio on the device (subject to OS implementation); good for privacy‑sensitive apps.
  • Continuous dictation impacts battery life; pause when the app goes to background and provide explicit controls.
  • Do not store raw audio unless users opt in; encrypt at rest and in transit if you must keep it.

What’s next

  • Add a push‑to‑talk mic that listens only while pressed.
  • Stream transcripts to a collaborative document.
  • Combine STT with a text‑to‑speech (TTS) engine for voice assistants.
  • Use cloud STT with phrase hints for jargon‑heavy domains.

Summary

You built a Flutter app that performs speech‑to‑text with real‑time feedback, locale switching, and basic sound visualization. The speech_to_text plugin gives you a fast, privacy‑friendly baseline on both Android and iOS. When your use‑case demands higher accuracy, long‑form transcription, or advanced features, graduate to a cloud architecture with a secure backend while reusing the same Flutter UI patterns you established here.

Related Posts