widget_chat is live on pub.dev — drop-in AI chat for Flutter, FlutterFlow, React & Web. Start free →

← Back to Blog
Flutter Voice AI Silent on iPhone? Fix AVAudioSession

Flutter Voice AI Silent on iPhone? Fix AVAudioSession

flutteriosavaudiosessionvoice-aiflutterflow

Flutter Voice AI Silent on iPhone? Fix AVAudioSession

Your WidgetChat voice assistant works on the iOS Simulator, works on Android, works on your own iPhone — and then a user files: "I tap the mic, the captions scroll, but the bot never talks." Nine times out of ten the reporter's ring/silent switch is flipped down, and the tenth time your audio is coming out of the earpiece at the top of the phone instead of the loudspeaker.

Both are AVAudioSession problems, both are invisible in testing, and the popular one-line fix (AVAudioSessionCategory.playback) silently kills the microphone your speech-to-speech assistant needs. Here's the correct configuration.

Why you can never reproduce it

The ring/silent switch (or the Action Button on iPhone 15 Pro and later) is a hardware toggle. No simulator has one. There is also no public API to read its position — you cannot query "is the phone muted?" and branch on it. So the only two ways to hit this bug are to flip the switch on a real device, or to ship.

The switch's effect depends entirely on your session's category:

Category Records? Plays when the switch is on silent? Default output route
soloAmbient (iOS default) no no speaker
ambient no no speaker
playback no yes speaker
record yes n/a (output muted)
playAndRecord yes yes receiver (earpiece)

If your app never configures a session, iOS leaves you on soloAmbient — muted by the switch. That's failure mode #1.

If you fix it with playback, the switch stops mattering but the input side of the route is gone: your recorder gets an empty or failing input node, so the assistant hears nothing and therefore says nothing. Some devs then hop between record and playback around each turn — which produces an audible route-change click, a ~100–300 ms activation gap that eats the first syllable, and makes barge-in impossible, because you can't be recording and playing at the same time.

A speech-to-speech assistant is a phone call, not a media player. Use playAndRecord.

Failure mode #2: playAndRecord whispers into the earpiece

playAndRecord ignores the silent switch, but it defaults to routing output to the receiver — the little speaker you hold to your ear. Held at arm's length, that reads as "no sound" in a bug report. Two fixes, and you want the first:

  • AVAudioSessionCategoryOptions.defaultToSpeaker — a category option, so it's part of your configuration and survives route changes. Valid only with playAndRecord; pairing it with playback fails with a PlatformException carrying OSStatus -50 (invalid parameter).
  • overrideOutputAudioPort(AVAudioSessionPortOverride.speaker) — an imperative override you re-apply after each route change. Useful as a "speakerphone" toggle, not as your baseline.

Watch out for one more trap: setting the mode can reset previously set category options. AVAudioSessionMode.voiceChat and .videoChat enable Apple's voice-processing path (echo cancellation — the thing that stops your bot hearing itself and barging in on itself), but they also bias output toward the earpiece. So set category, options, and mode in a single call, and keep defaultToSpeaker in the options. The audio_session package's configure() does exactly that.

The copy-paste config (audio_session 0.2.4)

# pubspec.yaml
dependencies:
  audio_session: ^0.2.4
import 'package:audio_session/audio_session.dart';

/// Call this once, immediately before you start a voice turn
/// (i.e. when the user taps the mic), not at app startup.
Future<void> enterVoiceCallAudioMode() async {
  final session = await AudioSession.instance;

  await session.configure(AudioSessionConfiguration(
    // playAndRecord: mic stays live AND output ignores the ring/silent switch.
    avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
    avAudioSessionCategoryOptions:
        AVAudioSessionCategoryOptions.defaultToSpeaker |   // not the earpiece
        AVAudioSessionCategoryOptions.allowBluetooth |     // AirPods/car HFP
        AVAudioSessionCategoryOptions.allowBluetoothA2dp |
        AVAudioSessionCategoryOptions.allowAirPlay,
    // voiceChat turns on Apple's echo cancellation, which is what makes
    // barge-in work: the mic doesn't re-hear the assistant's own voice.
    avAudioSessionMode: AVAudioSessionMode.voiceChat,
    avAudioSessionRouteSharingPolicy:
        AVAudioSessionRouteSharingPolicy.defaultPolicy,
    avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
    androidAudioAttributes: const AndroidAudioAttributes(
      contentType: AndroidAudioContentType.speech,
      usage: AndroidAudioUsage.voiceCommunication,
    ),
    androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
    androidWillPauseWhenDucked: true,
  ));

  await session.setActive(true);
}

Note AudioSessionConfiguration.speech() is not what you want here: that preset is playback + spokenAudio, built for podcast apps. It ignores the silent switch, but it has no input.

And release the session when the call ends — playAndRecord keeps the orange mic indicator lit, lowers other apps' output quality, and on some devices reduces max volume:

Future<void> exitVoiceCallAudioMode() async {
  final session = await AudioSession.instance;
  await session.setActive(
    false,
    avAudioSessionSetActiveOptions:
        AVAudioSessionSetActiveOptions.notifyOthersOnDeactivation,
  );
}

