Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Android] getAmplitude() performance issues #374

Closed
sbauly opened this issue Aug 2, 2024 · 3 comments
Closed

[Android] getAmplitude() performance issues #374

sbauly opened this issue Aug 2, 2024 · 3 comments
Labels

Comments

@sbauly
Copy link

sbauly commented Aug 2, 2024

Package version
record: ^5.1.2

Environment

  • OS: Android 14

Describe the bug

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

To Reproduce

  • Run the minimal example app on iOS and Android
  • Start recording
  • See the discrepancy in sample rate

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<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  /// 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<String> _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<void> 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<void> 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<void> 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<String> 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: <Widget>[
              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
Copy link

samry commented Aug 2, 2024

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
Copy link
Owner

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
Copy link
Owner

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants