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.
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
Flutter NFC: Read and Write NDEF Tags on Android and iOS
Learn to read and write NFC tags in Flutter with nfc_manager: setup, NDEF records, code, UX, capacity, and cross‑platform pitfalls.
Build a Flutter QR Code Scanner and Generator in Flutter
Build a Flutter app that scans and generates QR codes with mobile_scanner and qr_flutter. Includes setup, code, exporting, UX, and tips.
Flutter + Google Maps: A Complete Integration Guide
Integrate Google Maps in Flutter: setup keys, Android/iOS config, markers, directions, clustering, styling, and best practices.