flutter_recorder 2.0.3
flutter_recorder: ^2.0.3 copied to clipboard
A low-level audio recorder plugin which uses miniaudio as backend. Detect silence and save to WAV audio file. Audio wave, FFT and volume level can be get in real-time.
import 'dart:async';
import 'dart:developer' as dev;
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_recorder/flutter_recorder.dart';
import 'package:flutter_recorder_example/ui/bars.dart';
import 'package:logging/logging.dart';
import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
/// Demostrate how to use flutter_recorder.
///
/// The silence detection and the visualizer works when using [PCMFormat.f32].
/// Writing audio stream to file is not implemented on Web.
void main() async {
// The `flutter_recorder` package logs everything
// (from severe warnings to fine debug messages)
// using the standard `package:logging`.
// You can listen to the logs as shown below.
Logger.root.level = kDebugMode ? Level.FINE : Level.INFO;
Logger.root.onRecord.listen((record) {
dev.log(
record.message,
time: record.time,
level: record.level.value,
name: record.loggerName,
zone: record.zone,
error: record.error,
stackTrace: record.stackTrace,
);
});
runApp(
MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Flutter Recorder')),
body: MyApp(),
),
),
);
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
Directory? savingDir;
final format = PCMFormat.f32le;
final sampleRate = 22050;
final channels = RecorderChannels.stereo;
final recorder = Recorder.instance;
String? filePath;
var thresholdDb = -20.0;
var silenceDuration = 2.0;
var secondsOfAudioToWriteBefore = 0.0;
var androidInputPresetValue = 0;
var iosInputPresetValue = 0;
var webInputPresetValue = 0;
var micStatus = 'stopped';
List<CaptureDevice> devices = [];
int selectedDeviceId = -1;
File? file;
AndroidInputPreset? get selectedAndroidInputPreset =>
switch (androidInputPresetValue) {
1 => AndroidInputPreset.generic,
2 => AndroidInputPreset.camcorder,
3 => AndroidInputPreset.voiceRecognition,
4 => AndroidInputPreset.voiceCommunication,
5 => AndroidInputPreset.unprocessed,
_ => null,
};
String androidInputPresetLabel(int value) => switch (value) {
1 => 'Generic',
2 => 'Camcorder',
3 => 'Voice recognition',
4 => 'Voice communication',
5 => 'Unprocessed',
_ => 'System default',
};
IosInputPreset? get selectedIosInputPreset => switch (iosInputPresetValue) {
1 => IosInputPreset.generic,
2 => IosInputPreset.voiceCommunication,
3 => IosInputPreset.videoChat,
4 => IosInputPreset.speechRecognition,
5 => IosInputPreset.unprocessed,
_ => null,
};
String iosInputPresetLabel(int value) => switch (value) {
1 => 'Generic',
2 => 'Voice communication (AEC/AGC)',
3 => 'Video chat (AEC)',
4 => 'Speech recognition',
5 => 'Unprocessed (Measurement)',
_ => 'System default',
};
WebInputPreset get selectedWebInputPreset => switch (webInputPresetValue) {
1 => WebInputPreset.voiceCommunication,
2 => WebInputPreset.voiceRecognition,
3 => WebInputPreset.noiseSuppression,
4 => WebInputPreset.echoCancellation,
_ => WebInputPreset.unprocessed,
};
String webInputPresetLabel(int value) => switch (value) {
1 => 'Voice communication (AEC/AGC/NS)',
2 => 'Voice recognition (AGC/NS)',
3 => 'Noise suppression only',
4 => 'Echo cancellation only',
_ => 'Unprocessed (Raw audio)',
};
late final AppLifecycleListener _lifecycleListener;
StreamSubscription<AudioDataContainer>? _audioStreamSubscription;
StreamSubscription<RecorderDeviceNotification>? _deviceNotifSubscription;
@override
void initState() {
super.initState();
_lifecycleListener = AppLifecycleListener(
onDetach: () {
recorder.deinit();
},
);
if (defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS) {
Permission.microphone.request().isGranted.then((value) async {
if (!value) {
await [Permission.microphone].request();
}
});
}
/// Listen to device notification and microphone lifecycle events.
_deviceNotifSubscription = recorder.deviceNotificationEvents.listen((
event,
) {
setState(() {
micStatus = event.name;
});
if (event == RecorderDeviceNotification.rerouted) {
_refreshDevices();
}
});
_refreshDevices();
/// Listen to audio data stream. The data is received as Uint8List.
_audioStreamSubscription = recorder.uint8ListStream.listen((data) {
/// Write the PCM data to file. It can then be imported with the correct
/// parameters with for example Audacity.
/// Not testing on Web platform.
if (!kIsWeb) {
file?.writeAsBytesSync(
// If you want a conversion, call one of the `to*List` methods.
// data.toF32List(from: format).buffer.asUint8List(),
data.rawData,
mode: FileMode.writeOnlyAppend,
);
}
});
}
@override
void dispose() {
_lifecycleListener.dispose();
_deviceNotifSubscription?.cancel();
_audioStreamSubscription?.cancel();
recorder.deinit();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topCenter,
child: SingleChildScrollView(
padding: const EdgeInsets.all(10),
child: Column(
children: [
/// List capture devices, init, start, deinit
Wrap(
runSpacing: 6,
spacing: 6,
children: [
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Android preset: '),
DropdownButton<int>(
value: androidInputPresetValue,
items: List<DropdownMenuItem<int>>.generate(
AndroidInputPreset.values.length + 1,
(index) => DropdownMenuItem<int>(
value: index,
child: Text(androidInputPresetLabel(index)),
),
),
onChanged: (value) {
if (value == null) return;
setState(() => androidInputPresetValue = value);
},
),
],
),
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('iOS preset: '),
DropdownButton<int>(
value: iosInputPresetValue,
items: List<DropdownMenuItem<int>>.generate(
IosInputPreset.values.length + 1,
(index) => DropdownMenuItem<int>(
value: index,
child: Text(iosInputPresetLabel(index)),
),
),
onChanged: (value) {
if (value == null) return;
setState(() => iosInputPresetValue = value);
},
),
],
),
if (kIsWeb)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Web preset: '),
DropdownButton<int>(
value: webInputPresetValue,
items: List<DropdownMenuItem<int>>.generate(
WebInputPreset.values.length,
(index) => DropdownMenuItem<int>(
value: index,
child: Text(webInputPresetLabel(index)),
),
),
onChanged: (value) {
if (value == null) return;
setState(() => webInputPresetValue = value);
},
),
],
),
OutlinedButton(
onPressed: () {
showDeviceListDialog();
},
child: const Text('listCaptureDevices'),
),
OutlinedButton(
onPressed: () async {
try {
await recorder.init(
deviceID: selectedDeviceId,
format: format,
sampleRate: sampleRate,
channels: channels,
androidInputPreset: selectedAndroidInputPreset,
iosInputPreset: selectedIosInputPreset,
webInputPreset: selectedWebInputPreset,
);
} on Exception catch (e) {
debugPrint('-------------- init() error: $e\n');
}
},
child: const Text('init'),
),
OutlinedButton(
onPressed: () {
try {
recorder.start();
} on Exception catch (e) {
debugPrint('-------------- start() error: $e\n');
}
},
child: const Text('start'),
),
OutlinedButton(
onPressed: () {
recorder.deinit();
setState(() {
micStatus = 'stopped';
});
},
child: const Text('deinit'),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
child: Text(
'Mic status: $micStatus',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 10),
/// Recording
Wrap(
runSpacing: 6,
spacing: 6,
children: [
ElevatedButton(
onPressed: () async {
try {
/// Asking for file path to store the audio file.
/// On web platform, it will be asked internally
/// from the browser.
if (!kIsWeb) {
final Directory saveDir;
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android) {
// On mobile, use app documents directory
saveDir = await getApplicationDocumentsDirectory();
} else {
// On desktop, use downloads directory
final downloadsDir = await getDownloadsDirectory();
if (downloadsDir == null) {
debugPrint(
'-------------- startRecording() '
'Could not get downloads directory\n',
);
return;
}
saveDir = downloadsDir;
}
// Ensure the directory exists
if (!saveDir.existsSync()) {
saveDir.createSync(recursive: true);
}
filePath = '${saveDir.path}/flutter_recorder.ogg';
recorder.startRecording(
completeFilePath: filePath!,
format: RecordingFormat.opusOgg,
);
} else {
recorder.startRecording();
}
} on Exception catch (e) {
debugPrint('-------------- startRecording() $e\n');
}
},
child: const Text('Start recording'),
),
ElevatedButton(
onPressed: () {
recorder.setPauseRecording(pause: true);
},
child: const Text('Pause recording'),
),
ElevatedButton(
onPressed: () {
recorder.setPauseRecording(pause: false);
},
child: const Text('UN-Pause recording'),
),
ElevatedButton(
onPressed: () {
recorder.stopRecording();
if (!kIsWeb) {
debugPrint('Audio recorded to "$filePath"');
showFileRecordedDialog(filePath!);
}
},
child: const Text('Stop recording'),
),
],
),
const SizedBox(height: 10),
/// Streaming
Wrap(
runSpacing: 6,
spacing: 6,
children: [
CircularProgressIndicator(),
OutlinedButton(
onPressed: () async {
recorder.startStreamingData(format: StreamingFormat.opus);
if (!kIsWeb) {
final Directory baseDir;
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.android) {
// On mobile, use app documents directory
baseDir = await getApplicationDocumentsDirectory();
} else {
// On desktop, use downloads directory
final downloadsDir = await getDownloadsDirectory();
if (downloadsDir == null) {
debugPrint('Cannot get download directory!');
return;
}
baseDir = downloadsDir;
}
savingDir = Directory('${baseDir.path}/flutter_recorder');
savingDir!.createSync();
file = File(
'${savingDir?.path}/fr_${sampleRate}_${format.name}_'
'${channels.count}.pcm',
);
try {
if (file?.existsSync() ?? false) {
file?.deleteSync();
}
} catch (e) {
debugPrint('Error deleting file: $e');
}
}
},
child: const Text('start stream'),
),
OutlinedButton(
onPressed: () {
recorder.stopStreamingData();
},
child: const Text('stop stream'),
),
],
),
const SizedBox(height: 10),
/// The silence detection is available only with f32 format and
/// the visualization is adapted only with that format.
if (format == PCMFormat.f32le)
Column(
children: [
Column(
children: [
StreamBuilder(
stream: recorder.silenceChangedEvents,
builder: (context, snapshot) {
return ColoredBox(
color: snapshot.hasData && snapshot.data!.isSilent
? Colors.green
: Colors.red,
child: SizedBox(
width: 70,
height: 50,
child: Center(
child: Text(
recorder.getVolumeDb().toStringAsFixed(1),
),
),
),
);
},
),
const SizedBox(height: 10),
Wrap(
runSpacing: 6,
spacing: 6,
children: [
OutlinedButton(
onPressed: () {
recorder.setSilenceDetection(
enable: true,
onSilenceChanged: (isSilent, decibel) {
/// Here you can check if silence is changed.
/// Or you can do the same thing with the Stream
/// [Recorder.instance.silenceChangedEvents]
// debugPrint('SILENCE CHANGED: $isSilent, $decibel');
},
);
recorder.setSilenceThresholdDb(-27);
recorder.setSilenceDuration(0.5);
recorder.setSecondsOfAudioToWriteBefore(0.0);
setState(() {
thresholdDb = -27;
silenceDuration = 0.5;
secondsOfAudioToWriteBefore = 0;
});
},
child: const Text(
'setSilenceDetection ON -27, 0.5, 0.0',
),
),
OutlinedButton(
onPressed: () {
recorder.setSilenceDetection(enable: false);
},
child: const Text('setSilenceDetection OFF'),
),
],
),
// Threshold dB slider
Row(
mainAxisSize: MainAxisSize.max,
children: [
Text(
'Threshold: ${thresholdDb.toStringAsFixed(1)}dB',
),
Expanded(
child: Slider(
value: thresholdDb,
min: -100,
max: 0,
label: thresholdDb.toStringAsFixed(1),
onChanged: (value) {
recorder.setSilenceThresholdDb(value);
setState(() {
thresholdDb = value;
});
},
),
),
],
),
// Silence duration slider
Row(
mainAxisSize: MainAxisSize.max,
children: [
Text(
'Silence duration: '
'${silenceDuration.toStringAsFixed(1)}',
),
Expanded(
child: Slider(
value: silenceDuration,
min: 0,
max: 10,
label: silenceDuration.toStringAsFixed(1),
onChanged: (value) {
recorder.setSilenceDuration(value);
setState(() {
silenceDuration = value;
});
},
),
),
],
),
// Silence duration slider
Row(
mainAxisSize: MainAxisSize.max,
children: [
Text(
'Write before: '
'${secondsOfAudioToWriteBefore.toStringAsFixed(1)}',
),
Expanded(
child: Slider(
value: secondsOfAudioToWriteBefore,
min: 0,
max: 5,
label: silenceDuration.toStringAsFixed(1),
onChanged: (value) {
recorder.setSecondsOfAudioToWriteBefore(value);
setState(() {
secondsOfAudioToWriteBefore = value;
});
},
),
),
],
),
],
),
const Bars(),
],
),
],
),
),
);
}
Future<void> showFileRecordedDialog(String filePath) async {
final fileExists = await File(filePath).exists();
if (!mounted) return;
return showDialog<void>(
context: context,
barrierDismissible: true,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Recording saved!'),
content: Text('Audio saved to:\n$filePath\nFile exists: $fileExists'),
actions: <Widget>[
TextButton(
child: const Text('open'),
onPressed: () async {
OpenFilex.open(filePath, type: 'audio/wav');
},
),
TextButton(
child: const Text('close'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
void _refreshDevices() {
try {
final list = recorder.listCaptureDevices();
setState(() {
devices = list;
if (selectedDeviceId != -1 &&
!devices.any((d) => d.id == selectedDeviceId)) {
selectedDeviceId = -1;
}
});
} on Exception catch (e) {
debugPrint('Failed to list capture devices: $e');
}
}
Future<void> showDeviceListDialog() async {
_refreshDevices();
final list = recorder.listCaptureDevices();
return showDialog<void>(
context: context,
barrierDismissible: true,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, setDialogState) {
final activeDeviceId = list.any((d) => d.id == selectedDeviceId)
? selectedDeviceId
: -1;
return AlertDialog(
title: const Text('Input capture devices'),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Select active device:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
DropdownButton<int>(
isExpanded: true,
value: activeDeviceId,
items: [
const DropdownMenuItem<int>(
value: -1,
child: Text('Default device'),
),
...list.map(
(device) => DropdownMenuItem<int>(
value: device.id,
child: Text(
'${device.id}: ${device.name}'
'${device.isDefault ? ' (DEFAULT)' : ''}',
overflow: TextOverflow.ellipsis,
),
),
),
],
onChanged: (value) async {
if (value == null) return;
setState(() => selectedDeviceId = value);
setDialogState(() {});
if (recorder.isInitialized) {
final wasStarted = recorder.isDeviceStarted();
recorder.deinit();
try {
await recorder.init(
deviceID: value,
format: format,
sampleRate: sampleRate,
channels: channels,
androidInputPreset: selectedAndroidInputPreset,
iosInputPreset: selectedIosInputPreset,
webInputPreset: selectedWebInputPreset,
);
if (wasStarted) {
recorder.start();
}
} on Exception catch (e) {
debugPrint(
'-------------- init() on device change error: $e\n',
);
}
}
},
),
],
),
),
actions: <Widget>[
TextButton(
child: const Text('close'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
},
);
}
}