diff --git a/adaptive_number/test/adaptive_number_test.dart b/adaptive_number/test/adaptive_number_test.dart deleted file mode 100644 index 52931d7..0000000 --- a/adaptive_number/test/adaptive_number_test.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:adaptive_number/adaptive_number.dart'; -import 'package:test/test.dart'; - -// Since both implementations of Number only forward most method call, -// tests are kept minimal and should mainly concentrate on verifying that no -// wrong operator and/or method is being called because of a mix up -void main() { - group('utility functions', () { - test('retrieve value as int', () { - expect(Number.zero.intValue, 0); - expect(Number.one.intValue, 1); - expect(Number.two.intValue, 2); - expect(Number(42).intValue, 42); - }); - }); - - group('other overwrites', () { - test('hash code', () { - expect(Number.zero.hashCode, 0.hashCode); - expect(Number.one.hashCode, 1.hashCode); - expect(Number.two.hashCode, 2.hashCode); - expect(Number(42).hashCode, 42); - }); - - test('to string', () { - expect(Number.one.toString(), '1'); - expect((-Number.one).toString(), '-1'); - }); - - test('toRadixString', () { - expect(Number(42).toRadixString(2), '101010'); - }); - - test('abs', () { - expect(Number(-42).abs(), Number(42)); - }); - - test('compare to', () { - expect(Number.one.compareTo(Number.two), -1); - expect(Number.two.compareTo(Number.one), 1); - expect(Number.one.compareTo(Number.one), 0); - }); - }); - - group('operator tests', () { - test('add', () { - expect(Number.one + Number.two, Number(3)); - }); - - test('subtract', () { - expect(Number.two - Number.one, Number.one); - }); - - test('invert', () { - expect((-Number.one).intValue, -1); - expect((-Number.zero).intValue, 0); - }); - - test('multiply', () { - expect(Number.two * Number(3), Number(6)); - }); - - test('& bitwise and', () { - expect(Number(1234) & Number(9876), Number(1168)); - expect(Number(-1234) & Number(9876), Number(8708)); - }); - - test('>> bitwise shift right', () { - expect(Number(23423) >> 8, Number(91)); - expect(Number(-87653) >> 12, Number(-22)); - }); - - test('<< bitwise shift left', () { - expect(Number(23423) << 8, Number(5996288)); - expect(Number(-87653) << 12, Number(-359026688)); - }); - - test('^ bitwise xor', () { - expect(Number(1234) ^ Number(9876), Number(8774)); - }); - - test('| bitwise or', () { - expect(Number(1234) | Number(9876), Number(9942)); - expect(Number(-1234) | Number(9876), Number(-66)); - }); - - test('< less than', () { - expect(Number.one < Number.two, isTrue); - expect(Number.two < Number.one, isFalse); - }); - - test('< less than or equal', () { - expect(Number.two <= Number.one, isFalse); - expect(Number.one <= Number.two, isTrue); - expect(Number.two <= Number.two, isTrue); - }); - - test('< greater than', () { - expect(Number.two > Number.one, isTrue); - expect(Number.one > Number.two, isFalse); - }); - - test('< greater than or equal', () { - expect(Number.two >= Number.one, isTrue); - expect(Number.one >= Number.two, isFalse); - expect(Number.two >= Number.two, isTrue); - }); - - test('modulo', () { - expect(Number.two % Number.one, Number.zero); - expect(Number(3) % Number.one, Number.zero); - expect(Number.one % Number.two, Number.one); - }); - - test('truncating division', () { - expect(Number(1000) ~/ Number(-3), Number(-333)); - }); - - test('equals', () { - expect(Number(0) == Number.zero, isTrue); - expect(Number(1) == Number.one, isTrue); - expect(Number(2) == Number.two, isTrue); - - expect(Number(1) == Number.zero, isFalse); - expect(Number.one == Number.zero, isFalse); - }); - }); -} diff --git a/audio_waveforms/LICENSE b/audio_waveforms/LICENSE new file mode 100644 index 0000000..328279e --- /dev/null +++ b/audio_waveforms/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Simform Solutions + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/audio_waveforms/android/.gitignore b/audio_waveforms/android/.gitignore new file mode 100644 index 0000000..c6cbe56 --- /dev/null +++ b/audio_waveforms/android/.gitignore @@ -0,0 +1,8 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures diff --git a/audio_waveforms/android/build.gradle b/audio_waveforms/android/build.gradle new file mode 100644 index 0000000..a8b70a7 --- /dev/null +++ b/audio_waveforms/android/build.gradle @@ -0,0 +1,55 @@ +group 'com.simform.audio_waveforms' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.8.20' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + if (project.android.hasProperty("namespace")) { + namespace "com.simform.audio_waveforms" + } + + compileSdk 34 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdkVersion 21 + } +} + +dependencies { + implementation("androidx.multidex:multidex:2.0.1") + implementation "com.google.android.exoplayer:exoplayer:2.17.1" +} diff --git a/audio_waveforms/android/gradle.properties b/audio_waveforms/android/gradle.properties new file mode 100644 index 0000000..94adc3a --- /dev/null +++ b/audio_waveforms/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/audio_waveforms/android/gradle/wrapper/gradle-wrapper.properties b/audio_waveforms/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..8bc9958 --- /dev/null +++ b/audio_waveforms/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip diff --git a/audio_waveforms/android/settings.gradle b/audio_waveforms/android/settings.gradle new file mode 100644 index 0000000..f28d6ea --- /dev/null +++ b/audio_waveforms/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'audio_waveforms' diff --git a/audio_waveforms/android/src/main/AndroidManifest.xml b/audio_waveforms/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..10b1be9 --- /dev/null +++ b/audio_waveforms/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt new file mode 100644 index 0000000..2948bcd --- /dev/null +++ b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/AudioPlayer.kt @@ -0,0 +1,239 @@ +package com.simform.audio_waveforms + +import android.content.Context +import android.net.Uri +import android.os.Handler +import android.os.Looper +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.Player +import io.flutter.plugin.common.MethodChannel + +class AudioPlayer( + context: Context, + channel: MethodChannel, + playerKey: String +) { + private var handler: Handler = Handler(Looper.getMainLooper()) + private var runnable: Runnable? = null + private var methodChannel = channel + private var appContext = context + private var player: ExoPlayer? = null + private var playerListener: Player.Listener? = null + private var isPlayerPrepared: Boolean = false + private var finishMode = FinishMode.Stop + private var key = playerKey + private var updateFrequency: Long = 200 + + fun preparePlayer( + result: MethodChannel.Result, + path: String?, + volume: Float?, + frequency: Long?, + ) { + if (path != null) { + frequency?.let { + updateFrequency = it + } + val uri = Uri.parse(path) + val mediaItem = MediaItem.fromUri(uri) + stop() + player?.clearMediaItems() + player = ExoPlayer.Builder(appContext).build() + player?.setMediaItem(mediaItem) + player?.prepare() + playerListener = object : Player.Listener { + + override fun onPlayerError(error: PlaybackException) { + super.onPlayerError(error) + result.error(Constants.LOG_TAG, error.message, "Unable to load media source.") + } + + override fun onPlayerStateChanged(isReady: Boolean, state: Int) { + if (!isPlayerPrepared) { + if (state == Player.STATE_READY) { + player?.volume = volume ?: 1F + isPlayerPrepared = true + result.success(true) + } + } + if (state == Player.STATE_ENDED) { + val args: MutableMap = HashMap() + when (finishMode) { + FinishMode.Stop -> { + player?.stop() + player?.release() + player = null + stopListening() + args[Constants.finishType] = 2 + } + + FinishMode.Loop -> { + player?.seekTo(0) + player?.play() + args[Constants.finishType] = 0 + } + + FinishMode.Pause -> { + player?.seekTo(0) + player?.playWhenReady = false + stopListening() + args[Constants.finishType] = 1 + } + } + args[Constants.playerKey] = key + methodChannel.invokeMethod( + Constants.onDidFinishPlayingAudio, + args + ) + } + } + } + player?.addListener(playerListener!!) + } else { + result.error(Constants.LOG_TAG, "path to audio file or unique key can't be null", "") + } + } + + fun seekToPosition(result: MethodChannel.Result, progress: Long?) { + if (progress != null) { + player?.seekTo(progress) + sendCurrentDuration() + result.success(true) + } else { + result.success(false) + } + } + + fun start(result: MethodChannel.Result) { + try { + player?.playWhenReady = true + player?.play() + result.success(true) + startListening(result) + } catch (e: Exception) { + result.error(Constants.LOG_TAG, "Can not start the player", e.toString()) + } + } + + fun getDuration(result: MethodChannel.Result, durationType: DurationType) { + try { + if (durationType == DurationType.Current) { + val duration = player?.currentPosition + result.success(duration) + } else { + val duration = player?.duration + result.success(duration) + } + } catch (e: Exception) { + result.error(Constants.LOG_TAG, "Can not get duration", e.toString()) + } + } + + fun stop() { + stopListening() + if (playerListener != null) { + player?.removeListener(playerListener!!) + } + isPlayerPrepared = false + player?.stop() + } + + + fun pause() { + stopListening() + player?.pause() + } + + fun release(result: MethodChannel.Result) { + try { + player?.release() + result.success(true) + } catch (e: Exception) { + result.error(Constants.LOG_TAG, "Failed to release player resource", e.toString()) + } + + } + + fun setVolume(volume: Float?, result: MethodChannel.Result) { + try { + if (volume != null) { + player?.volume = volume + result.success(true) + } else { + result.success(false) + } + } catch (e: Exception) { + result.success(false) + } + } + + fun setRate(rate: Float?, result: MethodChannel.Result) { + try { + if (rate != null) { + player?.setPlaybackSpeed(rate) + result.success(true) + } else { + result.success(false) + } + } catch (e: Exception) { + result.success(false) + } + } + + fun setFinishMode(result: MethodChannel.Result, releaseModeType: Int?) { + try { + when (releaseModeType) { + 0 -> { + this.finishMode = FinishMode.Loop + } + + 1 -> { + this.finishMode = FinishMode.Pause + } + + 2 -> { + this.finishMode = FinishMode.Stop + } + + null -> { + throw Exception("Release mode is null") + } + + else -> { + throw Exception("Invalid Finish mode") + } + } + result.success(null) + } catch (e: Exception) { + result.error(Constants.LOG_TAG, "Can not set the release mode", e.toString()) + } + } + + private fun startListening(result: MethodChannel.Result) { + runnable = object : Runnable { + override fun run() { + sendCurrentDuration() + handler.postDelayed(this, updateFrequency) + } + } + handler.post(runnable!!) + + } + + private fun stopListening() { + runnable?.let { handler.removeCallbacks(it) } + sendCurrentDuration() + } + + private fun sendCurrentDuration() { + val currentPosition = player?.currentPosition ?: 0 + val args: MutableMap = HashMap() + args[Constants.current] = currentPosition + args[Constants.playerKey] = key + methodChannel.invokeMethod(Constants.onCurrentDuration, args) + } + + +} diff --git a/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt new file mode 100644 index 0000000..24fd73b --- /dev/null +++ b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt @@ -0,0 +1,263 @@ +package com.simform.audio_waveforms + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.content.pm.PackageManager +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaMetadataRetriever +import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION +import android.media.MediaRecorder +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.ActivityCompat +import com.simform.audio_waveforms.Constants.LOG_TAG +import com.simform.audio_waveforms.encoders.* +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.PluginRegistry +import java.io.File +import kotlin.math.sqrt + +class AudioRecorder : PluginRegistry.RequestPermissionsResultListener { + private var permissions = arrayOf(Manifest.permission.RECORD_AUDIO) + private var audioRecord: AudioRecord? = null + private var channelConfig: Int = AudioFormat.CHANNEL_IN_MONO + private var audioFormat: Int = AudioFormat.ENCODING_PCM_16BIT + private var bufferSize: Int? = null + private var recorderState: RecorderState = RecorderState.Disposed + private var filePath: String? = null + private var recordingThread: Thread? = null + private var recorderSettings: RecorderSettings? = null + private var encoder: Encoder? = null + lateinit var channel: MethodChannel + private var commonEncoder = CommonEncoder() + private var wavEncoder: WavEncoder? = null + private var successCallback: RequestPermissionsSuccessCallback? = null + private var totalSamples = 0L + private val channelCount: Int + get() = when (channelConfig) { + AudioFormat.CHANNEL_IN_MONO -> 1 + AudioFormat.CHANNEL_IN_STEREO -> 2 + else -> 1 + } + + override fun onRequestPermissionsResult( + requestCode: Int, permissions: Array, grantResults: IntArray + ): Boolean { + return if (requestCode == Constants.RECORD_AUDIO_REQUEST_CODE) { + successCallback?.onSuccess(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) + grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED + } else { + false + } + } + + private fun isPermissionGranted(activity: Activity?): Boolean { + val result = ActivityCompat.checkSelfPermission(activity!!, permissions[0]) + return result == PackageManager.PERMISSION_GRANTED + } + + fun checkPermission( + result: Result, activity: Activity?, successCallback: RequestPermissionsSuccessCallback + ) { + this.successCallback = successCallback + if (!isPermissionGranted(activity)) { + activity?.let { + ActivityCompat.requestPermissions( + it, permissions, Constants.RECORD_AUDIO_REQUEST_CODE + ) + } + } else { + result.success(true) + } + } + + @RequiresApi(Build.VERSION_CODES.M) + @SuppressLint("MissingPermission") + fun initRecorder( + recorderSettings: RecorderSettings, channel: MethodChannel, result: Result + ) { + filePath = recorderSettings.path + if (filePath == null) return + this.channel = channel + bufferSize = + AudioRecord.getMinBufferSize(recorderSettings.sampleRate, channelConfig, audioFormat) + + if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) { + result.error( + LOG_TAG, + "Invalid buffer size: $bufferSize", + null + ) + } + try { + audioRecord = AudioRecord( + MediaRecorder.AudioSource.MIC, + recorderSettings.sampleRate, + channelConfig, + audioFormat, + bufferSize!! + ) + } catch (e: Exception) { + result.error( + LOG_TAG, + "Error initializing AudioRecord: ${e.message}", + null + ) + return + } + this.recorderSettings = recorderSettings + encoder = recorderSettings.encoder + recorderState = RecorderState.Initialised + result.success(true) + } + + fun start(result: Result) { + if (recorderSettings == null || bufferSize == null) { + result.error( + LOG_TAG, + "recorder settings is null or bufferSize is null", + "recorderSettings: $recorderSettings, bufferSize: $bufferSize" + ) + return + } + audioRecord?.startRecording() + recorderState = RecorderState.Recording + if (encoder?.encodeForWav == true) { + wavEncoder = WavEncoder( + wavFile = File(recorderSettings!!.path!!), + sampleRate = recorderSettings!!.sampleRate + ) + wavEncoder?.start(result) + } else { + commonEncoder.initCodec(recorderSettings = recorderSettings!!, result = result) { + recordingThread?.join() + } + } + val buffer = ByteArray(bufferSize!!) + recordingThread = Thread { + while (recorderState == RecorderState.Recording || recorderState == RecorderState.Paused) { + if (recorderState == RecorderState.Recording) { + val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0 + + if (read > 0) { + val audioData = buffer.copyOf(read) + if (encoder?.encodeForWav == true) { + wavEncoder?.writePcmData(audioData) + } else { + commonEncoder.queueInputBuffer(audioData) + } + val rms = calculateRms(audioData, read) + totalSamples += read / channelCount + val durationSec = + totalSamples.toDouble() / (recorderSettings?.sampleRate + ?: Constants.DEFAULT_SAMPLE_RATE) + val milliSeconds = (durationSec * 1000).toLong() + sendBytesToFlutter(audioData, rms, milliSeconds) + } + } + } + } + recordingThread?.start() + result.success(true) + } + + fun stop(result: Result) { + try { + audioRecord?.stop() + totalSamples = 0L + recorderState = RecorderState.Stopped + if (encoder?.encodeForWav == true) { + wavEncoder?.stop(result) + recordingThread?.join() + sendRecordingResult(result) + } else { + commonEncoder.setOnEncodingCompleted { + sendRecordingResult(result) + } + commonEncoder.signalToStop() + } + + } catch (e: Exception) { + result.error(LOG_TAG, e.message, "An error occurred while stopping the recorder") + return + } + release() + } + + private fun sendRecordingResult(result: Result) { + val duration = getDuration(recorderSettings?.path) + val hashMap = HashMap() + hashMap[Constants.resultFilePath] = recorderSettings?.path + hashMap[Constants.resultDuration] = duration + result.success(hashMap) + } + + private fun sendBytesToFlutter(chunk: ByteArray, rms: Double, milliSeconds: Long) { + val args: MutableMap = HashMap() + args[Constants.normalisedRms] = rms + args[Constants.bytes] = chunk + args[Constants.recordedDuration] = milliSeconds + Handler(Looper.getMainLooper()).post { + channel.invokeMethod(Constants.onAudioChunk, args) + } + } + + private fun calculateRms(chunk: ByteArray, size: Int): Double { + var sum = 0.0 + var count = 0 + + val adjustedSize = if (size % 2 == 0) size else size - 1 + for (i in 0 until adjustedSize step 2) { + val low = chunk[i].toInt() and 0xff + val high = chunk[i + 1].toInt() + val sample = (high shl 8) or low + + sum += sample * sample.toDouble() + count++ + } + + val normalisedRms = sqrt(sum / count) / 32767.0 + return normalisedRms + } + + fun pause(result: Result) { + recorderState = RecorderState.Paused + result.success(false) + } + + fun resume(result: Result) { + recorderState = RecorderState.Recording + result.success(true) + } + + fun release() { + try { + audioRecord?.release() + } catch (e: Exception) { + Log.e(LOG_TAG, "Error releasing AudioRecord: ${e.message}") + } + + audioRecord = null + recorderState = RecorderState.Disposed + } + + private fun getDuration(path: String?): Int { + val mediaMetadataRetriever = MediaMetadataRetriever() + try { + mediaMetadataRetriever.setDataSource(path) + val duration = mediaMetadataRetriever.extractMetadata(METADATA_KEY_DURATION) + return duration?.toInt() ?: -1 + } catch (e: Exception) { + Log.e(LOG_TAG, "Error getting duration: ${e.message}") + } finally { + mediaMetadataRetriever.release() + } + return -1 + } +} diff --git a/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt new file mode 100644 index 0000000..4252ff1 --- /dev/null +++ b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/AudioWaveformsPlugin.kt @@ -0,0 +1,338 @@ +package com.simform.audio_waveforms + +import android.app.Activity +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + + +/** AudioWaveformsPlugin */ +class AudioWaveformsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { + private lateinit var channel: MethodChannel + private var recorder: MediaRecorder? = null + private var activity: Activity? = null + private lateinit var audioRecorder: AudioRecorder + private var recorderSettings = RecorderSettings(path = null) + private lateinit var applicationContext: Context + private var audioPlayers = mutableMapOf() + private var extractors = mutableMapOf() + private var pluginBinding: ActivityPluginBinding? = null + private var record: AudioRecorder = AudioRecorder() + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(flutterPluginBinding.binaryMessenger, Constants.methodChannelName) + channel.setMethodCallHandler(this) + audioRecorder = AudioRecorder() + applicationContext = flutterPluginBinding.applicationContext + } + + @RequiresApi(Build.VERSION_CODES.N) + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + Constants.initRecorder -> { + val arguments = call.arguments + if (arguments is Map<*, *>) { + recorderSettings = RecorderSettings.fromJson(arguments) + checkPathAndInitialiseRecorder(result, recorderSettings) + } else { + result.error( + Constants.LOG_TAG, "Failed to initialise Recorder", "Invalid Arguments" + ) + } + } + + Constants.startRecording -> record.start(result) + + Constants.stopRecording -> { + record.stop(result) + recorder = null + } + + Constants.pauseRecording -> record.pause(result) + + Constants.resumeRecording -> record.resume(result) + Constants.checkPermission -> audioRecorder.checkPermission( + result, activity, result::success + ) + + Constants.preparePlayer -> { + val audioPath = call.argument(Constants.path) as String? + val volume = call.argument(Constants.volume) as Double? + val key = call.argument(Constants.playerKey) as String? + val frequency = call.argument(Constants.updateFrequency) as Int? + if (key != null) { + initPlayer(key) + audioPlayers[key]?.preparePlayer( + result, + audioPath, + volume?.toFloat(), + frequency?.toLong(), + ) + } else { + result.error(Constants.LOG_TAG, "Player key can't be null", "") + } + + } + + Constants.startPlayer -> { + val key = call.argument(Constants.playerKey) as String? + if (key != null) { + audioPlayers[key]?.start(result) + } else { + result.error(Constants.LOG_TAG, "Player key can't be null", "") + } + } + + Constants.stopPlayer -> { + val key = call.argument(Constants.playerKey) as String? + if (key != null) { + try { + audioPlayers[key]?.stop() + result.success(true) + } catch (e: Exception) { + result.error(Constants.LOG_TAG, "Failed to stop player", e.message) + } + } else { + result.error(Constants.LOG_TAG, "Player key can't be null", "") + } + } + + Constants.pausePlayer -> { + val key = call.argument(Constants.playerKey) as String? + if (key != null) { + try { + audioPlayers[key]?.pause() + result.success(true) + } catch (e: Exception) { + result.error(Constants.LOG_TAG, "Failed to pause player", e.message) + } + } else { + result.error(Constants.LOG_TAG, "Player key can't be null", "") + } + } + + Constants.releasePlayer -> { + val key = call.argument(Constants.playerKey) as String? + audioPlayers[key]?.release(result) + } + + Constants.seekTo -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val progress = call.argument(Constants.progress) as Int? + val key = call.argument(Constants.playerKey) as String? + if (key != null) { + audioPlayers[key]?.seekToPosition(result, progress?.toLong()) + } else { + result.error(Constants.LOG_TAG, "Player key can't be null", "") + } + } else { + Log.e( + Constants.LOG_TAG, + "Minimum android O is required for seekTo function to works" + ) + } + } + + Constants.setVolume -> { + val volume = call.argument(Constants.volume) as Double? + val key = call.argument(Constants.playerKey) as String? + if (key != null) { + audioPlayers[key]?.setVolume(volume?.toFloat(), result) + } else { + result.error(Constants.LOG_TAG, "Player key can't be null", "") + } + } + + Constants.setRate -> { + val rate = call.argument(Constants.rate) as Double? + val key = call.argument(Constants.playerKey) as String? + if (key != null) { + audioPlayers[key]?.setRate(rate?.toFloat(), result) + } else { + result.error(Constants.LOG_TAG, "Player key can't be null", "") + } + } + + Constants.getDuration -> { + val type = + if ((call.argument(Constants.durationType) as Int?) == 0) DurationType.Current else DurationType.Max + val key = call.argument(Constants.playerKey) as String? + if (key != null) { + audioPlayers[key]?.getDuration(result, type) + } else { + result.error(Constants.LOG_TAG, "Player key can't be null", "") + } + } + + Constants.extractWaveformData -> { + val key = call.argument(Constants.playerKey) as String? + val path = call.argument(Constants.path) as String? + val noOfSample = call.argument(Constants.noOfSamples) as Int? + if (key != null) { + createOrUpdateExtractor( + playerKey = key, + result = result, + path = path, + noOfSamples = noOfSample ?: 100, + ) + } else { + result.error(Constants.LOG_TAG, "Waveform key can't be null", "") + } + } + + Constants.STOP_EXTRACTION -> { + val key = call.argument(Constants.playerKey) as String? + key?.let { + extractors[it]?.stop() + result.success(true) + } ?: result.error(Constants.LOG_TAG, "Waveform key can't be null", "") + } + + Constants.stopAllPlayers -> { + stopAllPlayer(result) + } + + Constants.finishMode -> { + val releaseType = call.argument(Constants.finishType) + val key = call.argument(Constants.playerKey) + key?.let { + audioPlayers[it]?.setFinishMode(result, releaseType) + } + } + + Constants.pauseAllPlayers -> { + pauseAllPlayer(result) + } + + else -> result.notImplemented() + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun checkPathAndInitialiseRecorder( + result: Result, recorderSettings: RecorderSettings + ) { + if (recorderSettings.path == null) { + val outputDir = activity?.cacheDir + val outputFile: File? + val dateTimeInstance = SimpleDateFormat(Constants.fileNameFormat, Locale.US) + val currentDate = dateTimeInstance.format(Date()) + try { + outputFile = File.createTempFile(currentDate, ".m4a", outputDir) + recorderSettings.path = outputFile.path + } catch (e: IOException) { + result.error(Constants.LOG_TAG, "Failed to create file", e.message) + return + } + } + record.initRecorder(recorderSettings, channel, result) + } + + private fun initPlayer(playerKey: String) { + if (!audioPlayers.containsKey(playerKey)) { + val newPlayer = AudioPlayer( + context = applicationContext, + channel = channel, + playerKey = playerKey, + ) + audioPlayers[playerKey] = newPlayer + } + return + } + + private fun createOrUpdateExtractor( + playerKey: String, + noOfSamples: Int, + path: String?, + result: Result, + ) { + if (path == null) { + result.error(Constants.LOG_TAG, "Path can't be null", "") + return + } + extractors[playerKey]?.stop() + extractors[playerKey] = WaveformExtractor( + context = applicationContext, + methodChannel = channel, + expectedPoints = noOfSamples, + key = playerKey, + path = path, + result = result, + extractorCallBack = object : ExtractorCallBack { + override fun onProgress(value: Float) { + if (value == 1.0F) { + result.success(extractors[playerKey]?.sampleData) + } + } + + }) + extractors[playerKey]?.startDecode() + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activity = binding.activity + pluginBinding = binding + pluginBinding!!.addRequestPermissionsResultListener(this.audioRecorder) + + } + + override fun onDetachedFromActivityForConfigChanges() { + activity = null + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + activity = binding.activity + } + + override fun onDetachedFromActivity() { + recorder?.release() + recorder = null + audioPlayers.clear() + extractors.clear() + activity = null + if (pluginBinding != null) { + pluginBinding!!.removeRequestPermissionsResultListener(this.audioRecorder) + } + } + + private fun stopAllPlayer(result: Result) { + try { + for ((key, _) in audioPlayers) { + audioPlayers[key]?.stop() + audioPlayers[key] = null + } + result.success(true) + } catch (e: Exception) { + result.error(Constants.LOG_TAG, "Failed to stop players", e.message) + } + } + + private fun pauseAllPlayer(result: Result) { + try { + for ((key, _) in audioPlayers) { + audioPlayers[key]?.pause() + } + result.success(true) + } catch (e: Exception) { + result.error(Constants.LOG_TAG, "Failed to pause players", e.message) + } + } +} diff --git a/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/RecorderSettings.kt b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/RecorderSettings.kt new file mode 100644 index 0000000..b1d58dd --- /dev/null +++ b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/RecorderSettings.kt @@ -0,0 +1,61 @@ +package com.simform.audio_waveforms + +/** + * Configuration class for audio recording settings + * + * This data class encapsulates all the settings required for configuring + * the audio recorder, including: + * - Output file path + * - Audio encoder/format + * - Sample rate + * - Bit rate + * + * It provides sensible defaults for common recording scenarios and includes + * a factory method to create instances from JSON/Map data received from Flutter. + */ +data class RecorderSettings( + /** + * Path where the recorded audio file will be saved + * Can be null if not specified by the caller + */ + var path: String?, + + /** + * Audio encoder to use for the recording + * Defaults to AAC Low Complexity (AAC_LC) + */ + val encoder: Encoder = Encoder.AAC_LC, + + /** + * Sample rate in Hz (samples per second) + * Defaults to 44100Hz (CD quality) + */ + val sampleRate: Int = 44100, + + /** + * Bit rate in bits per second + * Defaults to 128kbps (good quality for most audio) + */ + val bitRate: Int = 128000 +) { + companion object { + /** + * Creates a RecorderSettings instance from a Map/JSON object + * + * This factory method is used to convert parameters received from Flutter + * into a RecorderSettings object. It handles default values when parameters + * are missing. + * + * @param json The map containing recorder settings from Flutter + * @return A configured RecorderSettings instance + */ + fun fromJson(json: Map<*, *>): RecorderSettings { + return RecorderSettings( + path = json[Constants.path] as String?, + encoder = Encoder.fromString(json[Constants.encoder] as String?), + sampleRate = (json[Constants.sampleRate] as Int?) ?: 44100, + bitRate = json[Constants.bitRate] as Int + ) + } + } +} \ No newline at end of file diff --git a/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt new file mode 100644 index 0000000..59bbfcf --- /dev/null +++ b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt @@ -0,0 +1,329 @@ +package com.simform.audio_waveforms + +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.MediaMuxer +import android.media.MediaRecorder +import android.os.Build +import android.util.Log +import com.simform.audio_waveforms.Constants.LOG_TAG + +/** + * Specifies which duration value to retrieve from an audio player + */ +enum class DurationType { + /** Current playback position */ + Current, + /** Total duration of the audio file */ + Max +} + +/** + * Global constants used throughout the audio_waveforms plugin + * + * This object contains method channel names, parameter keys, default values, + * and other constants needed for the plugin's operation. + */ +object Constants { + + // TODO: Update all const to uppercase + const val initRecorder = "initRecorder" + const val startRecording = "startRecording" + const val stopRecording = "stopRecording" + const val pauseRecording = "pauseRecording" + const val resumeRecording = "resumeRecording" + const val checkPermission = "checkPermission" + const val path = "path" + const val LOG_TAG = "AudioWaveforms" + const val methodChannelName = "simform_audio_waveforms_plugin/methods" + const val encoder = "encoder" + const val sampleRate = "sampleRate" + const val bitRate = "bitRate" + const val fileNameFormat = "dd-MM-yy-hh-mm-ss" + + const val preparePlayer = "preparePlayer" + const val startPlayer = "startPlayer" + const val stopPlayer = "stopPlayer" + const val pausePlayer = "pausePlayer" + const val releasePlayer = "releasePlayer" + const val seekTo = "seekTo" + const val progress = "progress" + const val setVolume = "setVolume" + const val finishMode = "finishMode" + const val finishType = "finishType" + const val volume = "volume" + const val setRate = "setRate" + const val rate = "rate" + const val getDuration = "getDuration" + const val durationType = "durationType" + const val playerKey = "playerKey" + const val current = "current" + const val onCurrentDuration = "onCurrentDuration" + const val stopAllPlayers = "stopAllPlayers" + const val onDidFinishPlayingAudio = "onDidFinishPlayingAudio" + const val extractWaveformData = "extractWaveformData" + const val noOfSamples = "noOfSamples" + const val onCurrentExtractedWaveformData = "onCurrentExtractedWaveformData" + const val waveformData = "waveformData" + const val updateFrequency = "updateFrequency" + const val STOP_EXTRACTION = "stopExtraction" + + const val resultFilePath = "resultFilePath" + const val resultDuration = "resultDuration" + const val pauseAllPlayers = "pauseAllPlayers" + const val normalisedRms = "normalisedRms" + const val bytes = "bytes" + const val recordedDuration = "recordedDuration" + const val onAudioChunk = "onAudioChunk" + + // TODO: make user can set this in future + const val CHANNEL: Int = 1 + const val BIT_PER_SAMPLE: Int = 16 + + const val RECORD_AUDIO_REQUEST_CODE = 1001 + + /// Indicates 128 bits in a single channel for 8-bit PCM + const val EIGHT_BITS = 128f + + /// Indicates 32767 bits in a single channel for 16-bit PCM + const val SIXTEEN_BITS = 32767f + + /// Indicates 2147483648f bits in a single channel for 32-bit PCM + const val THIRTY_TWO_BITS = 2.14748365E9f + const val ENCODER_THREAD = "EncoderThread" + const val AAC_FILE_EXTENSION = "aac" + const val DEFAULT_SAMPLE_RATE = 44100 +} + +/** + * Defines behavior when audio playback reaches the end + * + * Controls what happens when an audio file finishes playing. + * + * @property value The integer value sent to the platform channel + */ +enum class FinishMode(val value: Int) { + /** Restart playback from the beginning */ + Loop(0), + + /** Pause at the end of the file */ + Pause(1), + + /** Stop playback and release resources */ + Stop(2) +} + + +/** + * Callback interface for permission request results + * + * This functional interface is used to notify when a permission request + * has been processed, providing the result of the permission check. + */ +fun interface RequestPermissionsSuccessCallback { + /** + * Called when permission request completes + * + * @param results true if all required permissions were granted, false otherwise + */ + fun onSuccess(results: Boolean?) +} + +/** + * Defines the possible states of the audio recorder + * + * These states allow tracking the recorder's lifecycle and determine + * which operations are valid at any given time. + */ +enum class RecorderState { + /** Recorder is initialized and ready to start recording */ + Initialised, + + /** Recorder is actively recording audio */ + Recording, + + /** Recorder has been temporarily paused but can resume */ + Paused, + + /** Recording has been stopped (can't resume, but can start a new recording) */ + Stopped, + + /** Recorder has been disposed and cannot be used anymore */ + Disposed +} + +/** + * Defines the supported audio encoders for recording + * + * This enum provides configuration details for various audio encoders, + * including: + * - MIME type + * - Buffer size + * - Output format + * - AAC profile (when applicable) + * - MediaMuxer usage requirements + * + * Each encoder has different characteristics in terms of audio quality, + * file size, and compatibility across platforms. + */ +enum class Encoder { + /** Uncompressed PCM audio in WAV container */ + WAV, + /** AAC Low Complexity profile - good quality/size balance */ + AAC_LC, + /** AAC High Efficiency profile - better compression than AAC_LC */ + AAC_HE, + /** AAC Enhanced Low Delay - optimized for real-time communication */ + AAC_ELD, + /** Adaptive Multi-Rate Narrowband - speech optimized, low bitrate */ + AMR_NB, + /** Adaptive Multi-Rate Wideband - better speech quality than AMR_NB */ + AMR_WB, + /** Opus codec - versatile audio codec with good compression */ + OPUS; + + /** + * Gets the MIME type string for this encoder + * + * Used when configuring MediaCodec encoders + */ + val mimeType: String + get() = when (this) { + WAV -> MediaFormat.MIMETYPE_AUDIO_RAW + AAC_LC, AAC_HE, AAC_ELD -> MediaFormat.MIMETYPE_AUDIO_AAC + AMR_NB -> MediaFormat.MIMETYPE_AUDIO_AMR_NB + AMR_WB -> MediaFormat.MIMETYPE_AUDIO_AMR_WB + OPUS -> MediaFormat.MIMETYPE_AUDIO_OPUS + } + + /** + * Gets the recommended buffer size for this encoder + * + * Different encoders work optimally with different buffer sizes. + * This property returns the recommended size for each encoder type. + */ + val bufferSize: Int + get() = when (this) { + AAC_LC -> 2048 + AAC_HE -> 2048 + AAC_ELD -> 2048 + AMR_NB -> 1024 + AMR_WB -> 2048 + WAV -> 8192 + OPUS -> 2048 + } + + /** + * Gets the appropriate output format for this encoder + * + * Maps each encoder to its corresponding container format in MediaRecorder. + * For OPUS, uses OGG container format on Android Q and above. + * + * @throws IllegalArgumentException if WAV is selected (uses raw PCM) + * @throws Exception if OPUS is selected on Android below Q + */ + val toOutputFormat: Int + get() = when (this) { + WAV -> throw IllegalArgumentException("Illegal format selection.") + AAC_LC, AAC_HE, AAC_ELD -> MediaRecorder.OutputFormat.MPEG_4 + AMR_NB -> MediaRecorder.OutputFormat.AMR_NB + AMR_WB -> MediaRecorder.OutputFormat.AMR_WB + OPUS -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG + } else { + throw Exception("Minimum android Q is required for $this encoder.") + } + } + } + + /** + * Gets the appropriate MediaMuxer output format for this encoder + * + * This property is specifically for MediaMuxer, which uses different + * constants than MediaRecorder.OutputFormat. + */ + val toMuxerOutputFormat: Int + get() = when (this) { + WAV, AMR_NB, AMR_WB -> throw IllegalArgumentException("MediaMuxer not used for this encoder.") + AAC_LC, AAC_HE, AAC_ELD -> MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4 + OPUS -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG + } else { + throw Exception("Minimum android Q is required for $this encoder.") + } + } + } + + + /** + * Indicates whether this encoder requires MediaMuxer + * + * Some encoders (AAC, OPUS) require a container format that needs MediaMuxer + * for proper file creation, while others (WAV, AMR) don't. + */ + val useMediaMuxer: Boolean + get() = when (this) { + WAV, AMR_NB, AMR_WB -> false + AAC_LC, AAC_HE, AAC_ELD, OPUS -> true + } + + /** + * Gets the AAC profile for this encoder if it's an AAC variant + * + * Returns the appropriate MediaCodecInfo profile constant for AAC encoders, + * or null for non-AAC encoders. + */ + val aacProfile: Int? + get() = when (this) { + AAC_LC -> MediaCodecInfo.CodecProfileLevel.AACObjectLC + AAC_HE -> MediaCodecInfo.CodecProfileLevel.AACObjectHE + AAC_ELD -> MediaCodecInfo.CodecProfileLevel.AACObjectELD + else -> null + } + + companion object { + /** + * Creates an Encoder from a string value + * + * Safely converts a string to its corresponding Encoder enum value. + * Returns AAC_LC as a fallback if the string is null or invalid. + * + * @param value The string representation of the encoder + * @return The matching Encoder, or AAC_LC if invalid + */ + fun fromString(value: String?): Encoder { + return try { + if (value == null) { + Log.e(LOG_TAG, "Encoder type is null. Defaulting to AAC_LC.") + return AAC_LC + } + valueOf(value) + } catch (_: IllegalArgumentException) { + Log.e(LOG_TAG, "Invalid encoder type: $value. Defaulting to AAC_LC.") + AAC_LC + } + } + } + + /** + * Indicates if this encoder uses WAV format + * + * WAV format requires special handling for raw PCM data + */ + val encodeForWav: Boolean + get() { + return this == WAV + } + + /** + * Indicates if this encoder is an AAC variant + * + * AAC encoders may need ADTS headers when writing raw frames + */ + val isAAC: Boolean + get() { + return this == AAC_LC || this == AAC_HE || this == AAC_ELD + } +} \ No newline at end of file diff --git a/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt new file mode 100644 index 0000000..46201a9 --- /dev/null +++ b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/WaveformExtractor.kt @@ -0,0 +1,432 @@ +package com.simform.audio_waveforms + +import android.content.Context +import android.media.AudioFormat +import android.media.MediaCodec +import android.media.MediaExtractor +import android.media.MediaFormat +import android.os.Build +import android.util.Log +import io.flutter.plugin.common.MethodChannel +import java.nio.ByteBuffer +import java.util.concurrent.CountDownLatch +import kotlin.math.pow +import kotlin.math.sqrt +import androidx.core.net.toUri + +/** + * WaveformExtractor handles the process of extracting amplitude data from audio files + * to generate waveform visualizations. + * + * This class uses the Android MediaCodec API to decode audio files and extract + * RMS (Root Mean Square) values at regular intervals, which represent the amplitude + * of the audio signal. These values can then be used to draw waveform visualizations. + * + * The extractor supports various audio formats and bit depths (8-bit, 16-bit, and 32-bit) + * and handles both mono and stereo audio channels. + */ +class WaveformExtractor( + /** Path to the audio file to analyze */ + private val path: String, + /** Number of waveform data points to generate */ + private val expectedPoints: Int, + /** Unique identifier for this extraction process */ + private val key: String, + /** Method channel for sending progress updates to Flutter */ + private val methodChannel: MethodChannel, + /** Result callback for sending the final result back to Flutter */ + private val result: MethodChannel.Result, + /** Callback for notifying about progress changes */ + private val extractorCallBack: ExtractorCallBack, + /** Application context for accessing content URIs */ + private val context: Context, +) { + /** MediaCodec for decoding audio data */ + private var decoder: MediaCodec? = null + /** MediaExtractor for reading audio tracks from the file */ + private var extractor: MediaExtractor? = null + /** Duration of the audio file in milliseconds */ + private var durationMillis = 0L + /** Current extraction progress (0.0 to 1.0) */ + private var progress = 0F + /** Number of processed chunks */ + private var currentProgress = 0F + + /** Latch for synchronizing completion of the extraction process */ + private val finishCount = CountDownLatch(1) + /** Flag indicating end of input data */ + private var inputEof = false + /** Sample rate of the audio in Hz */ + private var sampleRate = 0 + /** Number of audio channels (1=mono, 2=stereo) */ + private var channels = 1 + /** Bit depth of the audio (8, 16, or 32) */ + private var pcmEncodingBit = 16 + /** Total number of audio samples */ + private var totalSamples = 0L + /** Number of audio samples per waveform data point */ + private var perSamplePoints = 0L + /** Flag to prevent submitting multiple results */ + private var isReplySubmitted = false + + /** + * Retrieves the audio format from the given media file + * + * This method: + * 1. Creates a MediaExtractor to read the file + * 2. Finds the first audio track in the file + * 3. Retrieves and selects that track + * 4. Extracts the audio duration + * + * @param path Path to the audio file (content URI format) + * @return MediaFormat of the audio track, or null if no audio track is found + */ + private fun getFormat(path: String): MediaFormat? { + if (path.isEmpty()) { + return null + } + val mediaExtractor = MediaExtractor() + this.extractor = mediaExtractor + val uri = path.toUri() + mediaExtractor.setDataSource(context, uri, null) + val trackCount = mediaExtractor.trackCount + repeat(trackCount) { + val format = mediaExtractor.getTrackFormat(it) + val mime = format.getString(MediaFormat.KEY_MIME) ?: "" + if (mime.contains("audio")) { + durationMillis = format.getLong(MediaFormat.KEY_DURATION) / 1000 + mediaExtractor.selectTrack(it) + return format + } + } + return null + } + + /** + * Starts the decoding and waveform extraction process + * + * This method initializes the MediaCodec decoder with appropriate + * callbacks to process audio frames. It handles: + * 1. Setting up the decoder with the proper format + * 2. Processing input buffers from the MediaExtractor + * 3. Processing decoded PCM audio data in output buffers + * 4. Calculating RMS values for waveform visualization + * 5. Reporting progress via the callback interface and method channel + */ + fun startDecode() { + try { + val format = getFormat(path) ?: error("No audio format found") + val mime = format.getString(MediaFormat.KEY_MIME) ?: error("No MIME type found") + decoder = MediaCodec.createDecoderByType(mime).also { + it.configure(format, null, null, 0) + it.setCallback(object : MediaCodec.Callback() { + override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { + if (inputEof || index < 0) return + val extractor = extractor ?: return + codec.getInputBuffer(index)?.let { buf -> + val size = extractor.readSampleData(buf, 0) + val sampleTime = extractor.sampleTime + if (size > 0 && sampleTime >= 0) { + try { + codec.queueInputBuffer(index, 0, size, sampleTime, 0) + extractor.advance() + } catch (e: Exception) { + inputEof = true + result.error( + Constants.LOG_TAG, + e.message, + "Invalid input buffer." + ) + } + } else { + codec.queueInputBuffer( + index, + 0, + 0, + 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM + ) + inputEof = true + } + } + } + + override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { + sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) + channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) + pcmEncodingBit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (format.containsKey(MediaFormat.KEY_PCM_ENCODING)) { + when (format.getInteger(MediaFormat.KEY_PCM_ENCODING)) { + AudioFormat.ENCODING_PCM_16BIT -> 16 + AudioFormat.ENCODING_PCM_8BIT -> 8 + AudioFormat.ENCODING_PCM_FLOAT -> 32 + else -> 16 + } + } else { + 16 + } + } else { + 16 + } + totalSamples = (sampleRate.toLong() * durationMillis) / 1000 + perSamplePoints = totalSamples / expectedPoints + } + + override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { + if (!isReplySubmitted) { + result.error( + Constants.LOG_TAG, + e.message, + "An error is thrown while decoding the audio file" + ) + isReplySubmitted = true + finishCount.countDown() + } + } + + override fun onOutputBufferAvailable( + codec: MediaCodec, + index: Int, + info: MediaCodec.BufferInfo + ) { + if (index < 0 || decoder == null) return + + try { + if (info.size > 0) { + codec.getOutputBuffer(index)?.let { buf -> + try { + val size = info.size + // Set both position and limit to ensure buffer is accessible + buf.position(info.offset) + buf.limit(info.offset + info.size) + + when (pcmEncodingBit) { + 8 -> { + handle8bit(size, buf) + } + 16 -> { + handle16bit(size, buf) + } + 32 -> { + handle32bit(size, buf) + } + else -> { + Log.e(Constants.LOG_TAG, "Unsupported PCM encoding bit: $pcmEncodingBit") + } + } + } catch (e: Exception) { + Log.e(Constants.LOG_TAG, "Error processing output buffer: ${e.message}") + } + } + } + } finally { + // Always release the buffer, even if processing failed + try { + codec.releaseOutputBuffer(index, false) + } catch (e: IllegalStateException) { + Log.e(Constants.LOG_TAG, "Error releasing output buffer: ${e.message}") + } + } + + if (info.isEof()) { + updateProgress() + val rms = sqrt(sampleSum / perSamplePoints).toFloat() + sendProgress(rms) + stop() + } + } + + }) + it.start() + } + + } catch (e: Exception) { + if (!isReplySubmitted) { + result.error( + Constants.LOG_TAG, + e.message, + "An error is thrown before decoding the audio file" + ) + isReplySubmitted = true + } + } + + + } + + /** Collected waveform amplitude data points */ + var sampleData = ArrayList() + /** Count of samples processed for the current data point */ + private var sampleCount = 0L + /** Sum of squared sample values for RMS calculation */ + private var sampleSum = 0.0 + + /** + * Processes each audio sample and accumulates data for RMS calculation + * + * This method: + * 1. Accumulates squared sample values + * 2. When enough samples are collected for a data point, calculates the RMS + * 3. Updates progress and sends the new data point to Flutter + * + * @param value The normalized audio sample value (-1.0 to 1.0) + */ + private fun handleBufferDivision(value: Float) { + if (sampleCount == perSamplePoints) { + updateProgress() + + // Discard redundant values and release resources + if (progress > 1.0F) { + stop() + return + } + val rms = sqrt(sampleSum / perSamplePoints).toFloat() + sendProgress(rms) + } + + sampleCount++ + sampleSum += value.toDouble().pow(2.0) + } + + /** + * Processes 8-bit PCM audio data + * + * Reads 8-bit samples from the buffer, normalizes them to the range [-1.0, 1.0], + * and passes them to handleBufferDivision for RMS calculation. + * + * @param size Size of the buffer in bytes + * @param buf ByteBuffer containing the audio data + */ + private fun handle8bit(size: Int, buf: ByteBuffer) { + repeat(size / if (channels == 2) 2 else 1) { + val result = buf.get().toInt() / Constants.EIGHT_BITS + if (channels == 2) { + buf.get() + } + handleBufferDivision(result) + } + } + + /** + * Processes 16-bit PCM audio data + * + * Reads 16-bit samples from the buffer, normalizes them to the range [-1.0, 1.0], + * and passes them to handleBufferDivision for RMS calculation. + * + * @param size Size of the buffer in bytes + * @param buf ByteBuffer containing the audio data + */ + private fun handle16bit(size: Int, buf: ByteBuffer) { + repeat(size / if (channels == 2) 4 else 2) { + val first = buf.get().toInt() + val second = buf.get().toInt() shl 8 + val value = (first or second) / Constants.SIXTEEN_BITS + if (channels == 2) { + buf.get() + buf.get() + } + handleBufferDivision(value) + } + } + + /** + * Processes 32-bit PCM audio data + * + * Reads 32-bit samples from the buffer, normalizes them to the range [-1.0, 1.0], + * and passes them to handleBufferDivision for RMS calculation. + * + * @param size Size of the buffer in bytes + * @param buf ByteBuffer containing the audio data + */ + private fun handle32bit(size: Int, buf: ByteBuffer) { + repeat(size / if (channels == 2) 8 else 4) { + val first = buf.get().toLong() + val second = buf.get().toLong() shl 8 + val third = buf.get().toLong() shl 16 + val forth = buf.get().toLong() shl 24 + val value = (first or second or third or forth) / Constants.THIRTY_TWO_BITS + if (channels == 2) { + buf.get() + buf.get() + buf.get() + buf.get() + } + handleBufferDivision(value) + } + } + + /** + * Updates the extraction progress + * + * Increments the progress counter and calculates the overall + * extraction progress as a ratio of current to expected data points. + */ + private fun updateProgress() { + currentProgress++ + progress = currentProgress / expectedPoints + } + + /** + * Sends a new waveform data point and current progress to Flutter + * + * This method: + * 1. Adds the new RMS value to the waveform data + * 2. Reports the progress via the callback interface + * 3. Resets the sample counters for the next data point + * 4. Sends the current waveform data and progress to Flutter via the method channel + * + * @param rms The calculated RMS value for this data point + */ + private fun sendProgress(rms: Float) { + sampleData.add(rms) + extractorCallBack.onProgress(progress) + sampleCount = 0 + sampleSum = 0.0 + + val args: MutableMap = HashMap() + args[Constants.waveformData] = sampleData + args[Constants.progress] = progress + args[Constants.playerKey] = key + methodChannel.invokeMethod( + Constants.onCurrentExtractedWaveformData, + args + ) + } + + /** + * Stops the extraction process and releases resources + * + * This method: + * 1. Stops and releases the MediaCodec decoder + * 2. Releases the MediaExtractor + * 3. Signals completion via the countdown latch + */ + fun stop() { + decoder?.stop() + decoder?.release() + extractor?.release() + finishCount.countDown() + } +} + +/** + * Extension function to check if a buffer contains the end-of-stream flag + * + * @return true if this buffer marks the end of the stream, false otherwise + */ +fun MediaCodec.BufferInfo.isEof() = flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0 + +/** + * Callback interface for reporting waveform extraction progress + * + * Implementations of this interface receive progress updates during + * the waveform extraction process. + */ +interface ExtractorCallBack { + /** + * Called when extraction progress changes + * + * @param value Progress value from 0.0 to 1.0, where 1.0 indicates completion + */ + fun onProgress(value: Float) +} \ No newline at end of file diff --git a/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt new file mode 100644 index 0000000..182a7fa --- /dev/null +++ b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/encoders/CommonEncoder.kt @@ -0,0 +1,429 @@ +package com.simform.audio_waveforms.encoders + +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaCodec +import android.media.MediaFormat +import android.media.MediaMuxer +import android.os.Handler +import android.os.HandlerThread +import android.util.Log +import com.simform.audio_waveforms.Constants +import com.simform.audio_waveforms.Encoder +import com.simform.audio_waveforms.RecorderSettings +import com.simform.audio_waveforms.isEof +import io.flutter.plugin.common.MethodChannel +import java.io.FileOutputStream +import java.util.LinkedList + +/** + * CommonEncoder provides a unified interface for audio encoding functionality across + * multiple encoder types (AMR, AAC, etc.). It handles the complexities of: + * + * - Initializing the appropriate MediaCodec encoder based on the selected encoder type + * - Managing input and output buffer processing + * - Handling MediaMuxer operations when required (for container formats like MP4) + * - Writing encoded audio data to the output file + * - Adding format-specific headers like ADTS headers for AAC + * + * This class acts as a bridge between raw audio data captured from AudioRecord + * and the encoded output file ready for playback. + */ +class CommonEncoder { + /** MediaCodec instance for encoding audio data */ + private lateinit var mediaCodec: MediaCodec + + /** The selected encoder type (AAC_LC, AMR_NB, etc.) */ + private lateinit var encoder: Encoder + + /** Background thread for encoder operations */ + private lateinit var handlerThread: HandlerThread + + /** Handler for the encoder thread */ + private lateinit var handler: Handler + + /** Output stream for writing encoded audio data */ + private lateinit var outputStream: FileOutputStream + + /** Configuration for the recorder and encoder */ + private lateinit var recorderSettings: RecorderSettings + + /** MediaMuxer instance for container formats (optional) */ + private var mediaMuxer: MediaMuxer? = null + + /** Queue for audio data waiting to be encoded */ + private val inputQueue = LinkedList() + + /** Current available input buffer index (-1 if none available) */ + private var currentInputBufferIndex = -1 + + /** Flag indicating if the muxer has been started */ + private var isMuxerStarted = false + + /** Flag indicating encoding process should complete */ + private var isEncodingComplete = false + + /** Flag indicating encoder has been stopped */ + private var isEncoderStopped = false + + /** Track index for the audio track in the muxer */ + private var trackIndex = -1 + + /** Callback to invoke when encoding is complete */ + private var completionCallback: (() -> Unit)? = null + + /** Total bytes encoded so far, used for calculating presentation timestamps */ + private var totalBytesEncoded = 0L + + /** Track the first output timestamp to normalize subsequent timestamps */ + private var firstOutputTimestamp = -1L + + /** Track the last valid output timestamp to ensure monotonic increasing */ + private var lastOutputTimestamp = 0L + + /** + * Initializes the MediaCodec encoder with the specified settings + * + * @param recorderSettings The configuration for the recorder and encoder + * @param result The Flutter method channel result to report initialization status + * @param onEncodingCompleted Optional callback to invoke when encoding is completed + */ + fun initCodec( + recorderSettings: RecorderSettings, + result: MethodChannel.Result, + onEncodingCompleted: (() -> Unit)? = null, + ) { + // Reset state at the beginning + isMuxerStarted = false + trackIndex = -1 + isEncodingComplete = false + isEncoderStopped = false + inputQueue.clear() + currentInputBufferIndex = -1 + totalBytesEncoded = 0L + firstOutputTimestamp = -1L + lastOutputTimestamp = 0L + + this.recorderSettings = recorderSettings + encoder = recorderSettings.encoder + completionCallback = onEncodingCompleted + var useMediaMuxer = encoder.useMediaMuxer + var format: MediaFormat + try { + outputStream = FileOutputStream(recorderSettings.path!!) + mediaCodec = MediaCodec.createEncoderByType(encoder.mimeType) + + format = MediaFormat.createAudioFormat(encoder.mimeType, recorderSettings.sampleRate, 1) + + + recorderSettings.bitRate.let { + format.setInteger(MediaFormat.KEY_BIT_RATE, it) + } + encoder.aacProfile?.let { + format.setInteger( + MediaFormat.KEY_AAC_PROFILE, it + ) + } + format.setInteger( + MediaFormat.KEY_MAX_INPUT_SIZE, AudioRecord.getMinBufferSize( + recorderSettings.sampleRate, Constants.CHANNEL, AudioFormat.ENCODING_PCM_16BIT + ) + ) + + handlerThread = HandlerThread(Constants.ENCODER_THREAD) + handlerThread.start() + handler = Handler(handlerThread.looper) + + if (encoder == Encoder.AMR_NB) { + outputStream.write("#!AMR\n".toByteArray()) + } else if (encoder == Encoder.AMR_WB) { + outputStream.write("#!AMR-WB\n".toByteArray()) + } + + if (useMediaMuxer) { + mediaMuxer = MediaMuxer(recorderSettings.path!!, encoder.toMuxerOutputFormat) + } + } catch (e: Exception) { + result.error( + Constants.LOG_TAG, "Error initializing encoder: ${e.message}", null + ) + return + } + + mediaCodec.setCallback(object : MediaCodec.Callback() { + override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { + if (isEncodingComplete && inputQueue.isEmpty()) { + // Use the last calculated presentation time for EOF, not system time + val eofTimestamp = if (totalBytesEncoded > 0) { + val bytesPerSample = 2L + val channels = 1L + (totalBytesEncoded * 1_000_000L) / (recorderSettings.sampleRate * channels * bytesPerSample) + } else { + 0L + } + codec.queueInputBuffer( + index, 0, 0, eofTimestamp, MediaCodec.BUFFER_FLAG_END_OF_STREAM + ) + } else { + currentInputBufferIndex = index + feedEncoder() + } + } + + override fun onOutputBufferAvailable( + codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo + ) { + val buffer = codec.getOutputBuffer(index) ?: return + + if (info.isEof()) { + codec.releaseOutputBuffer(index, false) + stopEncoder() + return + } + + // Normalize output timestamps to start from 0 + if (firstOutputTimestamp == -1L && info.presentationTimeUs > 0) { + firstOutputTimestamp = info.presentationTimeUs + } + + // Calculate normalized timestamp (starting from 0) + val normalizedTimestamp = if (firstOutputTimestamp >= 0) { + maxOf(info.presentationTimeUs - firstOutputTimestamp, lastOutputTimestamp) + } else { + info.presentationTimeUs + } + + lastOutputTimestamp = normalizedTimestamp + + if (useMediaMuxer) { + if (!isMuxerStarted) { + trackIndex = mediaMuxer?.addTrack(mediaCodec.outputFormat) ?: -1 + mediaMuxer?.start() + isMuxerStarted = true + } + + // Ensure buffer is positioned correctly for MediaMuxer + buffer.position(info.offset) + buffer.limit(info.offset + info.size) + + // Create a new BufferInfo with normalized timestamp to avoid modifying the original + val normalizedInfo = MediaCodec.BufferInfo() + normalizedInfo.set(info.offset, info.size, normalizedTimestamp, info.flags) + + // Write sample data with normalized timestamps + mediaMuxer?.writeSampleData(trackIndex, buffer, normalizedInfo) + } else { + // For raw output (AAC/AMR without container), extract the encoded data + buffer.position(info.offset) + buffer.limit(info.offset + info.size) + val encodedData = ByteArray(info.size) + buffer.get(encodedData) + + addADTSIfAAC() + outputStream.write(encodedData) + } + + codec.releaseOutputBuffer(index, false) + } + + override fun onError( + codec: MediaCodec, e: MediaCodec.CodecException + ) { + Log.e( + Constants.LOG_TAG, "Error while encoding: ${e.message}" + ) + stopEncoder() + } + + override fun onOutputFormatChanged( + codec: MediaCodec, format: MediaFormat + ) { + if ((useMediaMuxer) && !isMuxerStarted) { + trackIndex = mediaMuxer?.addTrack(format) ?: -1 + mediaMuxer?.start() + isMuxerStarted = true + } + } + }) + + mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + mediaCodec.start() + } + + /** + * Queues audio data for encoding + * + * This method safely adds audio data to the input queue and attempts to feed + * it to the encoder if an input buffer is available. Thread-safe. + * + * @param buffer The raw audio data to encode + */ + fun queueInputBuffer(buffer: ByteArray) { + synchronized(inputQueue) { + inputQueue.add(buffer) + } + + if (currentInputBufferIndex >= 0) { + feedEncoder() + } + } + + /** + * Signals that no more audio data will be provided + * + * This method marks the encoding process as complete. Once all queued data + * is processed, the encoder will be sent an end-of-stream signal. + */ + fun signalToStop() { + isEncodingComplete = true + } + + /** + * Sets the callback to be invoked when encoding is completed + * + * @param callback The function to call when encoding completes + */ + fun setOnEncodingCompleted(callback: () -> Unit) { + completionCallback = callback + } + + + /** + * Feeds available audio data to the encoder + * + * This method is called when both audio data is available in the queue + * and an input buffer is available from the encoder. It's synchronized + * to ensure thread safety when accessing the input queue. + * + * The presentation timestamp is calculated based on the amount of audio data + * encoded so far, starting from 0. This ensures monotonic timestamps that + * represent the actual audio timeline for proper playback and looping. + */ + private fun feedEncoder() { + synchronized(inputQueue) { + if (inputQueue.isEmpty() || currentInputBufferIndex < 0) return + + val data = inputQueue.poll() ?: return + val inputBuffer = mediaCodec.getInputBuffer(currentInputBufferIndex) ?: return + inputBuffer.clear() + inputBuffer.put(data) + + // Calculate presentation time based on actual audio data encoded + // Formula: presentationTimeUs = (totalBytes * 1,000,000) / (sampleRate * channels * bytesPerSample) + // For 16-bit PCM mono: bytesPerSample = 2, channels = 1 + val bytesPerSample = 2L // 16-bit = 2 bytes + val channels = 1L // Mono + val presentationTimeUs = (totalBytesEncoded * 1_000_000L) / (recorderSettings.sampleRate * channels * bytesPerSample) + totalBytesEncoded += data.size + + mediaCodec.queueInputBuffer( + currentInputBufferIndex, 0, data.size, presentationTimeUs, 0 + ) + currentInputBufferIndex = -1 + } + } + + /** + * Adds ADTS (Audio Data Transport Stream) header for AAC audio files + * + * AAC raw frames require an ADTS header to be properly recognized by players. + * This method adds the header when necessary based on the encoder type and file extension. + */ + private fun addADTSIfAAC() { + if ((encoder.isAAC && recorderSettings.path!!.endsWith( + Constants.AAC_FILE_EXTENSION + )) + ) { + outputStream.write(addADTSPacket(encoder.bufferSize, recorderSettings.sampleRate)) + } + } + + /** + * Creates an ADTS header packet for AAC audio data + * + * @param dataLength The length of the AAC frame data + * @param sampleRate The sample rate of the audio in Hz + * @param channelConfig The channel configuration (mono/stereo) + * @return The ADTS header as a byte array + */ + private fun addADTSPacket( + dataLength: Int, sampleRate: Int, channelConfig: Int = Constants.CHANNEL + ): ByteArray { + val packet = ByteArray(7) + val profile = when (encoder) { + Encoder.AAC_LC -> 2 + Encoder.AAC_HE -> 5 + Encoder.AAC_ELD -> 39 + else -> 2 + } + val frameLength = dataLength + 7 + + val freqIdx = when (sampleRate) { + 96000 -> 0 + 88200 -> 1 + 64000 -> 2 + 48000 -> 3 + 44100 -> 4 + 32000 -> 5 + 24000 -> 6 + 22050 -> 7 + 16000 -> 8 + 12000 -> 9 + 11025 -> 10 + 8000 -> 11 + 7350 -> 12 + else -> 4 + } + + packet[0] = 0xFF.toByte() + packet[1] = 0xF9.toByte() + packet[2] = ((profile - 1 shl 6) + (freqIdx shl 2) + (channelConfig shr 2)).toByte() + packet[3] = ((channelConfig and 3 shl 6) + (frameLength shr 11)).toByte() + packet[4] = (frameLength and 0x7FF shr 3).toByte() + packet[5] = ((frameLength and 7 shl 5) + 0x1F).toByte() + packet[6] = 0xFC.toByte() + + return packet + } + + /** + * Stops the encoder and releases all resources + * + * This method is called when encoding is complete or when an error occurs. + * It: + * 1. Stops and releases the MediaCodec + * 2. Stops and releases the MediaMuxer (if used) + * 3. Closes the output file stream + * 4. Shuts down the handler thread + * 5. Calls the completion callback + * + * This method is designed to be idempotent (can be called multiple times safely). + */ + private fun stopEncoder() { + if (isEncoderStopped) return + isEncoderStopped = true + + try { + mediaCodec.stop() + mediaCodec.release() + mediaMuxer?.stop() + mediaMuxer?.release() + outputStream.close() + handlerThread.quitSafely() + handlerThread.join() + completionCallback?.invoke() + } catch (e: Exception) { + Log.e(Constants.LOG_TAG, "Error stopping encoder: ${e.message}") + } + + // Reset state for next recording + isMuxerStarted = false + trackIndex = -1 + isEncodingComplete = false + inputQueue.clear() + currentInputBufferIndex = -1 + totalBytesEncoded = 0L + firstOutputTimestamp = -1L + lastOutputTimestamp = 0L + } +} \ No newline at end of file diff --git a/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/encoders/WavEncoder.kt b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/encoders/WavEncoder.kt new file mode 100644 index 0000000..ae56d06 --- /dev/null +++ b/audio_waveforms/android/src/main/kotlin/com/simform/audio_waveforms/encoders/WavEncoder.kt @@ -0,0 +1,168 @@ +package com.simform.audio_waveforms.encoders + +import com.simform.audio_waveforms.Constants +import io.flutter.plugin.common.MethodChannel +import java.io.File +import java.io.FileOutputStream +import java.io.RandomAccessFile + +/** + * WavEncoder handles the creation of WAV audio files from raw PCM data. + * + * This class manages the process of writing PCM audio data to a file with the proper + * WAV file format headers. WAV is an uncompressed audio format that preserves the full + * quality of the recorded audio but results in larger file sizes compared to + * compressed formats like AAC or MP3. + * + * The encoder works in three main steps: + * 1. Start - Creates the file and writes a placeholder header + * 2. Write - Appends PCM audio data to the file + * 3. Stop - Updates the header with the final file size and audio parameters + */ +class WavEncoder( + /** Target file where the WAV audio will be written */ + private val wavFile: File, + + /** Sample rate of the audio in Hz (e.g., 44100, 48000) */ + private val sampleRate: Int +) { + /** Stream for writing data to the WAV file */ + private lateinit var outputStream: FileOutputStream + + /** Running count of audio data length in bytes */ + private var totalAudioLen = 0L + + /** Flag indicating if the encoder is currently active */ + private var isWriting = false + + /** + * Starts the WAV encoding process + * + * Creates a new file with a placeholder WAV header. The header will be + * updated with the correct values when [stop] is called. + * + * @param result Flutter result callback to report success or errors + */ + fun start(result: MethodChannel.Result) { + isWriting = true + try { + outputStream = FileOutputStream(wavFile) + // These are placeholder bytes for the header which will be updated later. + val header = ByteArray(44) + outputStream.write(header) + } catch (e: Exception) { + result.error( + "WAV_FILE_WRITE_ERROR", + "Error writing to WAV file: ${e.message}", + null + ) + } + } + + /** + * Writes PCM audio data to the WAV file + * + * Appends raw audio samples to the file and updates the total audio length counter. + * This method should be called for each chunk of audio data received from the recorder. + * + * @param pcmData Array of PCM audio samples to write to the file + */ + fun writePcmData(pcmData: ByteArray) { + if (!isWriting) return + outputStream.write(pcmData) + totalAudioLen += pcmData.size + } + + /** + * Stops the encoding process and finalizes the WAV file + * + * This method: + * 1. Marks the encoder as no longer writing + * 2. Closes the output stream + * 3. Updates the WAV header with the final file size and audio parameters + * + * @param result Flutter result callback to report success or errors + */ + fun stop(result: MethodChannel.Result) { + try { + isWriting = false + outputStream.close() + updateWavHeader() + } catch (e: Exception) { + result.error( + "WAV_FILE_CLOSE_ERROR", + "Error closing WAV file: ${e.message}", + null + ) + } + } + + /** + * Updates the WAV file header with the final audio parameters + * + * The WAV format requires a specific header structure with information about: + * - File format (RIFF/WAVE) + * - Audio format (PCM) + * - Number of channels + * - Sample rate + * - Bit depth + * - Total data size + * + * This method writes all these parameters to the beginning of the file after + * all audio data has been written and the total size is known. + */ + private fun updateWavHeader() { + val totalDataLen = totalAudioLen + 36 + val byteRate = sampleRate * Constants.CHANNEL * Constants.BIT_PER_SAMPLE / 8 + val header = ByteArray(44) + + /** + * Writes a 32-bit integer to the header in little-endian format + * + * @param offset Starting position in the header array + * @param value Integer value to write + */ + fun writeInt(offset: Int, value: Int) { + header[offset] = (value and 0xff).toByte() + header[offset + 1] = ((value shr 8) and 0xff).toByte() + header[offset + 2] = ((value shr 16) and 0xff).toByte() + header[offset + 3] = ((value shr 24) and 0xff).toByte() + } + + /** + * Writes a 16-bit short to the header in little-endian format + * + * @param offset Starting position in the header array + * @param value Short value to write + */ + fun writeShort(offset: Int, value: Short) { + header[offset] = (value.toInt() and 0xff).toByte() + header[offset + 1] = ((value.toInt() shr 8) and 0xff).toByte() + } + + // RIFF chunk descriptor + "RIFF".toByteArray().copyInto(header, 0) // ChunkID: "RIFF" in ASCII + writeInt(4, totalDataLen.toInt()) // ChunkSize: total size minus 8 bytes + "WAVE".toByteArray().copyInto(header, 8) // Format: "WAVE" in ASCII + + // "fmt " sub-chunk (format information) + "fmt ".toByteArray().copyInto(header, 12) // Subchunk1ID: "fmt " in ASCII + writeInt(16, 16) // Subchunk1Size: 16 for PCM format + writeShort(20, 1) // AudioFormat: 1 for PCM (uncompressed) + writeShort(22, Constants.CHANNEL.toShort()) // NumChannels: Mono=1, Stereo=2 + writeInt(24, sampleRate) // SampleRate: samples per second + writeInt(28, byteRate) // ByteRate: bytes per second + writeShort(32, (Constants.CHANNEL * Constants.BIT_PER_SAMPLE / 8).toShort()) // BlockAlign + writeShort(34, Constants.BIT_PER_SAMPLE.toShort()) // BitsPerSample: 8, 16, etc. + + // "data" sub-chunk (the actual sound data) + "data".toByteArray().copyInto(header, 36) // Subchunk2ID: "data" in ASCII + writeInt(40, totalAudioLen.toInt()) // Subchunk2Size: size of the audio data + + // Write the completed header back to the beginning of the file + val raf = RandomAccessFile(wavFile, "rw") + raf.seek(0) + raf.write(header) + raf.close() + } +} \ No newline at end of file diff --git a/audio_waveforms/ios/.gitignore b/audio_waveforms/ios/.gitignore new file mode 100644 index 0000000..0c88507 --- /dev/null +++ b/audio_waveforms/ios/.gitignore @@ -0,0 +1,38 @@ +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +.DS_Store +*.swp +profile + +DerivedData/ +build/ +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +/Flutter/Generated.xcconfig +/Flutter/ephemeral/ +/Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/exif/test/data/test-data b/audio_waveforms/ios/Assets/.gitkeep similarity index 100% rename from exif/test/data/test-data rename to audio_waveforms/ios/Assets/.gitkeep diff --git a/audio_waveforms/ios/Classes/AudioPlayer.swift b/audio_waveforms/ios/Classes/AudioPlayer.swift new file mode 100644 index 0000000..f886574 --- /dev/null +++ b/audio_waveforms/ios/Classes/AudioPlayer.swift @@ -0,0 +1,174 @@ +import Foundation + +import AVKit + +class AudioPlayer: NSObject, AVAudioPlayerDelegate { + private var seekToStart = true + private var stopWhenCompleted = false + private var timer: Timer? + private var player: AVAudioPlayer? + private var finishMode:FinishMode = FinishMode.stop + private var updateFrequency = 200 + var plugin: SwiftAudioWaveformsPlugin + var playerKey: String + var flutterChannel: FlutterMethodChannel + + + init(plugin: SwiftAudioWaveformsPlugin, playerKey: String, channel: FlutterMethodChannel) { + self.plugin = plugin + self.playerKey = playerKey + flutterChannel = channel + } + + func preparePlayer(path: String?, volume: Double?, updateFrequency: Int?,result: @escaping FlutterResult, overrideAudioSession : Bool) { + if(!(path ?? "").isEmpty) { + self.updateFrequency = updateFrequency ?? 200 + let audioUrl = URL.init(string: path!) + if(audioUrl == nil){ + result(FlutterError(code: Constants.audioWaveforms, message: "Failed to initialise Url from provided audio file", details: "If path contains `file://` try removing it")) + return + } + do { + stopPlayer() + player = nil + player = try AVAudioPlayer(contentsOf: audioUrl!) + do { + if overrideAudioSession { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + } + } catch { + result(FlutterError(code: Constants.audioWaveforms, message: "Couldn't set audio session.", details: error.localizedDescription)) + return + } + + } catch { + result(FlutterError(code: Constants.audioWaveforms, message: "Failed to prepare player", details: error.localizedDescription)) + return + } + player?.enableRate = true + player?.rate = 1.0 + player?.prepareToPlay() + player?.volume = Float(volume ?? 1.0) + result(true) + } else { + result(FlutterError(code: Constants.audioWaveforms, message: "Audio file path can't be empty or null", details: nil)) + } + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer,successfully flag: Bool) { + var finishType = 2 + + switch self.finishMode{ + + case .loop: + self.player?.currentTime = 0 + self.player?.play() + finishType = 0 + + case .pause: + self.player?.pause() + stopListening() + finishType = 1 + + case .stop: + self.player?.stop() + stopListening() + self.player = nil + finishType = 2 + + + } + + plugin.flutterChannel.invokeMethod(Constants.onDidFinishPlayingAudio, arguments: [ + Constants.finishType: finishType, + Constants.playerKey: playerKey]) + + } + + func startPlyer(result: @escaping FlutterResult) { + player?.play() + player?.delegate = self + startListening() + result(true) + } + + + func pausePlayer() { + stopListening() + player?.pause() + } + + func stopPlayer() { + stopListening() + player?.stop() + timer = nil + } + + func release(result: @escaping FlutterResult) { + player = nil + result(true) + } + + func getDuration(_ type: DurationType, _ result: @escaping FlutterResult) throws { + if type == .Current { + let ms = (player?.currentTime ?? 0) * 1000 + result(Int(ms)) + } else { + let ms = (player?.duration ?? 0) * 1000 + result(Int(ms)) + } + } + + func setVolume(_ volume: Double?, _ result: @escaping FlutterResult) { + player?.volume = Float(volume ?? 1.0) + result(true) + } + + func setRate(_ rate: Double?, _ result: @escaping FlutterResult) { + player?.rate = Float(rate ?? 1.0); + result(true) + } + + func seekTo(_ time: Int?, _ result: @escaping FlutterResult) { + if(time != nil) { + player?.currentTime = Double(time! / 1000) + sendCurrentDuration() + result(true) + } else { + result(false) + } + } + + func setFinishMode(result : @escaping FlutterResult, releaseType : Int?){ + if(releaseType != nil && releaseType == 0){ + self.finishMode = FinishMode.loop + }else if(releaseType != nil && releaseType == 1){ + self.finishMode = FinishMode.pause + }else{ + self.finishMode = FinishMode.stop + } + result(nil) + } + + func startListening() { + if #available(iOS 10.0, *) { + timer = Timer.scheduledTimer(withTimeInterval: (Double(updateFrequency) / 1000), repeats: true, block: { _ in + self.sendCurrentDuration() + }) + } else { + // Fallback on earlier versions + } + } + + func stopListening() { + timer?.invalidate() + timer = nil + sendCurrentDuration() + } + + func sendCurrentDuration() { + let ms = (player?.currentTime ?? 0) * 1000 + flutterChannel.invokeMethod(Constants.onCurrentDuration, arguments: [Constants.current: Int(ms), Constants.playerKey: playerKey]) + } +} diff --git a/audio_waveforms/ios/Classes/AudioRecorder.swift b/audio_waveforms/ios/Classes/AudioRecorder.swift new file mode 100644 index 0000000..64f2884 --- /dev/null +++ b/audio_waveforms/ios/Classes/AudioRecorder.swift @@ -0,0 +1,194 @@ +import AVFoundation +import Accelerate + +public class AudioRecorder: NSObject, AVAudioRecorderDelegate{ + var audioRecorder: AVAudioRecorder? + var path: String? + var useLegacyNormalization: Bool = false + var audioUrl: URL? + var recordedDuration: CMTime = CMTime.zero + var flutterChannel: FlutterMethodChannel + var bytesStreamEngine: RecorderBytesStreamEngine + init(channel: FlutterMethodChannel){ + flutterChannel = channel + bytesStreamEngine = RecorderBytesStreamEngine(channel: channel) + } + + func startRecording(_ result: @escaping FlutterResult,_ recordingSettings: RecordingSettings){ + useLegacyNormalization = recordingSettings.useLegacy ?? false + + var settings: [String: Any] = [ + AVFormatIDKey: getEncoder(recordingSettings.encoder ?? 0), + AVSampleRateKey: recordingSettings.sampleRate ?? 44100, + AVNumberOfChannelsKey: 1, + AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue + ] + + if (recordingSettings.bitRate != nil) { + settings[AVEncoderBitRateKey] = recordingSettings.bitRate + } + + if ((recordingSettings.encoder ?? 0) == Constants.kAudioFormatLinearPCM) { + settings[AVLinearPCMBitDepthKey] = recordingSettings.linearPCMBitDepth + settings[AVLinearPCMIsBigEndianKey] = recordingSettings.linearPCMIsBigEndian + settings[AVLinearPCMIsFloatKey] = recordingSettings.linearPCMIsFloat + } + + let options: AVAudioSession.CategoryOptions = [.defaultToSpeaker, .allowBluetooth] + if (recordingSettings.path == nil) { + let documentDirectory = getDocumentDirectory(result) + let date = Date() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = recordingSettings.fileNameFormat + let fileName = dateFormatter.string(from: date) + ".m4a" + self.path = "\(documentDirectory)/\(fileName)" + } else { + self.path = recordingSettings.path + } + + + do { + if recordingSettings.overrideAudioSession { + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, options: options) + try AVAudioSession.sharedInstance().setActive(true) + } + audioUrl = URL(fileURLWithPath: self.path!) + + if(audioUrl == nil){ + result(FlutterError(code: Constants.audioWaveforms, message: "Failed to initialise file URL", details: nil)) + return + } + audioRecorder = try AVAudioRecorder(url: audioUrl!, settings: settings as [String : Any]) + + audioRecorder?.delegate = self + audioRecorder?.isMeteringEnabled = true + audioRecorder?.record() + bytesStreamEngine + .attach( + result: result, + sampleRate: recordingSettings.sampleRate ?? Constants.defaultSampleRate + ) + result(true) + } catch { + result(FlutterError(code: Constants.audioWaveforms, message: "Failed to start recording", details: error.localizedDescription)) + } + } + + public func stopRecording(_ result: @escaping FlutterResult) { + audioRecorder?.stop() + bytesStreamEngine.detach() + if(audioUrl != nil) { + let asset = AVURLAsset(url: audioUrl!) + + if #available(iOS 15.0, *) { + Task { + do { + recordedDuration = try await asset.load(.duration) + sendResult(result, duration: Int(recordedDuration.seconds * 1000)) + } catch let err { + debugPrint(err.localizedDescription) + sendResult(result, duration: Int(CMTime.zero.seconds)) + } + } + } else { + recordedDuration = asset.duration + sendResult(result, duration: Int(recordedDuration.seconds * 1000)) + } + } else { + sendResult(result, duration: Int(CMTime.zero.seconds)) + } + audioRecorder = nil + } + + private func sendResult(_ result: @escaping FlutterResult, duration:Int){ + var params = [String:Any?]() + params[Constants.resultFilePath] = path + params[Constants.resultDuration] = duration + result(params) + } + + public func pauseRecording(_ result: @escaping FlutterResult) { + audioRecorder?.pause() + bytesStreamEngine.togglePause() + result(false) + } + + public func resumeRecording(_ result: @escaping FlutterResult) { + audioRecorder?.record() + bytesStreamEngine.togglePause(); + result(true) + } + + public func getDecibel(_ result: @escaping FlutterResult) { + audioRecorder?.updateMeters() + if(useLegacyNormalization){ + let amp = audioRecorder?.averagePower(forChannel: 0) ?? 0.0 + result(amp) + } else { + let amp = audioRecorder?.peakPower(forChannel: 0) ?? 0.0 + let linear = pow(10, amp / 20); + result(linear) + } + } + + public func checkHasPermission(_ result: @escaping FlutterResult){ + switch AVAudioSession.sharedInstance().recordPermission{ + + case .undetermined: + AVAudioSession.sharedInstance().requestRecordPermission() { [unowned self] allowed in + DispatchQueue.main.async { + result(allowed) + } + } + case .denied: + result(false) + case .granted: + result(true) + @unknown default: + result(false) + } + } + public func getEncoder(_ enCoder: Int) -> Int { + switch(enCoder) { + case Constants.kAudioFormatMPEG4AAC: + return Int(kAudioFormatMPEG4AAC) + case Constants.kAudioFormatMPEGLayer1: + return Int(kAudioFormatMPEGLayer1) + case Constants.kAudioFormatMPEGLayer2: + return Int(kAudioFormatMPEGLayer2) + case Constants.kAudioFormatMPEGLayer3: + return Int(kAudioFormatMPEGLayer3) + case Constants.kAudioFormatMPEG4AAC_ELD: + return Int(kAudioFormatMPEG4AAC_ELD) + case Constants.kAudioFormatMPEG4AAC_HE: + return Int(kAudioFormatMPEG4AAC_HE) + case Constants.kAudioFormatOpus: + return Int(kAudioFormatOpus) + case Constants.kAudioFormatAMR: + return Int(kAudioFormatAMR) + case Constants.kAudioFormatAMR_WB: + return Int(kAudioFormatAMR_WB) + case Constants.kAudioFormatLinearPCM: + return Int(kAudioFormatLinearPCM) + case Constants.kAudioFormatAppleLossless: + return Int(kAudioFormatAppleLossless) + case Constants.kAudioFormatMPEG4AAC_HE_V2: + return Int(kAudioFormatMPEG4AAC_HE_V2) + default: + return Int(kAudioFormatMPEG4AAC) + } + } + + private func getDocumentDirectory(_ result: @escaping FlutterResult) -> String { + let directory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] + let ifExists = FileManager.default.fileExists(atPath: directory) + if(directory.isEmpty){ + result(FlutterError(code: Constants.audioWaveforms, message: "The document directory path is empty", details: nil)) + return "" + } else if(!ifExists) { + result(FlutterError(code: Constants.audioWaveforms, message: "The document directory does't exists", details: nil)) + return "" + } + return directory + } +} diff --git a/audio_waveforms/ios/Classes/AudioWaveformsPlugin.h b/audio_waveforms/ios/Classes/AudioWaveformsPlugin.h new file mode 100644 index 0000000..5946602 --- /dev/null +++ b/audio_waveforms/ios/Classes/AudioWaveformsPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface AudioWaveformsPlugin : NSObject +@end diff --git a/audio_waveforms/ios/Classes/AudioWaveformsPlugin.m b/audio_waveforms/ios/Classes/AudioWaveformsPlugin.m new file mode 100644 index 0000000..92a1dc9 --- /dev/null +++ b/audio_waveforms/ios/Classes/AudioWaveformsPlugin.m @@ -0,0 +1,15 @@ +#import "AudioWaveformsPlugin.h" +#if __has_include() +#import +#else +// Support project import fallback if the generated compatibility header +// is not copied when this plugin is created as a library. +// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 +#import "audio_waveforms-Swift.h" +#endif + +@implementation AudioWaveformsPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftAudioWaveformsPlugin registerWithRegistrar:registrar]; +} +@end diff --git a/audio_waveforms/ios/Classes/RecorderBytesStreamEngine.swift b/audio_waveforms/ios/Classes/RecorderBytesStreamEngine.swift new file mode 100644 index 0000000..68841c7 --- /dev/null +++ b/audio_waveforms/ios/Classes/RecorderBytesStreamEngine.swift @@ -0,0 +1,98 @@ +// +// RecorderBytesStreamHandler.swift +// audio_waveforms +// +// Created by Ujas Majithiya on 10/04/25. +// + +import Foundation +import AVFAudio +import Accelerate + +class RecorderBytesStreamEngine { + private var audioEngine = AVAudioEngine() + private var flutterChannel: FlutterMethodChannel + private var paused: Bool = false + private var totalFrames: AVAudioFramePosition = 0 + + init(channel: FlutterMethodChannel) { + flutterChannel = channel + } + + func attach(result: @escaping FlutterResult, sampleRate: Int) { + let inputNode = audioEngine.inputNode + inputNode.installTap(onBus: 0, bufferSize: 1024, format: nil) { (buffer, time) in + if self.paused { + return + } + if let (convertedBytes, normalizedRms) = self.convertToFlutterType(buffer) { + self.totalFrames += AVAudioFramePosition(buffer.frameLength) + let effectiveSampleRate = buffer.format.sampleRate > 0 ? buffer.format.sampleRate : Double(sampleRate) + let duration = Double(self.totalFrames) / effectiveSampleRate + let milliseconds = Int(duration * 1000) + self.sendToFlutter( + convertedBytes, + normalizedRms: normalizedRms, + milliSeconds: milliseconds + ) + } + } + do { + try audioEngine.start() + } catch { + result(FlutterError(code: Constants.audioWaveforms, message: "Error starting Audio Engine", details: error.localizedDescription)) + } + } + + func togglePause() { + paused = !paused + } + + func detach() { + totalFrames = 0 + audioEngine.inputNode.removeTap(onBus: 0) + audioEngine.stop() + } + + private func convertToFlutterType(_ buffer: AVAudioPCMBuffer) -> (FlutterStandardTypedData, Double)? { + guard let channelData = buffer.floatChannelData?[0] else { return nil } + let frameLength = Int(buffer.frameLength) + + // Convert Float32 buffer to UInt8 (byte array) + var audioSamples = [Float32](repeating: 0.0, count: frameLength) + var sumOfSquares: Float = 0.0 + + for i in 0.. RecordingSettings { + let path = json[Constants.path] as? String + let encoder = json[Constants.encoder] as? Int + let sampleRate = json[Constants.sampleRate] as? Int + let bitRate = json[Constants.bitRate] as? Int + let fileNameFormat = Constants.fileNameFormat + let useLegacy = json[Constants.useLegacyNormalization] as? Bool + let overrideAudioSession = json[Constants.overrideAudioSession] as? Bool ?? true + let linearPCMBitDepth = json[Constants.linearPCMBitDepth] as? Int ?? 16 + let linearPCMIsBigEndian = json[Constants.linearPCMIsBigEndian] as? Bool ?? false + let linearPCMIsFloat = json[Constants.linearPCMIsFloat] as? Bool ?? false + + return RecordingSettings( + path: path, + encoder: encoder, + sampleRate: sampleRate, + bitRate: bitRate, + fileNameFormat: fileNameFormat, + useLegacy: useLegacy, + overrideAudioSession: overrideAudioSession, + linearPCMBitDepth: linearPCMBitDepth, + linearPCMIsBigEndian: linearPCMIsBigEndian, + linearPCMIsFloat: linearPCMIsFloat + ) + } +} diff --git a/audio_waveforms/ios/Classes/SwiftAudioWaveformsPlugin.swift b/audio_waveforms/ios/Classes/SwiftAudioWaveformsPlugin.swift new file mode 100644 index 0000000..ddcf70f --- /dev/null +++ b/audio_waveforms/ios/Classes/SwiftAudioWaveformsPlugin.swift @@ -0,0 +1,219 @@ +import Flutter +import UIKit + +public class SwiftAudioWaveformsPlugin: NSObject, FlutterPlugin { + + let audioRecorder: AudioRecorder + var audioPlayers = [String: AudioPlayer]() + var extractors = [String: WaveformExtractor]() + var flutterChannel: FlutterMethodChannel + + init(registrar: FlutterPluginRegistrar, flutterChannel: FlutterMethodChannel) { + self.flutterChannel = flutterChannel + audioRecorder = AudioRecorder(channel: flutterChannel) + super.init() + } + + deinit { + audioPlayers.removeAll() + extractors.removeAll() + } + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: Constants.methodChannelName, binaryMessenger: registrar.messenger()) + let instance = SwiftAudioWaveformsPlugin(registrar: registrar, flutterChannel: channel) + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? Dictionary + switch call.method { + case Constants.startRecording: + guard let args = call.arguments as? Dictionary else { + result(FlutterError(code: Constants.audioWaveforms, message: "Invalid Arguments", details: nil)) + return + } + audioRecorder.startRecording(result, RecordingSettings.fromJson((args))) + break + case Constants.pauseRecording: + audioRecorder.pauseRecording(result) + break + case Constants.resumeRecording: + audioRecorder.resumeRecording(result) + case Constants.stopRecording: + audioRecorder.stopRecording(result) + break + case Constants.getDecibel: + audioRecorder.getDecibel(result) + break + case Constants.checkPermission: + audioRecorder.checkHasPermission(result) + break + case Constants.preparePlayer: + let key = args?[Constants.playerKey] as? String + if(key != nil){ + initPlayer(playerKey: key!) + audioPlayers[key!]?.preparePlayer(path: args?[Constants.path] as? String, + volume: args?[Constants.volume] as? Double, + updateFrequency: args?[Constants.updateFrequency] as? Int, + result: result, + overrideAudioSession: (args?[Constants.overrideAudioSession] as? Bool) ?? false) + } else { + result(FlutterError(code: Constants.audioWaveforms, message: "Can not prepare player", details: "Player key is null")) + } + break + case Constants.startPlayer: + let key = args?[Constants.playerKey] as? String + if(key != nil){ + audioPlayers[key!]?.startPlyer(result: result) + } else { + result(FlutterError(code: Constants.audioWaveforms, message: "Can not start player", details: "Player key is null")) + } + break + case Constants.finishMode: + let key = args?[Constants.playerKey] as? String + let releaseType = args?[Constants.finishType] as? Int + if(key != nil){ + audioPlayers[key!]?.setFinishMode(result: result, releaseType: releaseType) + }else{ + result(FlutterError(code: Constants.audioWaveforms, message: "Can not set release mode", details: "Player key is null")) + } + case Constants.pausePlayer: + let key = args?[Constants.playerKey] as? String + if(key != nil){ + audioPlayers[key!]?.pausePlayer() + result(true) + } else { + result(FlutterError(code: Constants.audioWaveforms, message: "Can not pause player", details: "Player key is null")) + } + break + case Constants.stopPlayer: + let key = args?[Constants.playerKey] as? String + if(key != nil){ + audioPlayers[key!]?.stopPlayer() + result(true) + } else { + result(FlutterError(code: Constants.audioWaveforms, message: "Can not stop player", details: "Player key is null")) + } + break + case Constants.releasePlayer: + let key = args?[Constants.playerKey] as? String + if(key != nil){ + audioPlayers[key!]?.release(result: result) + } + break; + case Constants.seekTo: + let key = args?[Constants.playerKey] as? String + if(key != nil){ + audioPlayers[key!]?.seekTo(args?[Constants.progress] as? Int,result) + } else { + result(FlutterError(code: Constants.audioWaveforms, message: "Can not seek to postion", details: "Player key is null")) + } + case Constants.setVolume: + let key = args?[Constants.playerKey] as? String + if(key != nil){ + audioPlayers[key!]?.setVolume(args?[Constants.volume] as? Double,result) + } else { + result(FlutterError(code: Constants.audioWaveforms, message: "Can not set volume", details: "Player key is null")) + } + case Constants.setRate: + let key = args?[Constants.playerKey] as? String + if(key != nil){ + audioPlayers[key!]?.setRate(args?[Constants.rate] as? Double,result) + } else { + result(FlutterError(code: Constants.audioWaveforms, message: "Can not set rate", details: "Player key is null")) + } + case Constants.getDuration: + let type = args?[Constants.durationType] as? Int + let key = args?[Constants.playerKey] as? String + if(key != nil){ + do{ + if(type == 0){ + try audioPlayers[key!]?.getDuration(DurationType.Current,result) + } else { + try audioPlayers[key!]?.getDuration( DurationType.Max,result) + } + } catch{ + result(FlutterError(code: "", message: "Failed to get duration", details: nil)) + } + } else { + result(FlutterError(code: Constants.audioWaveforms, message: "Can not get duration", details: "Player key is null")) + } + case Constants.stopAllPlayers: + for (playerKey,_) in audioPlayers { + audioPlayers[playerKey]?.stopPlayer() + audioPlayers[playerKey] = nil + } + result(true) + case Constants.extractWaveformData: + guard let key = args?[Constants.playerKey] as? String else { + result( + FlutterError( + code: Constants.audioWaveforms, + message: "Can not get waveform data", + details: "Waveform key is null" + ) + ) + break + } + let path = args?[Constants.path] as? String + let noOfSamples = args?[Constants.noOfSamples] as? Int + createOrUpdateExtractor( + playerKey: key, + result: result, + path: path, + noOfSamples: noOfSamples + ) + case Constants.stopExtraction: + guard let key = args?[Constants.playerKey] as? String else { + result(FlutterError(code: Constants.audioWaveforms, message: "Can not get waveform data", details: "Waveform key is null")) + break + } + extractors[key]?.cancel() + result(true) + case Constants.pauseAllPlayers: + for(playerKey,_) in audioPlayers { + audioPlayers[playerKey]?.pausePlayer() + } + result(true) + break + default: + result(FlutterMethodNotImplemented) + break + } + } + + + func initPlayer(playerKey: String) { + if audioPlayers[playerKey] == nil { + let newPlayer = AudioPlayer(plugin: self,playerKey: playerKey,channel: flutterChannel) + audioPlayers[playerKey] = newPlayer + } + } + + func createOrUpdateExtractor(playerKey: String, result: @escaping FlutterResult,path: String?, noOfSamples: Int?) { + if(!(path ?? "").isEmpty) { + do { + let audioUrl = URL.init(string: path!) + if(audioUrl == nil){ + result(FlutterError(code: Constants.audioWaveforms, message: "Failed to initialise Url from provided audio file", details: "If path contains `file://` try removing it")) + return + } + extractors[playerKey]?.cancel() + let newExtractor = try WaveformExtractor(url: audioUrl!, flutterResult: result, channel: flutterChannel) + extractors[playerKey] = newExtractor + Task { + await newExtractor + .extractWaveform(samplesPerPixel: noOfSamples, playerKey: playerKey, + onExtractionComplete: { data in + result(data) + }) + } + } catch { + result(FlutterError(code: Constants.audioWaveforms, message: "Failed to decode audio file", details: nil)) + } + } else { + result(FlutterError(code: Constants.audioWaveforms, message: "Audio file path can't be empty or null", details: nil)) + } + } +} + diff --git a/audio_waveforms/ios/Classes/Utils.swift b/audio_waveforms/ios/Classes/Utils.swift new file mode 100644 index 0000000..e224093 --- /dev/null +++ b/audio_waveforms/ios/Classes/Utils.swift @@ -0,0 +1,93 @@ +enum DurationType { + case Current + case Max +} + +struct Constants { + static let methodChannelName = "simform_audio_waveforms_plugin/methods" + static let audioWaveforms = "AudioWaveforms" + static let startRecording = "startRecording" + static let pauseRecording = "pauseRecording" + static let stopRecording = "stopRecording" + static let getDecibel = "getDecibel" + static let checkPermission = "checkPermission" + static let path = "path" + static let encoder = "encoder" + static let sampleRate = "sampleRate" + static let bitRate = "bitRate" + static let fileNameFormat = "YY-MM-dd-HH-mm-ss" + static let resumeRecording = "resumeRecording" + + static let kAudioFormatMPEG4AAC = 0 + static let kAudioFormatMPEGLayer1 = 1 + static let kAudioFormatMPEGLayer2 = 2 + static let kAudioFormatMPEGLayer3 = 3 + static let kAudioFormatMPEG4AAC_ELD = 4 + static let kAudioFormatMPEG4AAC_HE = 5 + static let kAudioFormatOpus = 6 + static let kAudioFormatAMR = 7 + static let kAudioFormatAMR_WB = 8 + static let kAudioFormatLinearPCM = 9 + static let kAudioFormatAppleLossless = 10 + static let kAudioFormatMPEG4AAC_HE_V2 = 11 + + static let readAudioFile = "readAudioFile" + static let durationEventChannel = "durationEventChannel" + static let startPlayer = "startPlayer" + static let stopPlayer = "stopPlayer" + static let pausePlayer = "pausePlayer" + static let releasePlayer = "releasePlayer" + static let seekTo = "seekTo" + static let progress = "progress" + static let setVolume = "setVolume" + static let setRate = "setRate" + static let rate = "rate" + static let volume = "volume" + static let finishMode = "finishMode" + static let finishType = "finishType" + static let getDuration = "getDuration" + static let durationType = "durationType" + static let preparePlayer = "preparePlayer" + static let onCurrentDuration = "onCurrentDuration" + static let current = "current" + static let playerKey = "playerKey" + static let stopAllPlayers = "stopAllPlayers" + static let onDidFinishPlayingAudio = "onDidFinishPlayingAudio" + static let extractWaveformData = "extractWaveformData" + static let noOfSamples = "noOfSamples" + static let onCurrentExtractedWaveformData = "onCurrentExtractedWaveformData" + static let waveformData = "waveformData" + static let onExtractionProgressUpdate = "onExtractionProgressUpdate" + static let useLegacyNormalization = "useLegacyNormalization" + static let updateFrequency = "updateFrequency" + static let overrideAudioSession = "overrideAudioSession" + static let resultFilePath = "resultFilePath" + static let resultDuration = "resultDuration" + static let linearPCMBitDepth = "linearPCMBitDepth"; + static let linearPCMIsBigEndian = "linearPCMIsBigEndian"; + static let linearPCMIsFloat = "linearPCMIsFloat"; + static let pauseAllPlayers = "pauseAllPlayers" + static let stopExtraction = "stopExtraction" + static let onAudioChunk = "onAudioChunk" + static let bytes = "bytes" + static let normalisedRms = "normalisedRms" + static let recordedDuration = "recordedDuration" + static let defaultSampleRate = 44100 +} + + +/// Creates an 2D array of floats +public typealias FloatChannelData = [[Float]] + +/// Extension to fill array with zeros +public extension RangeReplaceableCollection where Iterator.Element: ExpressibleByIntegerLiteral { + init(zeros count: Int) { + self.init(repeating: 0, count: count) + } +} + +enum FinishMode : Int{ + case loop = 0 + case pause = 1 + case stop = 2 +} diff --git a/audio_waveforms/ios/Classes/WaveformExtractor.swift b/audio_waveforms/ios/Classes/WaveformExtractor.swift new file mode 100644 index 0000000..e922d5e --- /dev/null +++ b/audio_waveforms/ios/Classes/WaveformExtractor.swift @@ -0,0 +1,209 @@ +import Accelerate +import AVFoundation + +public class WaveformExtractor { + + public private(set) var audioFile: AVAudioFile? + private var result: FlutterResult + var flutterChannel: FlutterMethodChannel + private var waveformData = Array() + var progress: Float = 0.0 + var channelCount: Int = 1 + private var currentProgress: Float = 0.0 + private let abortWaveformDataQueue = DispatchQueue( + label: "WaveformExtractor", + attributes: .concurrent + ) + + private var _abortGetWaveformData: Bool = false + + public var abortGetWaveformData: Bool { + get { _abortGetWaveformData } + set { + abortWaveformDataQueue.async(flags: .barrier) { + self._abortGetWaveformData = newValue + } + } + } + public init(url: URL, flutterResult: @escaping FlutterResult, channel: FlutterMethodChannel) throws { + result = flutterResult + self.flutterChannel = channel + do { + audioFile = try AVAudioFile(forReading: url) + } catch { + audioFile = nil + result(FlutterError(code: Constants.audioWaveforms, message: error.localizedDescription, details: "Couldn't initialise AVAudioFile from \(url.absoluteString)")) + + } + } + + deinit { + audioFile = nil + } + + public func extractWaveform( + samplesPerPixel: Int?, + offset: Int? = 0, + length: UInt? = nil, + playerKey: String, + onExtractionComplete: ([Float]?) -> Void + ) async -> Void { + guard let audioFile = audioFile else { return } + + /// Prevent division by zero, + minimum resolution + let samplesPerPixel = max(1, samplesPerPixel ?? 100) + let currentFrame = audioFile.framePosition + let totalFrames = AVAudioFrameCount(audioFile.length) + var framesPerBuffer = totalFrames / AVAudioFrameCount(samplesPerPixel) + + guard let rmsBuffer = AVAudioPCMBuffer( + pcmFormat: audioFile.processingFormat, + frameCapacity: framesPerBuffer + ) else { return } + + let channelCount = Int(audioFile.processingFormat.channelCount) + let waveformStorage = WaveformStorage( + channelCount: channelCount, + size: samplesPerPixel + ) + + let startIndex = max( + 0, offset ?? Int(currentFrame / Int64(framesPerBuffer)) + ) + let endIndex = min( + samplesPerPixel, startIndex + (length.map { Int($0) } ?? samplesPerPixel) + ) + + if startIndex > endIndex { + sendErrorToFlutter( + message: "Offset is larger than total length.", + details: "Please select less number of samples" + ) + return + } + + var startFrame: AVAudioFramePosition = offset == nil + ? currentFrame + : Int64(startIndex * Int(framesPerBuffer)) + + for i in startIndex.. totalFrames { + framesPerBuffer = totalFrames - AVAudioFrameCount(startFrame) + if framesPerBuffer <= 0 { break } + } + } + + audioFile.framePosition = currentFrame + let waveformData = await waveformStorage.getData() + let data = getChannelMean(data: waveformData) + onExtractionComplete(data); + } + + func getChannelMean(data: FloatChannelData) -> [Float] { + var resultWaveform = [Float]() + + if channelCount == 2, !data[0].isEmpty, !data[1].isEmpty { + resultWaveform = zip(data[0], data[1]).map { ($0 + $1) / 2 } + } else if !data[0].isEmpty { + resultWaveform = data[0] + } else if !data[1].isEmpty { + resultWaveform = data[1] + } else { + sendErrorToFlutter( + message: "Cannot get waveform mean", + details: "Both audio channels are null" + ) + } + return resultWaveform + } + + public func cancel() { + abortGetWaveformData = true + } + + private func sendWaveformDataToFlutter( + waveformStorage: WaveformStorage, + progress: Float, + playerKey: String + ) async { + let waveformData = await waveformStorage.getData() + let meanData = getChannelMean(data: waveformData) + + DispatchQueue.main.async { + self.flutterChannel.invokeMethod( + Constants.onCurrentExtractedWaveformData, + arguments: [ + Constants.waveformData: meanData, + Constants.progress: progress, + Constants.playerKey: playerKey + ] + ) + } + } + + private func sendErrorToFlutter(message: String, details: String? = nil) { + DispatchQueue.main.async { + self.result( + FlutterError( + code: Constants.audioWaveforms, + message: message, + details: details + ) + ) + } + } +} + +actor WaveformStorage { + private var data: [[Float]] + + init(channelCount: Int, size: Int) { + data = Array(repeating: [Float](repeating: 0, count: size), count: channelCount) + } + + func update(channel: Int, index: Int, value: Float) { + data[channel][index] = value + } + + func getData() -> [[Float]] { + return data + } +} diff --git a/audio_waveforms/ios/audio_waveforms.podspec b/audio_waveforms/ios/audio_waveforms.podspec new file mode 100644 index 0000000..db48a16 --- /dev/null +++ b/audio_waveforms/ios/audio_waveforms.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint audio_waveforms.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'audio_waveforms' + s.version = '0.0.1' + s.summary = 'A new Flutter project.' + s.description = <<-DESC +A new Flutter project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '8.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/audio_waveforms/lib/audio_waveforms.dart b/audio_waveforms/lib/audio_waveforms.dart new file mode 100644 index 0000000..cf60644 --- /dev/null +++ b/audio_waveforms/lib/audio_waveforms.dart @@ -0,0 +1,12 @@ +library; + +export 'src/audio_file_waveforms.dart'; +export 'src/audio_waveforms.dart'; +export 'src/base/player_wave_style.dart'; +export 'src/base/utils.dart'; +export 'src/base/wave_style.dart'; +export 'src/controllers/player_controller.dart'; +export 'src/controllers/recorder_controller.dart'; +export 'src/models/android_encoder_settings.dart'; +export 'src/models/ios_encoder_setting.dart'; +export 'src/models/recorder_settings.dart'; diff --git a/audio_waveforms/lib/src/audio_file_waveforms.dart b/audio_waveforms/lib/src/audio_file_waveforms.dart new file mode 100644 index 0000000..43b4ce9 --- /dev/null +++ b/audio_waveforms/lib/src/audio_file_waveforms.dart @@ -0,0 +1,436 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../audio_waveforms.dart'; +import 'base/wave_clipper.dart'; +import 'painters/player_wave_painter.dart'; + +class AudioFileWaveforms extends StatefulWidget { + /// Generate waveforms from audio file. You play those audio file using + /// [PlayerController]. + /// + /// When you play the audio file, waves change their color according to + /// how much audio has been played and how much is left. + /// + /// With seeking gesture enabled, playing audio can be seeked to + /// any position using gestures. + const AudioFileWaveforms({ + super.key, + required this.size, + required this.playerController, + this.waveformData = const [], + this.continuousWaveform = true, + this.playerWaveStyle = const PlayerWaveStyle(), + this.padding, + this.margin, + this.decoration, + this.backgroundColor, + this.animationDuration = const Duration(milliseconds: 500), + this.animationCurve = Curves.easeIn, + this.clipBehavior = Clip.none, + this.waveformType = WaveformType.long, + this.enableSeekGesture = true, + this.onDragStart, + this.onDragEnd, + this.dragUpdateDetails, + this.onTapUp, + this.seekOnTapUp = true, + this.onTapDown, + }); + + /// A size to define height and width of waveform. + final Size size; + + /// A PlayerController having different controls for audio player. + final PlayerController playerController; + + /// Directly draws waveforms from this data. Extracted waveform data + /// is ignored if waveform data is provided from this parameter. + final List waveformData; + + /// When this flag is set to true, new waves are drawn as soon as new + /// waveform data is available from [onCurrentExtractedWaveformData]. + /// If this flag is set to false then waveforms will be drawn after waveform + /// extraction is fully completed. + /// + /// This flag is ignored if [waveformData] is directly provided. + /// + /// See documentation of extractWaveformData in [PlayerController] to + /// determine which value to choose. + /// + /// Defaults to true. + final bool continuousWaveform; + + /// A PlayerWaveStyle instance controls how waveforms should look. + final PlayerWaveStyle playerWaveStyle; + + /// Provides padding around waveform. + final EdgeInsets? padding; + + /// Provides margin around waveform. + final EdgeInsets? margin; + + /// Provides box decoration to the container having waveforms. + final BoxDecoration? decoration; + + /// Color which is applied in to background of the waveform. + /// If decoration is used then use color in it. + final Color? backgroundColor; + + /// Duration for animation. Defaults to 500 milliseconds. + final Duration animationDuration; + + /// Curve for animation. Defaults to Curves.easeIn + final Curve animationCurve; + + /// A clipping behaviour which is applied to container having waveforms. + final Clip clipBehavior; + + /// Draws waveform bases on selected option. For more info, see + /// [WaveformType] documentation. + final WaveformType waveformType; + + /// Allow seeking with gestures when turned on. + final bool enableSeekGesture; + + /// Provides a callback when drag starts. + final ValueSetter? onDragStart; + + /// Provides a callback when drag ends. + final ValueSetter? onDragEnd; + + /// Provides a callback on drag updates. + final ValueSetter? dragUpdateDetails; + + /// Provides a callback when pointer has stopped contacting the screen. + /// This handler will still provide callback when [seekOnTapUp] is set to `false`. + final ValueSetter? onTapUp; + + /// When set to true, seek gesture will be performed when pointer is lifted + /// from the screen otherwise seek gesture will be performed when pointer has + /// stopped contacting the screen. + /// + /// The continuous seek gesture aren't affected by this flag. + final bool seekOnTapUp; + + /// Provides a callback when pointer has started contacting the screen. + /// This handler will still provide callback when [seekOnTapUp] is set to `true`. + final GestureTapDownCallback? onTapDown; + + @override + State createState() => _AudioFileWaveformsState(); +} + +class _AudioFileWaveformsState extends State + with SingleTickerProviderStateMixin { + late AnimationController _growingWaveController; + late Animation _growAnimation; + + double _growAnimationProgress = 0.0; + final ValueNotifier _seekProgress = ValueNotifier(0); + bool showSeekLine = false; + + late EdgeInsets? margin; + late EdgeInsets? padding; + late BoxDecoration? decoration; + late Color? backgroundColor; + late Duration? animationDuration; + late Curve? animationCurve; + late Clip? clipBehavior; + late StreamSubscription onCurrentDurationSubscription; + late StreamSubscription onCompletionSubscription; + StreamSubscription>? onCurrentExtractedWaveformData; + + double get spacing => widget.playerWaveStyle.spacing; + + double get totalWaveWidth => + widget.playerWaveStyle.spacing * _waveformData.length; + + PlayerWaveStyle get playerWaveStyle => widget.playerWaveStyle; + + PlayerController get playerController => widget.playerController; + + WaveformExtractionController get waveformExtraction => + playerController.waveformExtraction; + + @override + void initState() { + super.initState(); + _initialiseVariables(); + _growingWaveController = AnimationController( + vsync: this, + duration: widget.animationDuration, + ); + _growAnimation = CurvedAnimation( + parent: _growingWaveController, + curve: widget.animationCurve, + ); + + _growingWaveController + ..addListener(_updateGrowAnimationProgress) + ..forward(); + + onCurrentDurationSubscription = + playerController.onCurrentDurationChanged.listen((event) { + _seekProgress.value = event; + _updatePlayerPercent(); + }); + + onCompletionSubscription = playerController.onCompletion.listen((event) { + _seekProgress.value = playerController.maxDuration; + _updatePlayerPercent(); + }); + if (widget.waveformData.isNotEmpty) { + _addWaveformData(widget.waveformData); + } else { + if (waveformExtraction.waveformData.isNotEmpty) { + _addWaveformData(waveformExtraction.waveformData); + } + if (!widget.continuousWaveform) { + playerController.addListener(_addWaveformDataFromController); + } else { + onCurrentExtractedWaveformData = waveformExtraction + .onCurrentExtractedWaveformData + .listen(_addWaveformData); + } + } + } + + @override + void dispose() { + onCurrentDurationSubscription.cancel(); + onCurrentExtractedWaveformData?.cancel(); + onCompletionSubscription.cancel(); + playerController.removeListener(_addWaveformDataFromController); + _growingWaveController.dispose(); + super.dispose(); + } + + double _audioProgress = 0.0; + double _cachedAudioProgress = 0.0; + + Offset _totalBackDistance = Offset.zero; + Offset _dragOffset = Offset.zero; + + double _initialDragPosition = 0.0; + double _scrollDirection = 0.0; + + bool _isScrolled = false; + double scrollScale = 1.0; + double _proportion = 0.0; + + final List _waveformData = []; + + @override + Widget build(BuildContext context) { + return Container( + padding: widget.padding, + margin: widget.margin, + decoration: widget.decoration, + clipBehavior: widget.clipBehavior, + child: GestureDetector( + onHorizontalDragUpdate: + widget.enableSeekGesture ? _handleDragGestures : null, + onTapUp: widget.enableSeekGesture ? _handleOnTapUp : null, + onHorizontalDragStart: + widget.enableSeekGesture ? _handleHorizontalDragStart : null, + onHorizontalDragEnd: widget.enableSeekGesture ? _handleOnDragEnd : null, + onTapDown: widget.enableSeekGesture ? _handleOnTapDown : null, + child: ClipPath( + // TODO: Update extraClipperHeight when duration labels are added + clipper: WaveClipper(extraClipperHeight: 0), + child: RepaintBoundary( + child: ValueListenableBuilder( + builder: (_, __, ___) { + return CustomPaint( + isComplex: true, + painter: PlayerWavePainter( + playerWaveStyle: playerWaveStyle, + waveformData: _waveformData, + animValue: _growAnimationProgress, + totalBackDistance: _totalBackDistance, + dragOffset: _dragOffset, + audioProgress: _audioProgress, + callPushback: !_isScrolled, + pushBack: _pushBackWave, + scrollScale: scrollScale, + waveformType: widget.waveformType, + cachedAudioProgress: _cachedAudioProgress, + ), + size: widget.size, + ); + }, + valueListenable: _seekProgress, + ), + ), + ), + ), + ); + } + + void _addWaveformDataFromController() => + _addWaveformData(waveformExtraction.waveformData); + + void _updateGrowAnimationProgress() { + if (mounted) { + setState(() { + _growAnimationProgress = _growAnimation.value; + }); + } + } + + void _handleOnDragEnd(DragEndDetails dragEndDetails) { + _isScrolled = false; + scrollScale = 1.0; + if (mounted) setState(() {}); + + if (widget.waveformType.isLong) { + playerController.seekTo( + (playerController.maxDuration * _proportion).toInt(), + ); + } + widget.onDragEnd?.call(dragEndDetails); + } + + void _addWaveformData(List data) { + _waveformData + ..clear() + ..addAll(data); + if (mounted) setState(() {}); + } + + void _handleDragGestures(DragUpdateDetails details) { + switch (widget.waveformType) { + case WaveformType.fitWidth: + _handleScrubberSeekUpdate(details); + break; + case WaveformType.long: + _handleScrollUpdate(details); + break; + } + + widget.dragUpdateDetails?.call(details); + } + + /// This method handles continues seek gesture + void _handleScrubberSeekUpdate(DragUpdateDetails details) { + final localPosition = details.localPosition.dx; + + _proportion = localPosition <= 0 ? 0 : localPosition / widget.size.width; + var seekPosition = playerController.maxDuration * _proportion; + + playerController.seekTo(seekPosition.toInt()); + } + + /// This method handles tap seek gesture + void _handleOnTapUp(TapUpDetails details) { + widget.onTapUp?.call(details); + if (!widget.seekOnTapUp) return; + _proportion = details.localPosition.dx / widget.size.width; + var seekPosition = playerController.maxDuration * _proportion; + + playerController.seekTo(seekPosition.toInt()); + } + + /// This method handles tap seek gesture + void _handleOnTapDown(TapDownDetails details) { + widget.onTapDown?.call(details); + if (widget.seekOnTapUp) return; + _proportion = details.localPosition.dx / widget.size.width; + var seekPosition = playerController.maxDuration * _proportion; + + playerController.seekTo(seekPosition.toInt()); + } + + ///This method handles horizontal scrolling of the wave + void _handleScrollUpdate(DragUpdateDetails details) { + // Direction of the scroll. Negative value indicates scroll left to right + // and positive value indicates scroll right to left + _scrollDirection = details.localPosition.dx - _initialDragPosition; + playerController.setRefresh(false); + _isScrolled = true; + + scrollScale = playerWaveStyle.scrollScale; + + final spacing = playerWaveStyle.spacing; + + // Update the drag offset based on scroll direction and thresholds. + final currentPosition = -_totalBackDistance.dx + _dragOffset.dx; + final updatedPosition = currentPosition + details.delta.dx; + + // left to right + if (updatedPosition + (spacing) < spacing / 2 && _scrollDirection > 0) { + _dragOffset += details.delta; + } + + // right to left + else if (currentPosition + totalWaveWidth + details.delta.dx > + (-spacing / 2) && + _scrollDirection < 0) { + _dragOffset += details.delta; + } + + // Indicates location of first wave + var start = currentPosition - (spacing / 2); + + _proportion = _scrollDirection < 0 + ? (start.abs() + details.delta.dx) / totalWaveWidth + : (details.delta.dx - start - spacing) / totalWaveWidth; + if (mounted) setState(() {}); + } + + ///This will help-out to determine direction of the scroll + void _handleHorizontalDragStart(DragStartDetails details) { + _initialDragPosition = details.localPosition.dx; + widget.onDragStart?.call(details); + } + + /// This initialises variable in [initState] so that everytime current duration + /// gets updated it doesn't re assign them to same values. + void _initialiseVariables() { + if (waveformExtraction.waveformData.isEmpty) { + waveformExtraction.waveformData.addAll(widget.waveformData); + } + showSeekLine = false; + margin = widget.margin; + padding = widget.padding; + decoration = widget.decoration; + backgroundColor = widget.backgroundColor; + animationDuration = widget.animationDuration; + animationCurve = widget.animationCurve; + clipBehavior = widget.clipBehavior; + } + + /// calculates seek progress + void _updatePlayerPercent() { + if (playerController.maxDuration == 0) return; + _audioProgress = _seekProgress.value / playerController.maxDuration; + } + + ///This will handle pushing back the wave when it reaches to middle/end of the + ///given size.width. + /// + ///This will also handle refreshing the wave after scrolled + void _pushBackWave() { + if (!_isScrolled && widget.waveformType.isLong) { + _totalBackDistance = Offset( + (playerWaveStyle.spacing * _audioProgress * _waveformData.length) + + playerWaveStyle.spacing + + _dragOffset.dx, + 0.0, + ); + } + if (playerController.shouldClearLabels) { + _initialDragPosition = 0.0; + _totalBackDistance = Offset.zero; + _dragOffset = Offset.zero; + } + _cachedAudioProgress = _audioProgress; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() {}); + } + }); + } +} diff --git a/audio_waveforms/lib/src/audio_waveforms.dart b/audio_waveforms/lib/src/audio_waveforms.dart new file mode 100644 index 0000000..7943cc5 --- /dev/null +++ b/audio_waveforms/lib/src/audio_waveforms.dart @@ -0,0 +1,309 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '/audio_waveforms.dart'; +import 'base/label.dart'; +import 'base/wave_clipper.dart'; +import 'painters/recorder_wave_painter.dart'; + +class AudioWaveforms extends StatefulWidget { + const AudioWaveforms({ + super.key, + required this.size, + required this.recorderController, + this.waveStyle = const WaveStyle(), + this.enableGesture = false, + this.padding, + this.margin, + this.decoration, + this.backgroundColor, + this.shouldCalculateScrolledPosition = false, + }); + + final Size size; + final RecorderController recorderController; + final WaveStyle waveStyle; + final EdgeInsets? padding; + final EdgeInsets? margin; + final BoxDecoration? decoration; + final Color? backgroundColor; + final bool enableGesture; + final bool shouldCalculateScrolledPosition; + + @override + State createState() => _AudioWaveformsState(); +} + +class _AudioWaveformsState extends State { + bool _isScrolled = false; + + /// Tracks the total horizontal offset applied when the waveform is shifted backward. + Offset _totalBackDistance = Offset.zero; + Offset _dragOffset = Offset.zero; + + double _initialOffsetPosition = 0.0; + late double _initialPosition; + Duration currentlyRecordedDuration = Duration.zero; + late StreamSubscription streamSubscription; + + late final Size _size; + late final WaveStyle _waveStyle; + late final RecorderController _recorderController; + late final bool _isRtl = widget.waveStyle.waveformRenderMode.isRtl; + + /// Duration timestamp labels shown on the waveform, added every second during recording. + final List