audio_flutter
Provider-neutral Flutter microphone capture, system/process capture, device discovery, microphone and system-audio permissions, health events, PCM playback, and native WAV recording.
import 'package:audio_core/audio_core.dart';
import 'package:audio_flutter/audio_flutter.dart';
final source = FlutterAudioCaptureSource(
FlutterAudioCaptureConfig(
type: AudioCaptureType.microphone,
format: AudioFormat(sampleRate: 16000, channels: 1),
),
);
final session = await source.prepare();
final frames = session.frames.listen(routeFrame);
await session.start();
Subscribe and attach routes before start() so early frames and health events
cannot be lost. Use audio_kit_graph when one capture must feed multiple
independent consumers.
The first implementation supports Apple platforms. System/process capture is macOS-only.
Microphone permission
prepare() fails a microphone capture with the typed
microphone_permission_denied failure when access is denied or restricted,
instead of starting an engine that would deliver only zeroes. Query or request
the permission yourself when the app wants to explain the prompt first:
final permission = FlutterMicrophonePermission();
var status = await permission.status();
if (status == AudioMicrophonePermissionStatus.notDetermined) {
status = await permission.request();
}
if (status.blocksCapture) {
// Only the user can lift this, in system settings.
}
A platform with no permission gate reports
AudioMicrophonePermissionStatus.unavailable and is never blocked. Apple
platforms still require the host app's Info.plist microphone usage
description; without it the process is terminated before any of this runs.
Selecting the processes to tap
Tapping a meeting app by its main process ID captures nothing: Electron and
Chromium apps render audio in helper processes, and browsers render web-call
audio in shared engine processes under a different bundle namespace.
SystemAudioProcessSelector expands a target set over a process snapshot.
final systemAudio = FlutterSystemAudio();
final processes = await systemAudio.listProcesses();
final processIds = const SystemAudioProcessSelector().expandProcessIds(
processes: processes,
bundleIds: <String>['com.microsoft.teams2', 'com.apple.Safari'],
);
final config = FlutterAudioCaptureConfig(
type: AudioCaptureType.systemAudio,
format: format,
processIds: processIds,
);
The prefix tables (browserExternalMediaBundlePrefixes, teamsBundlePrefixes,
electronHelperBundleSuffixes, ...) are public const and can be replaced
through the constructor, so an app can teach the selector about a browser fork
or a renamed build without waiting for a release.
An empty result means the target is not producing audio right now. Do not fall back to the bare target PID — that is the case that silently captures nothing.
Reclaiming leaked capture devices
A process killed mid-capture cannot unwind the private aggregate device its system tap runs on. Sweep those once at app start, before the first capture:
await FlutterSystemAudio().cleanupOrphanedCaptureDevices();
Only devices this plugin created are touched, and never one a live session in this process still owns. Platforms with nothing to reclaim return 0.
Deriving the delay between two captures
Two captures started back to back do not begin at the same instant: the microphone engine and the system tap each take their own time to produce their first buffer. Mic-bleed dedup and any cross-source alignment need that skew, and nothing native reports it — it is derived from the frames themselves.
Both sources timestamp frames from the same monotonic host clock, and expose
which clock that is as AudioFrame.clockId. So the delta between the first
frame of each capture is the start skew:
Future<Duration> firstFrameTimestamp(AudioSourceSession session) async =>
(await session.frames.first).timestamp;
final micStart = await firstFrameTimestamp(micSession);
final systemStart = await firstFrameTimestamp(systemSession);
// Positive: the microphone started later than the system capture.
final micDelay = micStart - systemStart;
final micDelaySeconds = micDelay.inMicroseconds / Duration.microsecondsPerSecond;
Two rules make this sound:
- Compare only frames whose
clockIdmatches. Sources on different clocks have unrelated timelines, and subtracting them produces a plausible-looking number that means nothing. Both Darwin capture kinds reportdarwin.host-time. - Take the delta once, from the first frame of each source, and persist it
with the recording. It is a property of that recording, not a live signal.
A capture chain rebuilt mid-recording (an output-device switch, a helper
process appearing) reports itself as
AudioDiscontinuityReason.sourceRestarton the next frame'sAudioFrame.discontinuity; the timeline continues across it, so the original delta stays valid.
FlutterAudioCaptureHealth.firstAudioAtMillis answers a different question —
when a source first produced non-zero audio, relative to its own session — and
is a diagnostic, not an alignment signal: a silent room delays it without
delaying the frame timeline.
Libraries
- audio_flutter
- Provider-neutral Flutter capture and PCM playback.