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

← Back to Blog
Flutter Voice AI Status UI: Listening, Speaking, Barge-In

Flutter Voice AI Status UI: Listening, Speaking, Barge-In

fluttervoice aiflutterflowbarge-inwidgetchat

Flutter Voice AI Status UI: Listening, Speaking, Barge-In

You added a live voice call to your Flutter app — the user taps the mic, the assistant talks back, and it even handles interruptions. Great. But on screen it's a mystery: is it my turn, or is the assistant still talking? Did it hear me when I cut in? Without a clear Flutter voice assistant listening/speaking indicator, users double-talk, repeat themselves, and tap the mic twice.

The fix isn't a heavyweight voice-activity-detection (VAD) model bolted onto your app. It's a small, boring, correct state machine plus a pulsing mic animation. This post shows how to build both — and, critically, how to flip the indicator the instant a user barges in over the assistant.

First, get the ownership model right

Here's the mistake that makes this hard: assuming your voice provider hands you a stream of onAssistantSpeechStarted / onUserSpeechStarted callbacks to bind your UI to.

Reality check. WidgetChat runs the voice call inside the embedded widget you already dropped into your Flutter/FlutterFlow app. It handles speech-to-speech, barge-in, and live captions itself. It does not expose a client-side turn-event API in Dart, so don't architect your indicator around an imagined callback stream. Instead, drive your custom on-screen indicator from a signal you fully own on-device. The widget stays the source of truth for the actual conversation; your indicator is a supplemental HUD you control.

That reframing is liberating. Your indicator becomes a self-contained, transport-agnostic component. You feed it signals; it renders state. The one signal you can always generate locally, without any provider hooks and without a 50 MB VAD model, is microphone input level.

The state machine

Model the call as four phases. Keep it in a ChangeNotifier so any widget can listen.

enum CallPhase { idle, connecting, listening, speaking }

class VoiceCallStatus extends ChangeNotifier {
  CallPhase _phase = CallPhase.idle;
  CallPhase get phase => _phase;

  void _set(CallPhase next) {
    if (_phase == next) return;
    _phase = next;
    notifyListeners();
  }

  void onConnecting() => _set(CallPhase.connecting);
  void onCallEnded() => _set(CallPhase.idle);

  /// The assistant has taken the floor and is talking.
  void onAssistantSpeaking() {
    // Never stomp a live user turn — the human always wins the mic.
    if (_phase == CallPhase.listening) return;
    _set(CallPhase.speaking);
  }

  /// User voice energy crossed / dropped below threshold.
  void onUserSpeech({required bool active}) {
    if (active) {
      _set(CallPhase.listening); // start-of-turn OR barge-in
    } else if (_phase == CallPhase.listening) {
      _set(CallPhase.speaking);  // user paused, floor returns to assistant
    }
  }
}

The barge-in rule lives in exactly one place: onUserSpeech(active: true) sets listening regardless of the current phase. It doesn't matter if the assistant was mid-sentence — the moment user energy appears, the UI says Listening…. And onAssistantSpeaking() refuses to override a live user turn. That single asymmetry is what makes the voice AI barge-in UI feel honest instead of laggy.

Generating the user-speech signal without a VAD model

You don't need to classify phonemes. You need to know "is there voice energy right now?" A simple amplitude threshold with hysteresis does the job. The record package (v7.x) exposes exactly this via onAmplitudeChanged, which streams an Amplitude with a current value in dBFS (roughly -160 at silence up to 0 at max).

import 'package:record/record.dart';

final _recorder = AudioRecorder();
StreamSubscription<Amplitude>? _ampSub;

Future<void> startLevelMonitor(VoiceCallStatus status) async {
  if (!await _recorder.hasPermission()) return;

  // Two thresholds create a hysteresis gap so the label doesn't flicker
  // on every tiny pause between words.
  const onThreshold = -30.0;  // dBFS: user is clearly speaking
  const offThreshold = -42.0; // dBFS: back to quiet
  bool speaking = false;

  _ampSub = _recorder
      .onAmplitudeChanged(const Duration(milliseconds: 150))
      .listen((amp) {
    if (!speaking && amp.current > onThreshold) {
      speaking = true;
      status.onUserSpeech(active: true);   // barge-in fires here
    } else if (speaking && amp.current < offThreshold) {
      speaking = false;
      status.onUserSpeech(active: false);
    }
  });
}

