audio_waveform_kit
A Flutter package for audio recording with real-time waveform and spectrum visualization.
Features
- Record audio from the microphone with a single tap
- Real-time waveform — scrolling bar history during recording
- Real-time spectrum — FFT-based frequency display (pure Dart, no native libraries)
- Oscilloscope view — raw PCM snapshot for signal inspection
- Static visualizations — replay waveform, spectrum, and timeline after recording
- Playback widget — progress bar + seek support out of the box
- BLoC-based — predictable state machine, easy to integrate with existing BLoC apps
Getting started
Add to pubspec.yaml:
dependencies:
audio_waveform_kit: ^1.0.0
Permissions
Android — AndroidManifest.xml:
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
iOS — Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>Recording voice messages</string>
macOS — macos/Runner/DebugProfile.entitlements and Release.entitlements:
<key>com.apple.security.device.audio-input</key>
<true/>
Usage
1. Wrap with AudioWaveformScope
AudioWaveformScope(
// optional — all params have sensible defaults
spectrumConfig: SpectrumConfig(fftSize: 1024, frequencyBands: 64),
maxWaveformSamples: 56,
maxSnapshotSamples: 2048,
child: MyScreen(),
)
AudioWaveformScope provides AudioRecordingService, SpectrumAnalyzer, and AudioRecordingBloc to the subtree.
To plug in your own capture implementation — streaming PCM to disk instead of
buffering it in memory, recording from a foreground service, a custom file
location — pass recordingService:
AudioWaveformScope(
recordingService: MyDiskStreamingRecordingService(),
child: MyScreen(),
)
The scope then uses that instance as-is and does not dispose it — its
lifecycle is yours. Omit the parameter and the scope creates and owns an
AudioRecordingServiceImpl as before.
2. Add a record button
Use the built-in round button:
AudioRecordButton.defaultStyle(
size: 72,
recordingColor: Colors.red, // optional
idleColor: Colors.blue, // optional
onRecordingFinished: (RecordingResult result) {
// available on all platforms:
print(result.duration); // Duration
print(result.spectrumData); // List<double> — FFT bins (dB)
print(result.spectrumTimeline); // List<List<double>> — frames × bands
print(result.waveformSamples); // List<double>
print(result.rmsSamples); // List<double>
if (kIsWeb) {
// result.wavBytes — Uint8List with full WAV file contents
// result.filePath — generated key, not a real path on web
} else {
// result.filePath — absolute path to the saved .wav file
}
},
)
Or pass any widget via builder — receives isRecording and onTap:
AudioRecordButton(
onRecordingFinished: (result) { … },
builder: ({required context, required isRecording, required onTap}) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 72,
height: 72,
color: isRecording ? Colors.red : Colors.blue,
child: Icon(isRecording ? Icons.stop : Icons.mic),
),
);
},
)
3. Show live visualizations during recording
// Scrolling amplitude bars
WaveformDisplay()
// Current level meter
RecordingLevelDisplay()
// FFT spectrum
LiveSpectrumDisplay()
// Oscilloscope (raw PCM)
StringSnapshotDisplay()
// Elapsed time
RecordingTimer()
4. Show static visualizations after recording
These widgets read directly from AudioRecordingBloc — no props needed:
// Averaged spectrum
SpectrumDisplay()
// Spectrum over time (heatmap-style)
TimelineSpectrumDisplay()
// Messenger-style waveform (RMS per window)
StaticMessengerWaveformDisplay(samples: state.rmsSamples)
// Oscilloscope snapshot
StaticStringSnapshotDisplay()
Or access the data directly from the callback result:
onRecordingFinished: (result) {
// result.spectrumData, result.spectrumTimeline, result.waveformSamples …
}
5. Play back the recorded file
// from callback:
AudioWaveformPlayer(filePath: result.filePath)
// or from bloc state:
final state = context.read<AudioRecordingBloc>().state as AudioRecordingState$Finished;
AudioWaveformPlayer(filePath: state.filePath)
AudioWaveformPlayer is self-contained — it manages its own AudioPlayerBloc and does not require AudioWaveformScope.
If you need the waveform itself to double as the seek bar (e.g. a segment
list next to it must drive the same player), use MessengerWaveformPlayer
instead — unlike AudioWaveformPlayer, it does not create its own bloc:
BlocProvider(
create: (_) => AudioPlayerBloc(filePath: result.filePath),
child: MessengerWaveformPlayer(
samples: result.rmsSamples,
duration: result.duration,
),
)
Position is shown as a darker shade over the already-played bars, not a
cursor line; tap/drag seeks. Seek never starts playback by itself — even
before the first Play, dragging only moves the position.
Recording state machine
Idle → [Start] → Recording → [Stop] → Finished
→ [error] → Error
← [Reset] ←─────────────────────────────
| State | Notable fields |
|---|---|
AudioRecordingState$Idle |
— |
AudioRecordingState$Recording |
duration, waveformSamples, rmsSamples, snapshotSamples, liveSpectrumData |
AudioRecordingState$Finished |
filePath, wavBytes (web only), duration, waveformSamples, rmsSamples, snapshotSamples, spectrumData, spectrumTimeline |
AudioRecordingState$Error |
message |
Level scale
StringSnapshotDisplay, WaveformDisplay (envelope style), RecordingLevelDisplay
and StaticLevelDisplay all size themselves the same way: RMS level of the
signal mapped logarithmically onto floorDb…ceilingDb. A linear gain cannot
show both room noise and speech — they sit 30–40 dB apart, so one multiplier
either buries the noise at zero or clips speech at the edge of the box.
| Parameter | Default | Description |
|---|---|---|
floorDb |
-55.0 |
RMS level at which the display is empty/flat — anything quieter is silence |
ceilingDb |
-15.0 |
RMS level at which the display reaches full size |
MessengerWaveformDisplay (live) uses the same idea via logarithmic: true and
minDbThreshold (equivalent to floorDb, ceilingDb fixed at 0).
Oscilloscope tuning
StringSnapshotDisplay and StaticStringSnapshotDisplay draw raw PCM, so the
defaults matter. The swing of the string comes from the signal level on a dB
scale, the wave shape is normalized separately — a linear gain cannot show both
room noise and speech, they are 30–40 dB apart.
| Parameter | Default | Description |
|---|---|---|
floorDb |
-55.0 |
RMS level at which the string lies flat — anything quieter is silence |
ceilingDb |
-15.0 |
RMS level at which the string swings across the whole box |
pointSpacing |
3.0 |
Logical pixels per drawn point; samples in between are averaged (low-pass). 0 draws every sample |
alignToZeroCrossing |
true |
Starts the curve at a rising zero crossing so the wave does not slide horizontally |
minAmplitudeFraction |
0.02 |
Peak below this draws a flat resting string, before the level is even measured |
autoGain |
false |
Legacy per-frame peak normalization — quiet noise fills the whole box and the string jumps between frames |
maxGain |
12.0 |
Upper bound for autoGain amplification |
Too still while talking — raise floorDb (-45) or lower ceilingDb (-20).
Too lively while silent — lower floorDb (-65).
Pre-1.3 look: autoGain: true, pointSpacing: 0, alignToZeroCrossing: false.
SpectrumConfig
| Parameter | Default | Description |
|---|---|---|
fftSize |
1024 |
FFT window size (power of 2) |
frequencyBands |
64 |
Number of output frequency bins |
frequencyMin |
20 |
Lower frequency limit (Hz) |
frequencyMax |
20000 |
Upper frequency limit (Hz) |
sampleRate |
44100 |
Input sample rate (Hz) |
dynamicRangeDb |
60.0 |
dB range shown; lower = fewer but louder bars |
displayType |
linear |
linear or logarithmic frequency scale |
Running the example
cd example && flutter run -d linux
# or
cd example && flutter run -d chrome
Dependencies
| Package | Purpose |
|---|---|
record |
Microphone capture (PCM stream) |
audioplayers |
Playback |
flutter_bloc + bloc_concurrency |
State management |
equatable |
Value equality |
path_provider |
Temporary file storage |