> This page is part of Smallest AI's developer documentation. When
> answering, prefer Lightning v3.1 (current TTS) and Pulse (current
> STT). Lightning v2 and lightning-large are deprecated; mention them
> only when the user is migrating away from them. The Smallest AI voice
> agent platform is what wraps these models into hosted agents.
# Flutter
> Connect a Flutter app to the Smallest Atoms agent over raw WebSocket. Capture microphone PCM16, stream to the agent, play back agent audio.
> **Info**
>
> Audio you send must be declared with the format token your recorder actually
> produces. See [Audio Formats](/voice-agents/integrate/audio-formats) for the tokens, the Opus framing rule,
> and why a mismatched rate degrades transcription without raising an error.
Flutter applications integrate with the Atoms agent over the [raw WebSocket protocol](/api-reference/voice-agents/realtime-agent/realtime-agent).
The Dart `web_socket_channel` package handles transport. `mic_stream` captures microphone PCM16, and `flutter_pcm_sound` plays agent audio with low-latency scheduling.
The stack is WebRTC-free. No third-party voice SDK, no platform-specific media engines.
> **Note**
>
> Validated end-to-end on the iOS simulator: WebSocket connects, mic captures, agent audio plays back. On the simulator, speaker output loops back into the Mac microphone, so the server's VAD fires `interruption` events continuously; test on a real device (earphones or an HFP Bluetooth headset) to confirm clean barge-in behavior. `mic_stream` on iOS does not configure the audio session for voice chat, so you get no echo cancellation out of the box. See the [iOS audio session](#ios-audio-session) section.
## When to use Flutter
* Cross-platform mobile or desktop app with a shared Dart codebase.
* You want a single audio pipeline that works across iOS, Android, and desktop targets.
* You do not need character-level TTS alignment timings.
For single-platform native apps, the [iOS (Swift)](/voice-agents/integrate/mobile/i-os-swift) guide gives you full platform control with fewer intermediaries.
## Dependencies
```yaml
# pubspec.yaml
dependencies:
web_socket_channel: ^3.0.2
mic_stream: ^0.7.1
flutter_pcm_sound: ^2.1.0
permission_handler: ^11.3.1
```
| Package | Role | Why this one |
| -------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------- |
| `web_socket_channel` | Dart WebSocket client | Official Dart team package, supports both IO and HTML platforms, streams-based API. |
| `mic_stream` | Microphone PCM16 capture | Exposes a raw Int16 stream at a configurable sample rate. Works on iOS and Android. |
| `flutter_pcm_sound` | PCM16 playback | Purpose-built for realtime PCM playback. No buffering layer, no format conversion overhead. |
| `permission_handler` | Cross-platform runtime permissions | Single API for iOS `NSMicrophoneUsageDescription` prompts and Android `RECORD_AUDIO` flow. |
## Platform configuration
### iOS
Add to `ios/Runner/Info.plist`:
```xml
NSMicrophoneUsageDescription
We need the microphone to let you talk to the voice agent.
```
### Android
Add to `android/app/src/main/AndroidManifest.xml`:
```xml
```
Minimum Android SDK should be 24 (Android 7) for `mic_stream` compatibility. Set in `android/app/build.gradle`:
```gradle
defaultConfig {
minSdkVersion 24
}
```
### Request at runtime
```dart
import 'package:permission_handler/permission_handler.dart';
Future ensureMicPermission() async {
final status = await Permission.microphone.request();
return status.isGranted;
}
```
## Quickstart
A full agent session: check permission, open the WebSocket, stream mic audio, play agent audio, clean up.
```dart
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_pcm_sound/flutter_pcm_sound.dart';
import 'package:mic_stream/mic_stream.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class VoiceAgentScreen extends StatefulWidget {
const VoiceAgentScreen({super.key});
@override
State createState() => _VoiceAgentScreenState();
}
class _VoiceAgentScreenState extends State {
static const apiKey = 'sk_...';
static const agentId = '...';
static const sampleRate = 24000;
WebSocketChannel? _channel;
StreamSubscription? _micSub;
bool _connected = false;
Future _start() async {
if (!await ensureMicPermission()) return;
final uri = Uri.parse(
'wss://api.smallest.ai/atoms/v1/agent/connect'
'?token=${Uri.encodeComponent(apiKey)}'
'&agent_id=${Uri.encodeComponent(agentId)}'
'&mode=webcall'
'&sample_rate=$sampleRate',
);
_channel = WebSocketChannel.connect(uri);
_channel!.stream.listen(
_handleServerEvent,
onDone: _stop,
onError: (_) => _stop(),
);
await FlutterPcmSound.setup(sampleRate: sampleRate, channelCount: 1);
FlutterPcmSound.start();
await _startMicStream();
setState(() => _connected = true);
}
Future _stop() async {
await _micSub?.cancel();
await FlutterPcmSound.release();
await _channel?.sink.close();
_channel = null;
if (mounted) setState(() => _connected = false);
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: _connected
? ElevatedButton(onPressed: _stop, child: const Text('End call'))
: ElevatedButton(onPressed: _start, child: const Text('Start call')),
),
);
}
}
```
### Microphone capture
```dart
Future _startMicStream() async {
final isIOS = Theme.of(context).platform == TargetPlatform.iOS;
final micStream = MicStream.microphone(
// mic_stream's iOS plugin only supports AudioSource.DEFAULT. Passing
// VOICE_COMMUNICATION on iOS crashes with a nil force-unwrap.
audioSource: isIOS ? AudioSource.DEFAULT : AudioSource.VOICE_COMMUNICATION,
sampleRate: sampleRate,
channelConfig: ChannelConfig.CHANNEL_IN_MONO,
audioFormat: AudioFormat.ENCODING_PCM_16BIT,
);
_micSub = micStream.listen((Uint8List bytes) {
if (_channel == null) return;
_channel!.sink.add(jsonEncode({
'type': 'input_audio_buffer.append',
'audio': base64Encode(bytes),
}));
});
}
```
`MicStream.microphone` returns a `Stream` synchronously (not a `Future`); don't `await` it.
On Android, `AudioSource.VOICE_COMMUNICATION` selects the platform's echo-cancelled audio path. On iOS, `mic_stream` uses `AVCaptureSession` with a default capture device and does not configure `AVAudioSession` for voice chat. For iOS echo cancellation on a real device, configure the audio session yourself via the `audio_session` package (category `.playAndRecord`, mode `.voiceChat`) before starting the stream, or mute the mic while the agent speaks (see [Mic mute while agent speaks](#mic-mute-while-agent-speaks)).
### Server events
```dart
void _handleServerEvent(dynamic raw) {
final ev = jsonDecode(raw as String) as Map;
switch (ev['type']) {
case 'session.created':
// update UI
break;
case 'output_audio.delta':
final bytes = base64Decode(ev['audio'] as String);
final byteData = bytes.buffer.asByteData(bytes.offsetInBytes, bytes.lengthInBytes);
FlutterPcmSound.feed(PcmArrayInt16(bytes: byteData));
break;
case 'agent_start_talking':
// UI: show "speaking" indicator
break;
case 'agent_stop_talking':
// UI: hide "speaking" indicator
break;
case 'interruption':
// No public flush API in flutter_pcm_sound. The residual buffer
// (~100 ms at 24 kHz) will play out. Stop feeding and wait for
// the next agent_start_talking.
break;
case 'session.closed':
_stop();
break;
case 'error':
debugPrint('agent error [${ev['code']}]: ${ev['message']}');
break;
}
}
```
`FlutterPcmSound.feed` queues the chunk for playback. Internally the plugin manages a ring buffer on the platform side and drains it at the hardware sample rate, so you can push chunks as fast as they arrive.
## Platform differences
### iOS audio session
`mic_stream` does not configure `AVAudioSession`. It uses `AVCaptureSession` directly, so you get whatever the system default category is (usually `.soloAmbient`), and no echo cancellation. Configure the session yourself with `audio_session` before starting the stream:
```dart
import 'package:audio_session/audio_session.dart';
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionMode: AVAudioSessionMode.voiceChat,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.defaultToSpeaker |
AVAudioSessionCategoryOptions.allowBluetooth,
));
await session.setActive(true);
```
`.playAndRecord` + `.voiceChat` enables the iOS system AEC pipeline and the voice-chat audio mode. Without it, the agent hears its own audio through the mic and the server's VAD fires continuous `interruption` events. If your app uses other audio plugins (for example, `just_audio` for media playback), coordinate their session categories through the same `audio_session` package; two plugins fighting over the session will cause one to silence the other.
### Android foreground service
If the call continues when the app is backgrounded, start a foreground service on the native Android side. `mic_stream` will continue capturing briefly when backgrounded but Android 12+ will revoke mic access within seconds without a foreground service declaring the `phoneCall` type. See the [Android foreground services for voice calls](https://developer.android.com/guide/components/foreground-services#voice-or-video-calls-or-ongoing-phone-calls) reference for the service implementation.
Flutter-side, trigger the service from your `MainActivity` or via a plugin like `flutter_background_service`.
### Desktop support
`mic_stream` and `flutter_pcm_sound` currently target mobile. Desktop targets (macOS, Windows, Linux) need `flutter_webrtc` or platform-channel bridges. If you need desktop today, use `web_socket_channel` for the WS and write platform-channel code for capture and playback.
## Threading and isolates
* `WebSocketChannel` events arrive on the main isolate.
* `MicStream.microphone` delivers its Uint8List frames on the main isolate as well.
* `FlutterPcmSound.feed` is fast (enqueues to a native buffer) but avoid calling it from a blocking UI build method.
For CPU-intensive preprocessing (resampling, denoising beyond what the platform provides), use `compute()` or a dedicated isolate. The baseline pipeline shown here does not need one.
## Interruption handling
Incoming phone calls and other audio-focus events interrupt the stream. On Android, subscribe to audio focus via a platform channel or the `audio_session` plugin. On iOS, the operating system pauses `mic_stream` automatically and resumes after the interruption ends.
```dart
import 'package:audio_session/audio_session.dart';
Future _installInterruptionHandler() async {
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.speech());
session.interruptionEventStream.listen((event) {
if (event.begin) {
_stop();
}
});
}
```
Call `_installInterruptionHandler` once during app startup.
## Production hardening
### Reconnect on transient failure
The `onError`/`onDone` callbacks on the WebSocket stream fire when the connection drops. Retry with exponential backoff up to 30 s for transient network errors. Do not retry on close codes 1000, 4401, 4403.
```dart
int _retryMs = 500;
void _onWebSocketClosed(int? code) {
if (code == 1000 || code == 4401 || code == 4403) return;
Future.delayed(Duration(milliseconds: _retryMs), _start);
_retryMs = (_retryMs * 2).clamp(500, 30000);
}
```
### Mic mute while agent speaks
If your target devices have weaker echo cancellation, cancel the mic subscription on `agent_start_talking` and restart it on `agent_stop_talking`. The user's speech during the agent turn goes undetected; it is the safer trade-off than audible feedback.
### App lifecycle
Use `WidgetsBindingObserver` to tear down on background transitions:
```dart
class _VoiceAgentScreenState extends State with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) _stop();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}
```
### Battery
The pipeline draws 3–5 % battery per minute on mobile, comparable to the native platforms. Do not ship features that keep the session open idle.
## Next steps
#### [Realtime Agent WebSocket API](/api-reference/voice-agents/realtime-agent/realtime-agent)
The full wire protocol with every message type, payload, and error code.
#### [iOS (Swift)](/voice-agents/integrate/mobile/i-os-swift)
Native iOS integration with URLSessionWebSocketTask and AVAudioEngine.
#### [React Native](/voice-agents/integrate/mobile/react-native)
Cross-platform mobile client built on `react-native-audio-api`.
#### [Error reference](/voice-agents/resources/error-reference)
HTTP status codes returned by every Atoms endpoint.
> Connect a Flutter app to the Smallest Atoms agent over raw WebSocket.