llfbandit / record

Audio recorder from microphone to a given file path. No external dependencies, MediaRecorder is used for Android an AVAudioRecorder for iOS.
https://pub.dev/packages/record
239 stars 202 forks source link

[Android] getAmplitude() performance issues #374

Closed sbauly closed 2 months ago

sbauly commented 3 months ago

Package version record: ^5.1.2

Environment

Describe the bug

There is a considerable performance discrepancy between iOS and Android when using the getAmplitude() method.

To Reproduce

Here is a minimal example app:

Example App ``` import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'flutter_record Example', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), home: const MyHomePage(title: 'flutter_record Example'), ); } } class MyHomePage extends StatefulWidget { const MyHomePage({super.key, required this.title}); final String title; @override State createState() => _MyHomePageState(); } class _MyHomePageState extends State { /// Audio Recorder final _recorder = AudioRecorder(); /// File path to store the recorded audio String? _filePath; /// Whether the audio is currently being recorded bool _isRecording = false; /// The list to store the volume data recorded throughout the exercise. final List _volumeData = []; /// Timer to update the volume level Timer? _updateVolumeTimer; /// Stopwatch Stopwatch? _stopwatch; /// Time of the stopwatch String get stopwatchTime { if (_stopwatch == null || !_stopwatch!.isRunning) { return "00:00"; } else { final seconds = _stopwatch!.elapsed.inSeconds.toString().padLeft(2, '0'); final milliseconds = (_stopwatch!.elapsed.inMilliseconds % 1000 ~/ 10) .toString() .padLeft(2, '0'); return "$seconds:$milliseconds"; } } /// Start recording audio Future startRecording() async { final bool hasPermission = await _recorder.hasPermission(); if (!hasPermission) return; final directory = await getApplicationDocumentsDirectory(); /// Generate a file name final String fileName = 'recording_${DateTime.now().millisecondsSinceEpoch}.m4a'; _filePath = '${directory.path}/$fileName'; /// Start recording await _recorder.start(const RecordConfig(), path: _filePath!); _isRecording = true; setState(() {}); /// Start the stopwatch _stopwatch = Stopwatch(); _stopwatch?.start(); /// Take a snapshot of the volume every 10 milliseconds _updateVolumeTimer = Timer.periodic( const Duration(milliseconds: 10), (timer) { updateVolume(); }, ); /// Record for 3 seconds await Future.delayed(const Duration(seconds: 3)); /// Stop recording volume data _stopwatch?.stop(); _updateVolumeTimer?.cancel(); /// Stop recording await stopRecording(); print('RECORDING = ${_recorder.isRecording}'); } /// Stop recording Future stopRecording() async { final path = await _recorder.stop(); final audioFile = File(path!); _isRecording = false; print('RECORDED AUDIO AT: $audioFile'); setState(() {}); } /// The volume level, at current double _volume = 0.0; /// Update the volume level Future updateVolume() async { /// Start time measurement final startTime = DateTime.now(); /// Get the current amplitude final Amplitude ampl = await _recorder.getAmplitude(); /// End time measurement final endTime = DateTime.now(); /// Calculate the duration final duration = endTime.difference(startTime).inMilliseconds; /// Add the amplitude data to the list _volume = ampl.current; _volumeData.add('${duration}ms ($_volume)'); print('GET AMPLITUDE TIME: ${duration}ms ($_volume)'); setState(() {}); } /// AudioPlayer to playback the recording final _audioPlayer = AudioPlayer(); /// Play the recorded audio void playRecording() { _audioPlayer.setFilePath(_filePath!); _audioPlayer.play(); print('PLAYING AUDIO'); setState(() {}); } /// The current platform final String platform = Platform.isAndroid ? 'ANDROID' : 'iOS'; /// The number of unique items in the volume data int countUniqueItems() { Set uniqueItems = _volumeData.toSet(); return uniqueItems.length; } /// How many samples were recorded out of the target of 300 (a sample every 10ms for 3 seconds) double calculateSampleCompleteness() { return (_volumeData.length / 300) * 100; } /// How diverse the samples are double calculateSampleDiversity() { return (countUniqueItems() / _volumeData.length) * 100; } /// Calculate the sample rate double calculateEffectiveSampleRate() { if (_volumeData.isEmpty) return 0; final totalDuration = _stopwatch?.elapsed.inMilliseconds ?? 3000; return _volumeData.length / (totalDuration / 1000); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(widget.title), ), body: Center( child: ListView( padding: const EdgeInsets.symmetric(horizontal: 32), children: [ const SizedBox(height: 40), /// Start recording button ElevatedButton( onPressed: () { _isRecording ? null : startRecording(); }, child: Text(_isRecording ? 'Recording' : 'Start Recording')), const SizedBox(height: 24), if (_isRecording) Text( textAlign: TextAlign.center, '$stopwatchTime s', ), /// Playback recorded audio button if (_filePath != null) ElevatedButton( onPressed: () { playRecording(); }, child: const Text('Play Recorded Audio')), const SizedBox(height: 40), /// Display the volume data if (_volumeData.isNotEmpty) ...[ Text('PLATFORM: $platform'), const SizedBox(height: 16), Text( 'Recording length: ${_stopwatch?.elapsed.inMilliseconds} ms'), const SizedBox(height: 16), Text('Sample Count: ${_volumeData.length} (Target: 300)'), const SizedBox(height: 16), Text( 'Sample Rate Score: ${calculateSampleCompleteness().toStringAsFixed(2)}%'), const SizedBox(height: 16), Text( 'Sample Diversity: ${calculateSampleDiversity().toStringAsFixed(2)}% - (${countUniqueItems()} unique samples)'), const SizedBox(height: 16), Text( 'Sample Rate: ${calculateEffectiveSampleRate().toStringAsFixed(2)} Hz (Target: 100 Hz)'), const SizedBox(height: 16), Text('$platform VOLUME DATA: ${_volumeData.join('\n')}'), ] ]))); } } ```

Here are screenshots which highlight the problem:

iOS Android

Note the recording length is in milliseconds, excuse the typo in the screenshots.

As you can see, when calling getAmplitude() every 10 milliseconds across a 3 second recording, iOS collects close to 300 samples with effectively no delay.

Android only collects in the range of 140-160 samples, often with significant delays between collecting samples whilst also getting lots of duplicate readings, too.

You can also see that whilst iOS starts getting volume data immediately, Android can take between 250-500ms before it starts actually picking up accurate data.

I didn’t notice this issue until updating my build.gradle from minSdkVersion: 23 to 24.

samry commented 3 months ago

Same issue here. getAmplitude() on Android returns the same exact value for upwards of 500ms, whereas every sample on iOS (down to every frame at 60fps) returns a slightly different value (as expected).

llfbandit commented 2 months ago

Thanks for the report, I'll take a look.

FYI, you should use onAmplitudeChanged stream to achieve the same. Also, onStateChanged stream is the way to go to ensure that the recording is really started.

llfbandit commented 2 months ago

I made small improvements in this area but it seems it is just slower to reach native side on Android.

Also, this does not solve "duplicated" values. This is because on iOS we must call explicitly an SDK method to get the values. On Android and other platforms, this is done each time PCM reader reads audio from hardware and I don't implement some kind of cumulated values to compare with previous call.

With my device and the given setup on top of example project on debug mode: 10ms interval => 280 with 100+ unique values. 10- values at -160.0 with onAmplitudeChanged stream. 20ms interval => 140+ with 95+ unique values. 5- values at -160.0 with onAmplitudeChanged stream. I will consider this ok as 10ms is a very low interval.