Flutter Web Voice AI Silent on Safari? Unlock AudioContext
Your Flutter web build talks fine in desktop Chrome and on Android. Open it in Safari or iPhone Safari, tap the mic, speak, and the captions stream in while the speaker stays dead. No exception, no red log line, no failed request. That silence is Safari's autoplay policy: a Web Audio AudioContext starts out suspended and only a trusted user gesture is allowed to move it to running. The fix is to call resume() from inside the same tap handler that starts the WidgetChat voice call.
The mobile voice fixes you may already have shipped do not help here. Routing audio away from the iPhone earpiece is an iOS audio-session problem, and the iOS mic permission prompt not showing is an Info.plist problem. Web has neither. It has a browser policy, and it fails silently.
Why the reply never plays
Chrome prints the hint in the console: The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page. Safari is quieter, but the rule is the same. The HTML standard defines which events grant user activation: trusted keydown, mousedown, pointerdown (mouse), pointerup (touch or pen), and touchend. Note what is missing: touchstart does not count, and neither does anything that runs after an await has returned you to the event loop.
That last point is the usual culprit in a voice flow. The tap handler asks for the mic, awaits the permission promise, awaits a socket, then creates or resumes the audio context. By then the gesture is gone. Captions keep working because they are just text over the network. Only playback is gated.
A few more Safari details matter for voice:
- Safari caps a page at about four open
AudioContextinstances. Create one, keep it, reuse it. - On iPhone, when the tab loses focus (a phone call, screen lock, app switch) the context moves to
interruptedinstead ofsuspended. You have to resume it again, and on some iOS 17 builds a fresh context is the only thing that works. Check for anything other thanrunning, not justsuspended. - A ringer switch set to silent mutes Web Audio in iOS Safari. Worth a line in your support macro.
The unlock helper (dart:js_interop)
Flutter's GestureDetector.onTap and ElevatedButton.onPressed fire from the browser's pointerup event, synchronously, so code at the top of the handler still runs inside the gesture. Put the unlock there, before any await.
Keep a single shared context and expose two things: a blocked check and an unlock() call. This file is web-only.
// web_audio_unlock_web.dart
import 'dart:js_interop';
@JS('AudioContext')
extension type _AudioContext._(JSObject _) implements JSObject {
external factory _AudioContext();
external String get state; // 'suspended' | 'running' | 'closed' | 'interrupted' (iOS)
external JSPromise<JSAny?> resume();
}
_AudioContext? _shared;
_AudioContext _context() => _shared ??= _AudioContext();
/// True when Safari/Chrome will not play audio yet.
bool get webAudioBlocked => _context().state != 'running';
/// Call this FIRST in a tap handler, before any await.
Future<bool> unlockWebAudio() async {
final ctx = _context();
if (ctx.state == 'running') return true;
try {
await ctx.resume().toDart;
} catch (_) {
// resume() rejects if the context was closed; fall through and report state.
}
return ctx.state == 'running';
}
Give it a no-op twin for mobile and desktop, and switch on the platform with a conditional export:
// web_audio_unlock_stub.dart
bool get webAudioBlocked => false;
Future<bool> unlockWebAudio() async => true;
// web_audio_unlock.dart
export 'web_audio_unlock_stub.dart'
if (dart.library.js_interop) 'web_audio_unlock_web.dart';
Safari has shipped the unprefixed AudioContext since 14.1, so the old webkitAudioContext fallback is not needed for anything you still support.
Wire it into the mic tap
The important line is the first one. unlockWebAudio() kicks off resume() synchronously inside the gesture, which is all the browser needs. Only after that do you start the WidgetChat voice call, which is the mic entry point on the same embedded widget you use for text chat.
import 'package:flutter/foundation.dart' show kIsWeb;
import 'web_audio_unlock.dart';
class MicButton extends StatefulWidget {
const MicButton({super.key, required this.startVoiceCall});
final Future<void> Function() startVoiceCall;
@override
State<MicButton> createState() => _MicButtonState();
}
class _MicButtonState extends State<MicButton> {
bool _needsSoundTap = false;
Future<void> _onMicTap() async {
// 1. Still inside the pointerup gesture: unlock first.
final unlockFuture = unlockWebAudio();
// 2. Now it is safe to await things.
final ok = await unlockFuture;
if (kIsWeb && !ok) {
setState(() => _needsSoundTap = true);
return; // do not start a call the user cannot hear
}
setState(() => _needsSoundTap = false);
await widget.startVoiceCall();
}
@override
Widget build(BuildContext context) {
if (_needsSoundTap) {
return FilledButton.icon(
onPressed: _onMicTap,
icon: const Icon(Icons.volume_up),
label: const Text('Tap to enable sound'),
);
}
return IconButton(
onPressed: _onMicTap,
icon: const Icon(Icons.mic),
tooltip: 'Talk to support',
);
}
}
If resume() does not land in running (a locked iPhone ringer, or a context stuck in interrupted), the button turns into a "Tap to enable sound" prompt instead of a mic that appears to listen and never answers. The second tap is a fresh gesture, so it gets a fresh chance. Showing the prompt beats starting a call that burns voice minutes while the user hears nothing.
Also resume on visibilitychange if your voice screen can survive an app switch on iPhone. The context goes interrupted while the tab is hidden, and a second call to unlockWebAudio() from the next tap covers the common case.
FlutterFlow web builds: a custom action
FlutterFlow custom actions are single Dart files that return a Future, so collapse the helper into one action and guard it with kIsWeb. dart:js_interop can be imported in cross-platform code; its members only fail if you call them off the web, which the guard prevents. FlutterFlow's analyzer may show the avoid_web_libraries_in_flutter lint. It is a lint, not a compile error.
// Custom Action: unlockWebAudio (returns bool)
import 'dart:js_interop';
import 'package:flutter/foundation.dart' show kIsWeb;
@JS('AudioContext')
extension type _AudioContext._(JSObject _) implements JSObject {
external factory _AudioContext();
external String get state;
external JSPromise<JSAny?> resume();
}
_AudioContext? _shared;
Future<bool> unlockWebAudio() async {
if (!kIsWeb) return true;
final ctx = _shared ??= _AudioContext();
if (ctx.state == 'running') return true;
try {
await ctx.resume().toDart;
} catch (_) {}
return ctx.state == 'running';
}
In the Action Flow Editor on your mic button:
- First action:
unlockWebAudio, output stored in a page state variable such assoundReady. - Conditional action on
soundReady. - True branch: the action that opens the WidgetChat voice call. False branch: set a
showEnableSoundpage state to true so the button relabels itself.
Order matters. Put the custom action first. If you place a "Request Permission" or a "Wait" action ahead of it, the gesture is consumed before resume() runs and Safari refuses again.
Testing checklist
- Desktop Safari: open Web Inspector, run
new AudioContext().statein the console before tapping. It should saysuspended. Tap the mic, then checkwebAudioBlocked. - iPhone Safari: test with the ringer on silent and on loud. Lock the screen mid-call, unlock, tap again, confirm sound returns.
- Chrome: works without the fix after any click on the page, which is exactly why it hides the bug. Test in a fresh incognito tab with no prior clicks.
- Captions only: if captions stream and audio does not, that is this bug. If neither arrives, you are looking at mic permission, not playback (see the NotAllowedError guide).
Voice is enabled per project in the WidgetChat dashboard under Voice, where you also pick the voice name, max session length and the captions default. Barge-in, live captions and product cards during speech all work on web once the context is running. Nothing in this post changes server side, and your provider keys stay there.
Ready to let users talk to your support bot in Safari too? Try WidgetChat free and add the unlock to your mic tap today.





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