Future<void> dispose() async {
  await _ampSub?.cancel();
  await _recorder.dispose();
}

Two honest caveats. First, on most platforms record needs an active capture session for amplitude, so this DIY monitor is best when you're not already contending for the mic — if your embedded voice call owns the microphone, don't open a second competing recorder; instead call status.onUserSpeech(...) from wherever your own call logic already knows about turns. Second, tune the thresholds per device and environment; -30/-42 dBFS are sane starting points, not gospel. The 12 dB gap is the important part — it kills the flicker.

The pulsing mic / waveform

Now the fun part: a Flutter animated mic waveform that visibly breathes while active. Use an AnimationController with repeat(reverse: true) and an AnimatedBuilder so only the indicator rebuilds — not your whole call screen.

class MicPulse extends StatefulWidget {
  final bool active;
  final Color color;
  const MicPulse({super.key, required this.active, required this.color});
  @override
  State<MicPulse> createState() => _MicPulseState();
}

class _MicPulseState extends State<MicPulse>
    with SingleTickerProviderStateMixin {
  late final AnimationController _c = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 700),
  );

  @override
  void initState() {
    super.initState();
    if (widget.active) _c.repeat(reverse: true);
  }

  @override
  void didUpdateWidget(MicPulse old) {
    super.didUpdateWidget(old);
    if (widget.active && !_c.isAnimating) {
      _c.repeat(reverse: true);
    } else if (!widget.active) {
      _c.stop();
      _c.value = 0;
    }
  }

  @override
  void dispose() { _c.dispose(); super.dispose(); }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _c,
      builder: (_, __) {
        final scale = 1.0 + (_c.value * 0.35);
        return Container(
          width: 64 * scale,
          height: 64 * scale,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            color: widget.color.withOpacity(0.15 + _c.value * 0.2),
          ),
          child: Icon(Icons.mic, color: widget.color, size: 28),
        );
      },
    );
  }
}

Wiring it into one status bar

A single AnimatedBuilder on the VoiceCallStatus maps each phase to a label, color, and whether the pulse animates:

AnimatedBuilder(
  animation: status,
  builder: (context, _) {
    final (label, color, pulsing) = switch (status.phase) {
      CallPhase.idle       => ('Tap to talk', Colors.grey, false),
      CallPhase.connecting => ('Connecting…', Colors.amber, false),
      CallPhase.listening  => ('Listening…',  Colors.green, true),
      CallPhase.speaking   => ('Speaking…',   Colors.blue,  true),
    };
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        MicPulse(active: pulsing, color: color),
        const SizedBox(width: 12),
        Text(label, style: TextStyle(color: color, fontWeight: FontWeight.w600)),
      ],
    );
  },
)

Green pulse for the user, blue pulse for the assistant, and the barge-in override in the reducer flips green over blue the instant the user's voice energy crosses the threshold. That's the whole flutter voice AI status ui — no ML, no guesswork, one source of truth.

FlutterFlow note

Same idea works for a flutterflow voice chatbot state indicator: hold CallPhase as a Custom Data Type in App State, write a Custom Action that updates it (from your mic monitor or your call logic), and bind a Custom Widget's label + animation to it. The state machine stays identical; only the plumbing changes.

Ship the voice call itself first

An indicator only matters if you have a voice call to indicate. WidgetChat gives you exactly that inside the same widget you already embed for text chat: real-time speech-to-speech, built-in barge-in, live captions, and on-screen product cards — across iOS, Android, and web, with provider API keys kept server-side. Turn it on in the dashboard's Voice section, then layer this indicator on top.

Try WidgetChat free and give your users a voice assistant they can actually follow along with.

The record package's onAmplitudeChanged stream is the simplest local source for a user-speech signal.

Flutter's AnimationController + AnimatedBuilder power the pulsing mic indicator.

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!