Also confirm NSMicrophoneUsageDescription is in ios/Runner/Info.plist. Without it the app is terminated on first mic access — which looks like yet another "the bot doesn't talk" report.

Debugging the route on a real device

When a user says there's still no sound, log where the audio is actually going:

import 'dart:io';
import 'package:audio_session/audio_session.dart';

Future<void> logAudioRoute() async {
  if (!Platform.isIOS) return;
  final route = await AVAudioSession().currentRoute;
  // Expect: outputs -> Speaker. If it says "Receiver", defaultToSpeaker
  // isn't applied (wrong category, or the mode reset your options).
  print('outputs: ${route.outputs.map((o) => o.portName).toList()}');
  print('inputs:  ${route.inputs.map((i) => i.portName).toList()}');
}

The FlutterFlow version

FlutterFlow apps hit this the hardest, because Test Mode and web preview never touch a real ring switch. Add it as a custom action:

  1. Custom Code → Custom Actions → + Add, name it enterVoiceCallAudioMode.
  2. In the action's Pubspec Dependencies field, add audio_session: ^0.2.4.
  3. Paste the body, then call the action in the mic button's On Tap chain — before the action that opens the WidgetChat voice call.
// FlutterFlow Custom Action: enterVoiceCallAudioMode
// Pubspec Dependencies: audio_session: ^0.2.4
import 'dart:io';
import 'package:audio_session/audio_session.dart';

Future<void> enterVoiceCallAudioMode() async {
  if (kIsWeb || !Platform.isIOS) return; // Android/web need no change here
  final session = await AudioSession.instance;
  await session.configure(AudioSessionConfiguration(
    avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
    avAudioSessionCategoryOptions:
        AVAudioSessionCategoryOptions.defaultToSpeaker |
        AVAudioSessionCategoryOptions.allowBluetooth |
        AVAudioSessionCategoryOptions.allowBluetoothA2dp,
    avAudioSessionMode: AVAudioSessionMode.voiceChat,
  ));
  await session.setActive(true);
}

(FlutterFlow's boilerplate already imports flutter/material.dart, which is where kIsWeb comes from via foundation.dart — if your project complains, add import 'package:flutter/foundation.dart';.) Then enable Microphone under Settings → Permissions so the usage description lands in Info.plist, and test on a physical iPhone with the switch flipped — Test Mode will not catch this.

Is overriding silent mode rude?

Sometimes. The honest framing is consent, and iOS gives you no way to read the switch, so you decide by intent:

  • Fair game: the user tapped the mic to start a voice call. That's an explicit, foreground, session-scoped request to have a spoken conversation — the same reason a phone call, a voice memo, or Siri's response plays through a silenced phone. Tear the session down the moment the call ends.
  • Rude: auto-starting voice, playing a spoken greeting when the widget opens, notification chimes, or leaving playAndRecord active app-wide "just in case". A user who muted their phone in a meeting and gets a talking support bot from a button they didn't press will one-star you.

Two things keep the polite path polite. WidgetChat's voice calls show live captions during the session, so a user who genuinely can't have audio still gets the whole answer on screen (captions default is configurable per project in the dashboard's Voice section, alongside the voice name and max session length). And voice is plan-gated by a monthly voice-minute pool — so build a text path for when it's off. That's the same widget and the same conversation, and if you're driving text yourself, it's the streaming endpoint:

// POST https://api.widgetchat.app/v1/chat/stream  → token-by-token SSE `data:` lines
final req = http.Request('POST', Uri.parse('https://api.widgetchat.app/v1/chat/stream'));

The 60-second checklist

  1. Real iPhone, ring/silent switch on silent.
  2. Tap the mic. If it's silent → your category is ambient/soloAmbient/unset. Fix: playAndRecord.
  3. Hold the phone to your ear. If you hear it there → add defaultToSpeaker.
  4. Speak over the assistant. If it can't hear you → add voiceChat mode for echo cancellation.
  5. End the call and play a song in Music. If it's quiet or ducked → you forgot setActive(false).

Get those five right and "the bot doesn't talk on iPhone" disappears from your inbox for good.

Try WidgetChat free

WidgetChat drops a real-time AI support chatbot into your Flutter or FlutterFlow app — streaming text answers from your own content, plus live speech-to-speech voice with barge-in, captions, and on-screen product cards, all in the same widget and the same dashboard. Provider keys stay server-side; no proprietary SDK needed to integrate. Try WidgetChat free at widgetchat.app.

The audio_session package on pub.dev — the Flutter API used to set the AVAudioSession category and options.

Apple's docs: defaultToSpeaker is valid only with the playAndRecord category.

WidgetChat — the AI support chatbot with live voice you embed in Flutter and FlutterFlow apps.

Author

About the author

Widget Chat is a team of developers and designers passionate about creating the best AI chatbot experience for Flutter, web, and mobile apps.

Comments

Comments are coming soon. We'd love to hear your thoughts!