diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index c02e1831..ca70ccbc 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -38,6 +38,7 @@
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 653ea794..6379fdb6 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -17,8 +17,8 @@ android {
applicationId = "com.eva.recorderapp"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.compileSdk.get().toInt()
- versionCode = 11
- versionName = "1.4.1"
+ versionCode = 12
+ versionName = "1.4.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
diff --git a/data/interactions/src/main/java/com/eva/interactions/data/ShareRecordingsUtilImpl.kt b/data/interactions/src/main/java/com/eva/interactions/data/ShareRecordingsUtilImpl.kt
index 7f87b0fd..d272983f 100644
--- a/data/interactions/src/main/java/com/eva/interactions/data/ShareRecordingsUtilImpl.kt
+++ b/data/interactions/src/main/java/com/eva/interactions/data/ShareRecordingsUtilImpl.kt
@@ -52,6 +52,7 @@ internal class ShareRecordingsUtilImpl(
val intent = Intent(Intent.ACTION_SEND).apply {
setDataAndType(uri, "audio/*")
+ putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.share_audio_extra_subject))
}
diff --git a/data/player/src/main/java/com/eva/player/data/reader/AudioVisualizerImpl.kt b/data/player/src/main/java/com/eva/player/data/reader/AudioVisualizerImpl.kt
deleted file mode 100644
index 441c55d6..00000000
--- a/data/player/src/main/java/com/eva/player/data/reader/AudioVisualizerImpl.kt
+++ /dev/null
@@ -1,103 +0,0 @@
-package com.eva.player.data.reader
-
-import android.content.ContentUris
-import android.content.Context
-import android.media.MediaExtractor
-import android.provider.MediaStore
-import android.util.Log
-import androidx.core.net.toUri
-import com.eva.player.domain.AudioVisualizer
-import com.eva.player.domain.exceptions.DecoderExistsException
-import com.eva.player.domain.exceptions.InvalidMimeTypeException
-import com.eva.recordings.domain.models.AudioFileModel
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.withContext
-
-private const val TAG = "PLAIN_VISUALIZER"
-
-internal class AudioVisualizerImpl(private val context: Context) : AudioVisualizer {
-
- private var _extractor: MediaExtractor? = null
- private var _decoder: MediaCodecPCMDataDecoder? = null
-
- private val _isReady = MutableStateFlow(false)
- override val isVisualReady: StateFlow
- get() = _isReady
-
- private val _visualization = MutableStateFlow(floatArrayOf())
- override val normalizedVisualization: Flow
- get() = _visualization.map { array -> array.normalize().smoothen(.4f) }
- .catch { err -> Log.d(TAG, "SOME ERROR", err) }
- .flowOn(Dispatchers.Default)
-
- override suspend fun prepareVisualization(model: AudioFileModel, timePerPointInMs: Int)
- : Result = prepareVisualization(fileUri = model.fileUri, timePerPointInMs)
-
- override suspend fun prepareVisualization(fileId: Long, timePerPointInMs: Int): Result {
- val uri = ContentUris.withAppendedId(
- MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL),
- fileId
- )
- return prepareVisualization(fileUri = uri.toString(), timePerPointInMs)
- }
-
- override suspend fun prepareVisualization(fileUri: String, timePerPointInMs: Int)
- : Result {
-
- if (_decoder != null) {
- Log.d(TAG, "CLEAN DECODER TO PREPARE IT AGAIN")
- return Result.failure(DecoderExistsException())
- }
-
- return withContext(Dispatchers.IO) {
- try {
- _extractor = MediaExtractor().apply {
- setDataSource(context, fileUri.toUri(), null)
- }
- val format = _extractor?.getTrackFormat(0)
- val mimetype = format?.mimeType
- if (mimetype?.startsWith("audio") != true) {
- Log.e(TAG, "WRONG MIME TYPE")
- return@withContext Result.failure(InvalidMimeTypeException())
- }
- _extractor?.selectTrack(0)
-
- _decoder = MediaCodecPCMDataDecoder(
- extractor = _extractor,
- totalTime = format.duration,
- seekDurationMillis = timePerPointInMs,
- ).apply {
- setOnBufferDecode { array ->
- _isReady.update { true }
- _visualization.update { it + array }
- }
- }
- Log.d(TAG, "MEDIA CODEC SET FOR MIME TYPE:$mimetype")
- _decoder?.initiateCodec(format, mimetype)
- Result.success(Unit)
- } catch (e: Exception) {
- Log.e(TAG, "Error decoding or processing audio", e)
- Result.failure(e)
- }
- }
- }
-
- override fun cleanUp() {
- _isReady.update { false }
-
- Log.d(TAG, "MEDIA CODEC IS RELEASED")
- _decoder?.cleanUp()
- _decoder = null
-
- Log.d(TAG, "MEDIA EXTRACTOR IS RELEASED")
- _extractor?.release()
- _extractor = null
- }
-}
\ No newline at end of file
diff --git a/data/player/src/main/java/com/eva/player/data/reader/MediaCodecPCMDataDecoder.kt b/data/player/src/main/java/com/eva/player/data/reader/MediaCodecPCMDataDecoder.kt
deleted file mode 100644
index 5363193d..00000000
--- a/data/player/src/main/java/com/eva/player/data/reader/MediaCodecPCMDataDecoder.kt
+++ /dev/null
@@ -1,268 +0,0 @@
-package com.eva.player.data.reader
-
-import android.media.MediaCodec
-import android.media.MediaExtractor
-import android.media.MediaFormat
-import android.os.Handler
-import android.os.HandlerThread
-import android.util.Log
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import java.nio.ByteOrder
-import java.util.concurrent.ConcurrentLinkedQueue
-import kotlin.concurrent.atomics.AtomicBoolean
-import kotlin.concurrent.atomics.AtomicLong
-import kotlin.concurrent.atomics.ExperimentalAtomicApi
-import kotlin.concurrent.atomics.plusAssign
-import kotlin.math.sqrt
-import kotlin.time.Duration
-
-private const val TAG = "CODEC_CALLBACK"
-
-@OptIn(ExperimentalAtomicApi::class)
-internal class MediaCodecPCMDataDecoder(
- private val seekDurationMillis: Int,
- private val totalTime: Duration,
- private val extractor: MediaExtractor? = null,
-) : MediaCodec.Callback() {
-
- private val threadName = "MediaCodecComputeThread"
- private var _handlerThread: HandlerThread? = null
- private var _handler: Handler? = null
- private var _mediaCodec: MediaCodec? = null
-
- private val _scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
- private val _operations = ConcurrentLinkedQueue>()
-
- @Volatile
- private var _codecState = MediaCodecState.EXEC
-
- private val _mutex = Mutex()
-
- // callbacks
- private var _onBufferDecoded: ((FloatArray) -> Unit)? = null
- private var _onDecodeComplete: (() -> Unit)? = null
-
- // need to play with the size to get the optimal results
- private val batchSize = 50
-
- private val currentTimeInMs = AtomicLong(0L)
- private val _isBatchProcessing = AtomicBoolean(false)
-
- override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
-
- // codec state is non exec means work is done return END_OF_STREAM
- if (_codecState != MediaCodecState.EXEC) {
- Log.d(TAG, "CANNOT RUN IN WRONG STATE OR END OF BUFFER REACHED")
- codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
- return
- }
- val currentTime = currentTimeInMs.load()
- // if timeInMs is greater than totalTime+extra return END_OF_STREAM
- if (currentTime >= totalTime.inWholeMilliseconds + seekDurationMillis) {
- Log.d(TAG, "TOTAL TIME IS ALREADY REACHED")
- codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
- return
- }
-
- try {
-
- // receive a buffer
- if (index < 0) return
- val inputBuffer = codec.getInputBuffer(index) ?: return
-
- val extractor = extractor ?: return
- // seek the extractor as we don't need extra data
- extractor.seekTo(currentTimeInMs.load() * 1000, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
- val sampleSize = extractor.readSampleData(inputBuffer, 0)
-
- // sample size is zero thus processing done END_OF_STREAM
- if (sampleSize <= 0) {
- _codecState = MediaCodecState.END
- Log.d(TAG, "END OF INPUT STREAM")
- codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
- return
- }
-
- // advance the extractor to read the next sample if not END_OF_STREAM
- if (!extractor.advance()) {
- Log.d(TAG, "CANNOT ADVANCE EXTRACTOR ANY MORE")
- _codecState = MediaCodecState.END
- codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
- } else {
- // update the current time
- currentTimeInMs.plusAssign(seekDurationMillis.toLong())
- codec.queueInputBuffer(index, 0, sampleSize, extractor.sampleTime, 0)
- }
-
- } catch (e: MediaCodec.CodecException) {
- Log.e(TAG, "MEDIA CODEC EXCEPTION ${e.diagnosticInfo} ${e.errorCode} ", e)
- } catch (e: IllegalStateException) {
- Log.e(TAG, "MEDIA CODEC IS NOT IN EXECUTION STATE", e)
- }
- }
-
-
- override fun onOutputBufferAvailable(
- codec: MediaCodec,
- index: Int,
- info: MediaCodec.BufferInfo
- ) {
- if (info.isEndOfStream) {
- Log.i(TAG, "EVERYTHING RAN ON ${Thread.currentThread().name}")
- codec.stop()
- _codecState = MediaCodecState.STOP
- handleEndOfStream()
- return
- }
- if (info.size > 0) {
- try {
- val outputBuffer = codec.getOutputBuffer(index) ?: return
- outputBuffer.position(info.offset)
- outputBuffer.rewind()
- outputBuffer.order(ByteOrder.LITTLE_ENDIAN)
-
- val format = codec.outputFormat
- val pcmEncoding = format.pcmEncoding
- val channelCount = format.channels
- val floatArray = outputBuffer.asFloatArray(info.size, pcmEncoding, channelCount)
- handleFloatArray(floatArray)
- } catch (e: Exception) {
- e.printStackTrace()
- } finally {
- codec.releaseOutputBuffer(index, false)
- }
- }
-
- }
-
- private fun handleFloatArray(pcm: FloatArray) {
- // if there is some pcm data available
- if (pcm.isNotEmpty()) {
- val action = _scope.async { pcm.performRMS() }
- _operations.offer(action)
- }
-
- // Try to acquire the lock atomically BEFORE launching
- if (!_isBatchProcessing.compareAndSet(expectedValue = false, newValue = true)) return
-
- _scope.launch {
- try {
- if (_operations.size >= batchSize && isActive) {
- // batched operation
- _isBatchProcessing.compareAndSet(expectedValue = false, newValue = true)
- val operations = buildSet {
- repeat(batchSize) {
- val item = _operations.poll() ?: return@repeat
- add(item)
- }
- }
- Log.i(TAG, "EVALUATING INFORMATION BATCH :${operations.size}")
- val resultAsFloatArray = operations.awaitAll().toFloatArray()
- _onBufferDecoded?.invoke(resultAsFloatArray)
- }
- } catch (_: CancellationException) {
- Log.d(TAG, "CANCELLATION IN BATCH PROCESSING")
- } catch (e: Exception) {
- Log.d(TAG, "Exception at the end of stream", e)
- } finally {
- _isBatchProcessing.store(false)
- }
- }
- }
-
- private fun handleEndOfStream() {
- _scope.launch {
- try {
- Log.d(TAG, "END OF BUFFER REACHED, AWAITING OPERATIONS")
-
- val leftItems = buildSet {
- while (isActive) {
- val item = _operations.poll() ?: break
- add(item)
- }
- }
- Log.i(TAG, "EVALUATING INFORMATION END :${leftItems.size}")
- _mutex.withLock {
- if (leftItems.isEmpty()) return@withLock
- val results = leftItems.awaitAll()
- val resultAsFloatArray = results.toFloatArray()
- _onBufferDecoded?.invoke(resultAsFloatArray)
- _onDecodeComplete?.invoke()
- }
- } catch (_: CancellationException) {
- Log.d(TAG, "CANCELLATION IN HANDLING END OF STREAM")
- } catch (e: Exception) {
- Log.d(TAG, "Exception at the end of stream", e)
- }
- }
- }
-
- override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
- Log.e(TAG, "ERROR HAPPENED", e)
- }
-
- override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
- Log.d(TAG, "MEDIA FORMAT CHANGED: $format")
- }
-
- fun setOnBufferDecode(listener: (FloatArray) -> Unit) {
- _onBufferDecoded = listener
- }
-
- fun setOnComplete(listener: () -> Unit) {
- _onDecodeComplete = listener
- }
-
- fun initiateCodec(format: MediaFormat, mimeType: String) {
- if (_handlerThread == null || _handlerThread?.isAlive == false) {
- _handlerThread = HandlerThread(threadName).apply { start() }
- _handler = Handler(_handlerThread!!.looper)
- }
- _mediaCodec?.reset()
- _mediaCodec = MediaCodec.createDecoderByType(mimeType).apply {
- configure(format, null, null, 0)
- setCallback(this@MediaCodecPCMDataDecoder, _handler!!)
- }
- _mediaCodec?.start()
- Log.d(TAG, "MEDIA CODEC STARTED")
- }
-
- fun cleanUp() {
- Log.d(TAG, "MEDIA CODEC RELEASING")
- _mediaCodec?.stop()
- _mediaCodec?.release()
- _mediaCodec = null
-
- // shut down the thread
- val quit = _handlerThread?.quitSafely() ?: false
- _handlerThread = null
- _handler = null
- Log.d(TAG, "HANDLER THREAD STOPPED:$quit")
-
- Log.d(TAG, "CLEANING UP OPERATIONS")
- _operations.forEach { it.cancel() }
- _operations.clear()
-
- Log.d(TAG, "CLEARING UP SCOPE")
- _scope.cancel()
- }
-
- private suspend fun FloatArray.performRMS(): Float {
- return withContext(Dispatchers.Default) {
- val squaredAvg = map { it * it }.average().toFloat()
- sqrt(squaredAvg)
- }
- }
-}
\ No newline at end of file
diff --git a/data/player/src/main/java/com/eva/player/data/reader/MediaCodecState.kt b/data/player/src/main/java/com/eva/player/data/reader/MediaCodecState.kt
deleted file mode 100644
index 9ac7dd8c..00000000
--- a/data/player/src/main/java/com/eva/player/data/reader/MediaCodecState.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.eva.player.data.reader
-
-import android.media.MediaCodec
-
-internal enum class MediaCodecState {
- /**
- * [MediaCodec] stopped, its either started or configured
- */
- STOP,
-
- /**
- * [MediaCodec] state executing mediacodec is running
- */
- EXEC,
-
- /**
- * [MediaCodec] state released
- */
- END
-}
\ No newline at end of file
diff --git a/data/player/src/main/java/com/eva/player/di/PlayerControllerModule.kt b/data/player/src/main/java/com/eva/player/di/PlayerControllerModule.kt
index 1f3ba5a7..cc96c562 100644
--- a/data/player/src/main/java/com/eva/player/di/PlayerControllerModule.kt
+++ b/data/player/src/main/java/com/eva/player/di/PlayerControllerModule.kt
@@ -2,9 +2,7 @@ package com.eva.player.di
import android.content.Context
import com.eva.player.data.player.MediaControllerProvider
-import com.eva.player.data.reader.AudioVisualizerImpl
import com.eva.player.domain.AudioFilePlayer
-import com.eva.player.domain.AudioVisualizer
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -21,9 +19,5 @@ object PlayerControllerModule {
fun providesMediaControllerProvider(@ApplicationContext context: Context)
: AudioFilePlayer = MediaControllerProvider(context)
- @Provides
- @ViewModelScoped
- fun providesPlainVisualizer(@ApplicationContext context: Context): AudioVisualizer =
- AudioVisualizerImpl(context)
}
\ No newline at end of file
diff --git a/data/player/src/main/java/com/eva/player/domain/AudioVisualizer.kt b/data/player/src/main/java/com/eva/player/domain/AudioVisualizer.kt
deleted file mode 100644
index 92242d38..00000000
--- a/data/player/src/main/java/com/eva/player/domain/AudioVisualizer.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package com.eva.player.domain
-
-import com.eva.recordings.domain.models.AudioFileModel
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.StateFlow
-
-interface AudioVisualizer {
-
- val isVisualReady: StateFlow
-
- val normalizedVisualization: Flow
-
- suspend fun prepareVisualization(model: AudioFileModel, timePerPointInMs: Int = 10)
- : Result
-
- suspend fun prepareVisualization(fileUri: String, timePerPointInMs: Int): Result
-
- suspend fun prepareVisualization(fileId: Long, timePerPointInMs: Int): Result
-
- fun cleanUp()
-}
\ No newline at end of file
diff --git a/data/player/src/main/java/com/eva/player/domain/exceptions/DecoderExistsException.kt b/data/player/src/main/java/com/eva/player/domain/exceptions/DecoderExistsException.kt
deleted file mode 100644
index 8c754373..00000000
--- a/data/player/src/main/java/com/eva/player/domain/exceptions/DecoderExistsException.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.eva.player.domain.exceptions
-
-class DecoderExistsException : Exception("Decoder already ready clean it to continue")
\ No newline at end of file
diff --git a/data/recorder/src/main/java/com/eva/recorder/data/service/RecorderServiceBinderImpl.kt b/data/recorder/src/main/java/com/eva/recorder/data/service/RecorderServiceBinderImpl.kt
index 46cc499e..fcfe7f24 100644
--- a/data/recorder/src/main/java/com/eva/recorder/data/service/RecorderServiceBinderImpl.kt
+++ b/data/recorder/src/main/java/com/eva/recorder/data/service/RecorderServiceBinderImpl.kt
@@ -7,6 +7,8 @@ import android.content.ServiceConnection
import android.os.IBinder
import android.util.Log
import com.eva.recorder.domain.RecorderServiceBinder
+import com.eva.recorder.domain.models.RecordedPoint
+import com.eva.recorder.domain.models.RecorderState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -16,6 +18,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
+import kotlinx.datetime.LocalTime
import kotlin.time.Duration
private const val TAG = "RECORDER_SERVICE_BINDER"
@@ -26,19 +29,23 @@ internal class RecorderServiceBinderImpl(private val context: Context) : Recorde
private val _isBounded = MutableStateFlow(false)
private var _service = MutableStateFlow(null)
- private val _serviceInstanceFlow = combine(_isBounded, _service) { bounded, service ->
- if (bounded && service != null) service
- else null
- }.filterNotNull()
+ private val _serviceInstanceFlow
+ get() = combine(_isBounded, _service) { bounded, service ->
+ if (bounded && service != null) service
+ else null
+ }.filterNotNull()
- override val recorderTimer = _serviceInstanceFlow.flatMapLatest { it.recorderTime }
+ override val recorderTimer: Flow
+ get() = _serviceInstanceFlow.flatMapLatest { it.recorderTime }
- override val recorderState = _serviceInstanceFlow.flatMapLatest { it.recorderState }
+ override val recorderState: Flow
+ get() = _serviceInstanceFlow.flatMapLatest { it.recorderState }
override val bookMarkTimes: Flow>
get() = _serviceInstanceFlow.flatMapLatest { it.bookMarks }
- override val amplitudes = _serviceInstanceFlow.flatMapLatest { it.amplitudes }
+ override val amplitudes: Flow>
+ get() = _serviceInstanceFlow.flatMapLatest { it.amplitudes }
override val isConnectionReady: StateFlow
get() = _isBounded
@@ -48,13 +55,13 @@ internal class RecorderServiceBinderImpl(private val context: Context) : Recorde
val binder = (service as? VoiceRecorderService.LocalBinder)
_service.value = binder?.getService()
val isBounded = _isBounded.updateAndGet { true }
- Log.d(TAG, "SERVICE CONNECTED :BOUNDED :$isBounded")
+ Log.i(TAG, "SERVICE CONNECTED IS_BOUNDED :$isBounded")
}
override fun onServiceDisconnected(name: ComponentName?) {
val bounded = _isBounded.updateAndGet { false }
_service.value = null
- Log.d(TAG, "SERVICE DISCONNECTED :BOUNDED:$bounded")
+ Log.i(TAG, "SERVICE DISCONNECTED IS_BOUNDED:$bounded")
}
}
@@ -62,8 +69,11 @@ internal class RecorderServiceBinderImpl(private val context: Context) : Recorde
override fun bindToService() {
try {
val intent = Intent(context, VoiceRecorderService::class.java)
- context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
- Log.d(TAG, "SERVICE BIND")
+ val hasPermission = context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
+ Log.d(TAG, "SERVICE BIND SUCCESS:$hasPermission")
+ } catch (e: SecurityException) {
+ Log.e(TAG, "CANNOT ACCESS SERVICE", e)
+ context.unbindService(serviceConnection)
} catch (e: Exception) {
e.printStackTrace()
}
@@ -71,9 +81,10 @@ internal class RecorderServiceBinderImpl(private val context: Context) : Recorde
override fun unBindService() {
try {
+ if (!_isBounded.value) return
context.unbindService(serviceConnection)
val isBounded = _isBounded.updateAndGet { false }
- Log.d(TAG, "SERVICE UN-BIND BOUNDED:$isBounded")
+ Log.d(TAG, "SERVICE UN-BIND IS_BOUNDED:$isBounded")
} catch (e: Exception) {
e.printStackTrace()
}
diff --git a/data/visualizer/.gitignore b/data/visualizer/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/data/visualizer/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/data/visualizer/build.gradle.kts b/data/visualizer/build.gradle.kts
new file mode 100644
index 00000000..991d3701
--- /dev/null
+++ b/data/visualizer/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+ alias(libs.plugins.recorderapp.android.library)
+ alias(libs.plugins.recorderapp.hilt)
+}
+
+android {
+ namespace = "com.com.visualizer"
+}
+
+dependencies {
+ implementation(project(":data:recordings"))
+}
\ No newline at end of file
diff --git a/data/visualizer/consumer-rules.pro b/data/visualizer/consumer-rules.pro
new file mode 100644
index 00000000..e69de29b
diff --git a/data/visualizer/proguard-rules.pro b/data/visualizer/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/data/visualizer/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/data/visualizer/src/main/java/com/com/visualizer/data/AudioVisualizerImpl.kt b/data/visualizer/src/main/java/com/com/visualizer/data/AudioVisualizerImpl.kt
new file mode 100644
index 00000000..96a539d3
--- /dev/null
+++ b/data/visualizer/src/main/java/com/com/visualizer/data/AudioVisualizerImpl.kt
@@ -0,0 +1,114 @@
+package com.com.visualizer.data
+
+import android.content.Context
+import android.util.Log
+import androidx.core.net.toUri
+import androidx.lifecycle.LifecycleOwner
+import com.com.visualizer.domain.AudioVisualizer
+import com.com.visualizer.domain.ThreadController
+import com.com.visualizer.domain.VisualizerState
+import com.com.visualizer.domain.exception.DecoderExistsException
+import com.eva.recordings.domain.models.AudioFileModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+private const val TAG = "PLAIN_VISUALIZER"
+
+internal class AudioVisualizerImpl(
+ private val context: Context,
+ private val threadHandler: ThreadController
+) : AudioVisualizer {
+
+ private var _decoder: MediaCodecPCMDataDecoder? = null
+ private val _lock = Mutex()
+
+ private val _isReady = MutableStateFlow(VisualizerState.NOT_STARTED)
+ private val _visualization = MutableStateFlow(floatArrayOf())
+
+ override val visualizerState: StateFlow
+ get() = _isReady
+
+ override val normalizedVisualization: Flow
+ get() = _visualization.map { array -> array.normalize().smoothen(.4f) }
+ .flowOn(Dispatchers.Default)
+ .catch { err -> Log.d(TAG, "SOME ERROR", err) }
+
+
+ override suspend fun prepareVisualization(
+ model: AudioFileModel,
+ lifecycleOwner: LifecycleOwner,
+ timePerPointInMs: Int
+ ): Result = prepareVisualization(
+ fileUri = model.fileUri,
+ lifecycleOwner = lifecycleOwner,
+ timePerPointInMs
+ )
+
+ override suspend fun prepareVisualization(
+ fileUri: String,
+ lifecycleOwner: LifecycleOwner,
+ timePerPointInMs: Int
+ ): Result {
+
+ if (_decoder != null) {
+ Log.d(TAG, "CLEAN DECODER TO PREPARE IT AGAIN")
+ return Result.failure(DecoderExistsException())
+ }
+
+ val handler = threadHandler.bindToLifecycle(lifecycleOwner)
+
+ return withContext(Dispatchers.IO) {
+ try {
+ _lock.withLock {
+ _decoder = MediaCodecPCMDataDecoder(
+ handler = handler,
+ seekDurationMillis = timePerPointInMs,
+ )
+ }
+ _decoder?.setOnBufferDecode(::updateVisuals)
+ _decoder?.setOnComplete(::releaseObjects)
+ _decoder?.initiateExtraction(context, fileUri.toUri())
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Log.e(TAG, "CANNOT DECODE THIS URI", e)
+ Result.failure(e)
+ }
+ }
+ }
+
+ private fun updateVisuals(array: FloatArray) {
+ _isReady.update { VisualizerState.RUNNING }
+ _visualization.update { it + array }
+ }
+
+ private fun releaseObjects() {
+ if (_lock.tryLock()) {
+ try {
+ Log.d(TAG, "CLEARING UP OBJECTS")
+ _decoder?.cleanUp()
+ } finally {
+ _decoder = null
+ _lock.unlock()
+ }
+ }
+ _isReady.update { VisualizerState.FINISHED }
+ }
+
+ override fun cleanUp() {
+ // reset values
+ _visualization.update { floatArrayOf() }
+ // release the objects
+ releaseObjects()
+ }
+
+}
\ No newline at end of file
diff --git a/data/player/src/main/java/com/eva/player/data/reader/ByteBufferExt.kt b/data/visualizer/src/main/java/com/com/visualizer/data/ByteBufferExt.kt
similarity index 97%
rename from data/player/src/main/java/com/eva/player/data/reader/ByteBufferExt.kt
rename to data/visualizer/src/main/java/com/com/visualizer/data/ByteBufferExt.kt
index 7ca9f931..ec9a9cb0 100644
--- a/data/player/src/main/java/com/eva/player/data/reader/ByteBufferExt.kt
+++ b/data/visualizer/src/main/java/com/com/visualizer/data/ByteBufferExt.kt
@@ -1,4 +1,4 @@
-package com.eva.player.data.reader
+package com.com.visualizer.data
import java.nio.ByteBuffer
diff --git a/data/player/src/main/java/com/eva/player/data/reader/FloatArrayExt.kt b/data/visualizer/src/main/java/com/com/visualizer/data/FloatArrayExt.kt
similarity index 90%
rename from data/player/src/main/java/com/eva/player/data/reader/FloatArrayExt.kt
rename to data/visualizer/src/main/java/com/com/visualizer/data/FloatArrayExt.kt
index d78eeb4e..2a4598e5 100644
--- a/data/player/src/main/java/com/eva/player/data/reader/FloatArrayExt.kt
+++ b/data/visualizer/src/main/java/com/com/visualizer/data/FloatArrayExt.kt
@@ -1,11 +1,11 @@
-package com.eva.player.data.reader
+package com.com.visualizer.data
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
-fun FloatArray.lowPassFilter(sampleRate: Int, cutoffFrequency: Float): FloatArray {
+internal fun FloatArray.lowPassFilter(sampleRate: Int, cutoffFrequency: Float): FloatArray {
val cutOff = cutoffFrequency / (sampleRate / 2f)
val filterLength = 101
val impulseResponse = FloatArray(filterLength)
@@ -71,7 +71,7 @@ fun FloatArray.compressFloatArray(m: Int): FloatArray {
}
-fun FloatArray.normalize(): FloatArray {
+internal fun FloatArray.normalize(): FloatArray {
val maxValue = maxOrNull() ?: .0f
val minValue = minOrNull() ?: .0f
@@ -87,7 +87,7 @@ fun FloatArray.normalize(): FloatArray {
}
-fun FloatArray.smoothen(smoothness: Float = .7f): FloatArray {
+internal fun FloatArray.smoothen(smoothness: Float = .7f): FloatArray {
if (isEmpty() || smoothness == 0f) return this
val original = copyOf()
for (i in 1..>()
+
+ // callbacks
+ private var _onBufferDecoded: ((FloatArray) -> Unit)? = null
+ private var _onDecodeComplete: (() -> Unit)? = null
+
+ // need to play with the size to get the optimal results
+ private val batchSize = 50
+
+ // time constraints
+ private val _currentTimeInMs = AtomicLong(0L)
+ private val _totalTimeInMs = AtomicLong(0L)
+
+ // flags
+ private val _isBatchProcessing = AtomicBoolean(false)
+ private var _isCleaningUp = AtomicBoolean(false)
+
+ override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
+ // clean up is called thus no more processing
+ if (_isCleaningUp.load()) {
+ Log.i(CODEC_TAG, "IGNORING READING INPUT CALLBACK ")
+ return
+ }
+ try {
+ // codec state is non exec means work is done return END_OF_STREAM
+ if (_codecState != MediaCodecState.EXEC) {
+ Log.w(PROCESSING_TAG, "WRONG CODEC STATE")
+ codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
+ return
+ }
+ // if timeInMs is greater than totalTime+extra return END_OF_STREAM
+ if (_currentTimeInMs.load() >= _totalTimeInMs.load() + seekDurationMillis) {
+ Log.i(PROCESSING_TAG, "TOTAL TIME HAS REACHED SENDING END OF STREAM")
+ codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
+ return
+ }
+ } catch (e: Exception) {
+ Log.e(PROCESSING_TAG, "UNABLE TO SEND EOS FLAG", e)
+ }
+
+ try {
+ // receive a buffer
+ if (index < 0) return
+ val inputBuffer = codec.getInputBuffer(index) ?: return
+
+ val extractor = _extractor ?: run {
+ codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
+ Log.w(EXTRACTOR_TAG, "EXTRACTOR RELEASED CANNOT CALCULATE")
+ return
+ }
+ // seek the extractor as we don't need extra data
+ extractor.seekTo(_currentTimeInMs.load() * 1_000L, MediaExtractor.SEEK_TO_CLOSEST_SYNC)
+ val sampleSize = extractor.readSampleData(inputBuffer, 0)
+
+ // sample size is zero thus processing done END_OF_STREAM
+ if (sampleSize <= 0) {
+ codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
+ Log.i(PROCESSING_TAG, "INPUT BUFFER :END OF INPUT STREAM")
+ return
+ }
+
+ // advance the extractor to read the next sample if not END_OF_STREAM
+ if (!extractor.advance()) {
+ Log.d(PROCESSING_TAG, "CANNOT ADVANCE EXTRACTOR")
+ Log.d(PROCESSING_TAG, "INPUT BUFFER :END OF INPUT STREAM")
+ codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
+ } else if (_codecState == MediaCodecState.EXEC) {
+ // update the current time
+ _currentTimeInMs.plusAssign(seekDurationMillis.toLong())
+ codec.queueInputBuffer(index, 0, sampleSize, extractor.sampleTime, 0)
+ }
+ } catch (e: MediaCodec.CodecException) {
+ Log.e(PROCESSING_TAG, "MEDIA CODEC EXCEPTION ${e.diagnosticInfo} ${e.errorCode} ", e)
+ } catch (e: IllegalStateException) {
+ Log.e(PROCESSING_TAG, "MEDIA CODEC IS NOT IN EXECUTION STATE", e)
+ }
+ }
+
+
+ override fun onOutputBufferAvailable(
+ codec: MediaCodec,
+ index: Int,
+ info: MediaCodec.BufferInfo
+ ) {
+ // look is this cleaning up
+ if (_isCleaningUp.load()) {
+ Log.i(CODEC_TAG, "IGNORING PROCESSING OUTPUT CALLBACK")
+ return
+ }
+ // correct state or not
+ if (_codecState != MediaCodecState.EXEC) {
+ Log.w(PROCESSING_TAG, "WRONG STATE SHOULD BE EXEC FOUND :$_codecState")
+ return
+ }
+ // special case to handle end of stream
+ if (info.isEndOfStream) {
+ codec.stop()
+ _codecState = MediaCodecState.RELEASED
+ handleEndOfStream()
+ return
+ }
+ if (info.size > 0) {
+ try {
+ if (_codecState != MediaCodecState.EXEC) return
+ val outputBuffer = codec.getOutputBuffer(index) ?: return
+ outputBuffer.position(info.offset)
+ outputBuffer.rewind()
+ outputBuffer.order(ByteOrder.LITTLE_ENDIAN)
+
+ val format = codec.outputFormat
+ val pcmEncoding = format.pcmEncoding
+ val channelCount = format.channels
+ val floatArray = outputBuffer.asFloatArray(info.size, pcmEncoding, channelCount)
+ handleFloatArray(floatArray)
+
+ // release the buffer as work is done
+ if (_codecState == MediaCodecState.EXEC)
+ codec.releaseOutputBuffer(index, false)
+ } catch (e: IllegalStateException) {
+ Log.e(PROCESSING_TAG, "WRONG STATE TO HANDLE THE OUTPUT BUFFERS", e)
+ }
+ }
+ }
+
+ override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
+ if (e.isRecoverable) codec.stop()
+
+ Log.e(CODEC_TAG, "ERROR HAPPENED", e)
+ }
+
+ override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
+ Log.i(CODEC_TAG, "MEDIA FORMAT CHANGED: $format")
+ }
+
+ private fun handleFloatArray(pcm: FloatArray) {
+ // if there is some pcm data available
+ if (pcm.isNotEmpty()) {
+ val action = _scope.async { pcm.performRMS() }
+ _operations.offer(action)
+ }
+
+ // Try to acquire the lock atomically BEFORE launching
+ if (!_isBatchProcessing.compareAndSet(expectedValue = false, newValue = true)) return
+
+ _scope.launch {
+ try {
+ _mutex.withLock {
+ if (_operations.size >= batchSize && isActive) {
+ // batched operation
+ _isBatchProcessing.compareAndSet(expectedValue = false, newValue = true)
+ val operations = buildSet {
+ repeat(batchSize) {
+ val item = _operations.poll() ?: return@repeat
+ add(item)
+ }
+ }
+ Log.d(PROCESSING_TAG, "EVALUATING INFORMATION BATCH :${operations.size}")
+ val resultAsFloatArray = operations.awaitAll().toFloatArray()
+ _onBufferDecoded?.invoke(resultAsFloatArray)
+ }
+ }
+ } catch (e: Exception) {
+ if (e is CancellationException)
+ Log.d(PROCESSING_TAG, "CANCELLATION IN BATCH PROCESSING")
+ Log.e(PROCESSING_TAG, "Exception at the end of stream", e)
+ } finally {
+ _isBatchProcessing.store(false)
+ }
+ }
+ }
+
+ private fun handleEndOfStream() {
+ Log.d(PROCESSING_TAG, "END OF BUFFER REACHED, AWAITING OPERATIONS")
+ _scope.launch {
+ try {
+ _mutex.withLock {
+ val leftItems = buildSet {
+ while (isActive) {
+ val item = _operations.poll() ?: break
+ add(item)
+ }
+ }
+ Log.d(PROCESSING_TAG, "EVALUATING INFORMATION END :${leftItems.size}")
+ if (leftItems.isEmpty()) return@withLock
+ val results = leftItems.awaitAll()
+ val resultAsFloatArray = results.toFloatArray()
+ _onBufferDecoded?.invoke(resultAsFloatArray)
+ }
+ } catch (e: Exception) {
+ if (e is CancellationException) {
+ Log.d(PROCESSING_TAG, "CANCELLATION IN HANDLING END OF STREAM")
+ return@launch
+ }
+ Log.e(PROCESSING_TAG, "Exception at the end of stream", e)
+ } finally {
+ // stream read ended
+ _onDecodeComplete?.invoke()
+ }
+ }
+ }
+
+ fun setOnBufferDecode(listener: (FloatArray) -> Unit) {
+ _onBufferDecoded = listener
+ }
+
+ fun setOnComplete(listener: () -> Unit) {
+ _onDecodeComplete = listener
+ }
+
+ @Synchronized
+ fun initiateExtraction(context: Context, fileURI: Uri): Result {
+ _extractor?.release()
+ _extractor = MediaExtractor().apply {
+ setDataSource(context, fileURI, null)
+ }
+ val format = _extractor?.getTrackFormat(0)
+ val mimeType = format?.mimeType
+
+ if (mimeType == null || !mimeType.startsWith("audio"))
+ return Result.failure(InvalidMimeTypeException())
+
+ if (_extractor?.trackCount == 0)
+ return Result.failure(ExtractorNoTrackFoundException())
+
+ Log.i(EXTRACTOR_TAG, "EXTRACTOR PREPARED")
+ _extractor?.selectTrack(0)
+ _totalTimeInMs.store(format.duration.inWholeMilliseconds)
+
+ initiateCodec(format)
+ return Result.success(Unit)
+ }
+
+
+ private fun initiateCodec(format: MediaFormat) {
+ _isCleaningUp.store(false)
+ _isBatchProcessing.store(false)
+ _currentTimeInMs.store(0)
+
+ if (_codecState == MediaCodecState.RELEASED)
+ Log.d(CODEC_TAG, "FRESH INSTANCE FOUND")
+
+ // codec is reset
+ _mediaCodec?.reset()
+ _codecState = MediaCodecState.STOPPED
+
+ // codec is configured
+ val codecName = _codecList.findDecoderForFormat(format)
+ _mediaCodec = MediaCodec.createByCodecName(codecName).apply {
+ setCallback(this@MediaCodecPCMDataDecoder, handler)
+ configure(format, null, null, 0)
+ }
+
+ Log.i(CODEC_TAG, "MEDIA CODEC CONFIGURED THREAD:${handler?.looper?.thread}")
+ Log.d(CODEC_TAG, "MEDIA CODEC NAME :${_mediaCodec?.name}")
+
+ // codec is started
+ _mediaCodec?.start()
+ _codecState = MediaCodecState.EXEC
+ Log.i(CODEC_TAG, "MEDIA CODEC STARTED CURRENT STATE :$_codecState")
+ }
+
+
+ @Synchronized
+ fun cleanUp() {
+ if (_operations.isNotEmpty()) {
+ Log.d(PROCESSING_TAG, "CANCELLING DEFERRED CALCULATIONS")
+ _operations.forEach { it.cancel() }
+ _operations.clear()
+ }
+ Log.d(PROCESSING_TAG, "CANCELLING OPERATIONS SCOPE")
+ _scope.cancel()
+
+ if (handler == null || handler.looper == Looper.getMainLooper()) {
+ codecClean()
+ } else {
+ val isPosted = handler.post { codecClean() }
+ Log.i(CODEC_TAG, "CLEAN UP POSTED! :$isPosted")
+ }
+
+ }
+
+ private fun codecClean() {
+ try {
+ Log.i(CODEC_TAG, "CLEANING UP IN :${Thread.currentThread()}")
+ _isCleaningUp.compareAndSet(expectedValue = false, newValue = true)
+ if (_codecState == MediaCodecState.EXEC) {
+ // flush all the buffers
+ _mediaCodec?.flush()
+ // codec is stopped then switched to stopped state
+ _mediaCodec?.stop()
+ _codecState = MediaCodecState.STOPPED
+ }
+ } catch (e: Exception) {
+ Log.e(CODEC_TAG, "UNABLE TO STOP CODEC", e)
+ } finally {
+ _isCleaningUp.compareAndSet(expectedValue = true, newValue = false)
+ }
+
+ try {
+ if (_codecState == MediaCodecState.STOPPED) {
+ // clear the codec
+ val clearDuration = measureTime { _mediaCodec?.setCallback(null) }
+ // Give it time to process
+ Log.d(CODEC_TAG, "CALLBACK CLEAR POSTED TO HANDLER $clearDuration")
+ }
+ } catch (e: Exception) {
+ Log.e(CODEC_TAG, "FAILED TO CLEAR CALLBACK", e)
+ }
+
+ // release the codec
+ try {
+ _mediaCodec?.release()
+ Log.i(CODEC_TAG, "MEDIA CODEC RELEASED")
+ } finally {
+ _codecState = MediaCodecState.RELEASED
+ _mediaCodec = null
+ }
+
+ // release the extractor
+ _extractor?.release()
+ Log.d(EXTRACTOR_TAG, "EXTRACTOR RELEASED")
+ }
+
+ private suspend fun FloatArray.performRMS(): Float {
+ return withContext(Dispatchers.Default) {
+ val squaredAvg = map { it * it }.average().toFloat()
+ sqrt(squaredAvg)
+ }
+ }
+}
\ No newline at end of file
diff --git a/data/visualizer/src/main/java/com/com/visualizer/data/MediaCodecState.kt b/data/visualizer/src/main/java/com/com/visualizer/data/MediaCodecState.kt
new file mode 100644
index 00000000..0ddc2f5d
--- /dev/null
+++ b/data/visualizer/src/main/java/com/com/visualizer/data/MediaCodecState.kt
@@ -0,0 +1,21 @@
+package com.com.visualizer.data
+
+import android.media.MediaCodec
+
+internal enum class MediaCodecState {
+
+ /**
+ * [MediaCodec] is not processing anything with configured or stopped or uninitialized
+ */
+ STOPPED,
+
+ /**
+ * [MediaCodec] is currently reading/writing the output/input buffers
+ */
+ EXEC,
+
+ /**
+ * [MediaCodec] is not required any more
+ */
+ RELEASED
+}
\ No newline at end of file
diff --git a/data/player/src/main/java/com/eva/player/data/reader/MediaFormatExt.kt b/data/visualizer/src/main/java/com/com/visualizer/data/MediaFormatExt.kt
similarity index 96%
rename from data/player/src/main/java/com/eva/player/data/reader/MediaFormatExt.kt
rename to data/visualizer/src/main/java/com/com/visualizer/data/MediaFormatExt.kt
index c99258ff..804d243b 100644
--- a/data/player/src/main/java/com/eva/player/data/reader/MediaFormatExt.kt
+++ b/data/visualizer/src/main/java/com/com/visualizer/data/MediaFormatExt.kt
@@ -1,4 +1,4 @@
-package com.eva.player.data.reader
+package com.com.visualizer.data
import android.media.AudioFormat
import android.media.MediaCodec
diff --git a/data/visualizer/src/main/java/com/com/visualizer/data/ThreadLifecycleControllerImpl.kt b/data/visualizer/src/main/java/com/com/visualizer/data/ThreadLifecycleControllerImpl.kt
new file mode 100644
index 00000000..39a87e3d
--- /dev/null
+++ b/data/visualizer/src/main/java/com/com/visualizer/data/ThreadLifecycleControllerImpl.kt
@@ -0,0 +1,119 @@
+package com.com.visualizer.data
+
+import android.os.Build
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.Process
+import android.util.Log
+import androidx.annotation.MainThread
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import com.com.visualizer.domain.ThreadController
+import kotlin.time.measureTime
+
+private const val TAG = "THREAD_CONTROLLER"
+
+internal class ThreadLifecycleControllerImpl(private val threadName: String) :
+ DefaultLifecycleObserver, ThreadController {
+
+ @Volatile
+ private var _handlerThread: HandlerThread? = null
+
+ @Volatile
+ private var _handler: Handler? = null
+
+ private val _exceptionHandler = Thread.UncaughtExceptionHandler { thread, exc ->
+ Log.e(TAG, "THREADING ERRORS NAME:${thread.name} STATE:${thread.state}", exc)
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ owner.lifecycle.removeObserver(this)
+ stopThread()
+ }
+
+ @MainThread
+ @Synchronized
+ override fun bindToLifecycle(lifecycleOwner: LifecycleOwner): Handler {
+ lifecycleOwner.lifecycle.addObserver(this@ThreadLifecycleControllerImpl)
+ return getHandler()
+ }
+
+ private fun getHandler(): Handler {
+ if (_handlerThread == null || _handlerThread?.isAlive == false) createThread()
+ return _handler!!
+ }
+
+ /**
+ * Prepares the handler for use
+ */
+ @Suppress("DEPRECATION")
+ @Synchronized
+ private fun createThread() {
+ if (_handlerThread?.isAlive == true) {
+ Log.w(TAG, "THREAD IS NOT KILLED")
+ return
+ }
+
+ val newThread = HandlerThread(threadName, Process.THREAD_PRIORITY_AUDIO).apply {
+ setUncaughtExceptionHandler(_exceptionHandler)
+ start()
+ }
+ // set the new handler
+ _handlerThread = newThread
+ _handler = Handler.createAsync(newThread.looper)
+
+ val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
+ newThread.threadId() else newThread.id
+
+ val message = buildString {
+ append("HANDLER THREAD IS SET: ")
+ append("NAME: ${newThread.name} |")
+ append("STATE: ${newThread.looper.thread.state} |")
+ append("ID: $threadId |")
+ append("PRIORITY :${newThread.priority}")
+ }
+ Log.i(TAG, message)
+ }
+
+ /**
+ * Stop [_handlerThread] from running anymore
+ * @param maxWaitTime Time in millis the thread should wait for thread to die
+ */
+ @Synchronized
+ private fun stopThread(maxWaitTime: Long = 600L) {
+ require(maxWaitTime > 0) { "Wait time need to be greater than 0" }
+
+ val handlerThread = _handlerThread ?: run {
+ Log.d(TAG, "HANDLER THREAD WAS NOT SET")
+ return
+ }
+ val handler = _handler ?: return
+ handler.removeCallbacksAndMessages(null)
+
+ try {
+ val safeRequest = if (handlerThread.isAlive)
+ handlerThread.quitSafely() else false
+
+ if (!safeRequest) {
+ Log.d(TAG, "LOOPER WAS NOT SET OF THE THREAD IS ALREADY KILLED")
+ return
+ }
+ Log.i(TAG, "THREAD QUIT, THREAD STATE: ${handlerThread.state}")
+ // blocking code
+ val duration = measureTime { handlerThread.join(maxWaitTime) }
+ Log.d(TAG, "THREAD CURRENT STATE: ${handlerThread.state}")
+ Log.d(TAG, "JOIN TOOK :$duration")
+
+ } catch (e: InterruptedException) {
+ Log.e(TAG, "THREAD JOIN FAILED", e)
+ e.printStackTrace()
+ } finally {
+ Log.v(TAG, "AFTER CLEAN UP")
+ Log.v(TAG, "STATE: ${_handlerThread?.state}")
+ _handlerThread?.uncaughtExceptionHandler = null
+ _handlerThread = null
+ _handler = null
+ //
+ }
+ }
+}
\ No newline at end of file
diff --git a/data/visualizer/src/main/java/com/com/visualizer/di/ThreadControllerModule.kt b/data/visualizer/src/main/java/com/com/visualizer/di/ThreadControllerModule.kt
new file mode 100644
index 00000000..d2c2b4b4
--- /dev/null
+++ b/data/visualizer/src/main/java/com/com/visualizer/di/ThreadControllerModule.kt
@@ -0,0 +1,18 @@
+package com.com.visualizer.di
+
+import com.com.visualizer.data.ThreadLifecycleControllerImpl
+import com.com.visualizer.domain.ThreadController
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object ThreadControllerModule {
+
+ @Provides
+ @Singleton
+ fun providesThread(): ThreadController = ThreadLifecycleControllerImpl("ComputeThread")
+}
\ No newline at end of file
diff --git a/data/visualizer/src/main/java/com/com/visualizer/di/VisualizerModule.kt b/data/visualizer/src/main/java/com/com/visualizer/di/VisualizerModule.kt
new file mode 100644
index 00000000..d2daad48
--- /dev/null
+++ b/data/visualizer/src/main/java/com/com/visualizer/di/VisualizerModule.kt
@@ -0,0 +1,24 @@
+package com.com.visualizer.di
+
+import android.content.Context
+import com.com.visualizer.data.AudioVisualizerImpl
+import com.com.visualizer.domain.AudioVisualizer
+import com.com.visualizer.domain.ThreadController
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.components.ViewModelComponent
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.android.scopes.ViewModelScoped
+
+@Module
+@InstallIn(ViewModelComponent::class)
+object VisualizerModule {
+
+ @Provides
+ @ViewModelScoped
+ fun providesPlainVisualizer(
+ @ApplicationContext context: Context,
+ controller: ThreadController
+ ): AudioVisualizer = AudioVisualizerImpl(context, controller)
+}
\ No newline at end of file
diff --git a/data/visualizer/src/main/java/com/com/visualizer/domain/AudioVisualizer.kt b/data/visualizer/src/main/java/com/com/visualizer/domain/AudioVisualizer.kt
new file mode 100644
index 00000000..ffd68507
--- /dev/null
+++ b/data/visualizer/src/main/java/com/com/visualizer/domain/AudioVisualizer.kt
@@ -0,0 +1,27 @@
+package com.com.visualizer.domain
+
+import androidx.lifecycle.LifecycleOwner
+import com.eva.recordings.domain.models.AudioFileModel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+
+interface AudioVisualizer {
+
+ val visualizerState: StateFlow
+
+ val normalizedVisualization: Flow
+
+ suspend fun prepareVisualization(
+ model: AudioFileModel,
+ lifecycleOwner: LifecycleOwner,
+ timePerPointInMs: Int = 10
+ ): Result
+
+ suspend fun prepareVisualization(
+ fileUri: String,
+ lifecycleOwner: LifecycleOwner,
+ timePerPointInMs: Int
+ ): Result
+
+ fun cleanUp()
+}
\ No newline at end of file
diff --git a/data/visualizer/src/main/java/com/com/visualizer/domain/ThreadController.kt b/data/visualizer/src/main/java/com/com/visualizer/domain/ThreadController.kt
new file mode 100644
index 00000000..931ffa70
--- /dev/null
+++ b/data/visualizer/src/main/java/com/com/visualizer/domain/ThreadController.kt
@@ -0,0 +1,10 @@
+package com.com.visualizer.domain
+
+import android.os.Handler
+import androidx.lifecycle.LifecycleOwner
+
+fun interface ThreadController {
+
+ fun bindToLifecycle(lifecycleOwner: LifecycleOwner): Handler?
+
+}
\ No newline at end of file
diff --git a/data/visualizer/src/main/java/com/com/visualizer/domain/VisualizerState.kt b/data/visualizer/src/main/java/com/com/visualizer/domain/VisualizerState.kt
new file mode 100644
index 00000000..50fad44d
--- /dev/null
+++ b/data/visualizer/src/main/java/com/com/visualizer/domain/VisualizerState.kt
@@ -0,0 +1,7 @@
+package com.com.visualizer.domain
+
+enum class VisualizerState {
+ NOT_STARTED,
+ RUNNING,
+ FINISHED
+}
\ No newline at end of file
diff --git a/data/visualizer/src/main/java/com/com/visualizer/domain/exception/DecoderExistsException.kt b/data/visualizer/src/main/java/com/com/visualizer/domain/exception/DecoderExistsException.kt
new file mode 100644
index 00000000..61459860
--- /dev/null
+++ b/data/visualizer/src/main/java/com/com/visualizer/domain/exception/DecoderExistsException.kt
@@ -0,0 +1,3 @@
+package com.com.visualizer.domain.exception
+
+class DecoderExistsException : Exception("Decoder is holding resources, clean it to run again")
\ No newline at end of file
diff --git a/data/visualizer/src/main/java/com/com/visualizer/domain/exception/ExtractorNoTrackFoundException.kt b/data/visualizer/src/main/java/com/com/visualizer/domain/exception/ExtractorNoTrackFoundException.kt
new file mode 100644
index 00000000..f78f21d7
--- /dev/null
+++ b/data/visualizer/src/main/java/com/com/visualizer/domain/exception/ExtractorNoTrackFoundException.kt
@@ -0,0 +1,3 @@
+package com.com.visualizer.domain.exception
+
+class ExtractorNoTrackFoundException : Exception("No tracks found in the associated URI")
\ No newline at end of file
diff --git a/data/player/src/main/java/com/eva/player/domain/exceptions/InvalidMimeTypeException.kt b/data/visualizer/src/main/java/com/com/visualizer/domain/exception/InvalidMimeTypeException.kt
similarity index 66%
rename from data/player/src/main/java/com/eva/player/domain/exceptions/InvalidMimeTypeException.kt
rename to data/visualizer/src/main/java/com/com/visualizer/domain/exception/InvalidMimeTypeException.kt
index 51c67d8a..87613d97 100644
--- a/data/player/src/main/java/com/eva/player/domain/exceptions/InvalidMimeTypeException.kt
+++ b/data/visualizer/src/main/java/com/com/visualizer/domain/exception/InvalidMimeTypeException.kt
@@ -1,3 +1,3 @@
-package com.eva.player.domain.exceptions
+package com.com.visualizer.domain.exception
class InvalidMimeTypeException : Exception("Media extractor found an invalid datatype")
\ No newline at end of file
diff --git a/feature/player-shared/build.gradle.kts b/feature/player-shared/build.gradle.kts
index 43c1ad05..ff9af596 100644
--- a/feature/player-shared/build.gradle.kts
+++ b/feature/player-shared/build.gradle.kts
@@ -23,6 +23,7 @@ dependencies {
implementation(project(":data:player"))
implementation(project(":data:editor"))
+ implementation(project(":data:visualizer"))
implementation(project(":data:recordings"))
implementation(project(":data:interactions"))
implementation(project(":data:use_case"))
diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/PlayerVisualizerViewmodel.kt b/feature/player-shared/src/main/java/com/eva/player_shared/PlayerVisualizerViewmodel.kt
index e5ddea3e..cab8b205 100644
--- a/feature/player-shared/src/main/java/com/eva/player_shared/PlayerVisualizerViewmodel.kt
+++ b/feature/player-shared/src/main/java/com/eva/player_shared/PlayerVisualizerViewmodel.kt
@@ -3,11 +3,14 @@ package com.eva.player_shared
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
+import com.com.visualizer.data.compressFloatArray
+import com.com.visualizer.domain.AudioVisualizer
+import com.com.visualizer.domain.VisualizerState
+import com.com.visualizer.domain.exception.DecoderExistsException
import com.eva.editor.domain.AudioConfigToActionList
-import com.eva.player.data.reader.compressFloatArray
-import com.eva.player.domain.AudioVisualizer
-import com.eva.player.domain.exceptions.DecoderExistsException
+import com.eva.player_shared.util.CoroutineLifecycleOwner
import com.eva.player_shared.util.updateArrayViaConfigs
+import com.eva.recordings.domain.provider.PlayerFileProvider
import com.eva.ui.navigation.PlayerSubGraph
import com.eva.ui.viewmodel.AppViewModel
import com.eva.ui.viewmodel.UIEvents
@@ -22,44 +25,47 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class PlayerVisualizerViewmodel @Inject constructor(
private val visualizer: AudioVisualizer,
+ private val playerFileProvider: PlayerFileProvider,
private val savedStateHandle: SavedStateHandle,
) : AppViewModel() {
+ private val _lifecycleOwner by lazy { CoroutineLifecycleOwner(viewModelScope.coroutineContext) }
+
private val route: PlayerSubGraph.NavGraph
get() = savedStateHandle.toRoute()
- val audioId: Long
- get() = route.audioId
-
private val _compressedVisualization = MutableStateFlow(floatArrayOf())
+ private val _clipConfigs = MutableStateFlow(emptyList())
+
+ // basic flag
+ private var _isVisualizerStarted = false
private val _uiEvents = MutableSharedFlow()
override val uiEvent: SharedFlow
get() = _uiEvents
-
- private val _clipConfigs = MutableStateFlow(emptyList())
-
- val isVisualsReady = visualizer.isVisualReady.stateIn(
- scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(5_000L),
- initialValue = false
- )
+ val isVisualsReady = visualizer.visualizerState
+ .map { it != VisualizerState.NOT_STARTED }
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000L),
+ initialValue = false
+ )
val fullVisualization = visualizer.normalizedVisualization
.onStart { prepareVisuals() }
.stateIn(
scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(2_000),
+ started = SharingStarted.WhileSubscribed(5_000),
initialValue = floatArrayOf()
)
@@ -69,7 +75,7 @@ class PlayerVisualizerViewmodel @Inject constructor(
.onStart { updatesOnConfigChange() }
.stateIn(
scope = viewModelScope,
- started = SharingStarted.WhileSubscribed(10_000L),
+ started = SharingStarted.WhileSubscribed(5_000L),
initialValue = floatArrayOf()
)
@@ -77,15 +83,25 @@ class PlayerVisualizerViewmodel @Inject constructor(
fun updateClipConfigs(configs: AudioConfigToActionList) = _clipConfigs.update { configs }
- private fun prepareVisuals() = viewModelScope.launch {
- val result = visualizer.prepareVisualization(
- fileId = audioId,
- timePerPointInMs = RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE
- )
- result.onFailure { err ->
- if (err is DecoderExistsException) return@onFailure
- _uiEvents.emit(UIEvents.ShowSnackBar(err.message ?: ""))
- }
+ private fun prepareVisuals() {
+ // if started once don't start again
+ if (!_isVisualizerStarted) return
+ _isVisualizerStarted = true
+
+ visualizer.visualizerState.onEach { state ->
+ // only run this if the visualizer not in finished or running state
+ if (state != VisualizerState.NOT_STARTED) return@onEach
+
+ val result = visualizer.prepareVisualization(
+ lifecycleOwner = _lifecycleOwner,
+ fileUri = playerFileProvider.providesAudioFileUri(route.audioId),
+ timePerPointInMs = RecorderConstants.RECORDER_AMPLITUDES_BUFFER_SIZE
+ )
+ result.onFailure { err ->
+ if (err is DecoderExistsException) return@onFailure
+ _uiEvents.emit(UIEvents.ShowSnackBar(err.message ?: ""))
+ }
+ }.launchIn(viewModelScope)
}
@@ -101,7 +117,6 @@ class PlayerVisualizerViewmodel @Inject constructor(
override fun onCleared() {
visualizer.cleanUp()
- super.onCleared()
}
}
diff --git a/feature/player-shared/src/main/java/com/eva/player_shared/util/CoroutineLifecycleOwner.kt b/feature/player-shared/src/main/java/com/eva/player_shared/util/CoroutineLifecycleOwner.kt
new file mode 100644
index 00000000..48cdf285
--- /dev/null
+++ b/feature/player-shared/src/main/java/com/eva/player_shared/util/CoroutineLifecycleOwner.kt
@@ -0,0 +1,27 @@
+package com.eva.player_shared.util
+
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import kotlinx.coroutines.Job
+import kotlin.coroutines.CoroutineContext
+
+internal class CoroutineLifecycleOwner(context: CoroutineContext) : LifecycleOwner {
+
+ private val lifecycleRegistry = LifecycleRegistry(this)
+ .apply { currentState = Lifecycle.State.INITIALIZED }
+
+ override val lifecycle: Lifecycle
+ get() = lifecycleRegistry
+
+ init {
+ if (context[Job]?.isActive == true) {
+ lifecycleRegistry.currentState = Lifecycle.State.RESUMED
+ context[Job]?.invokeOnCompletion {
+ lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
+ }
+ } else {
+ lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
+ }
+ }
+}
\ No newline at end of file
diff --git a/feature/recorder/src/main/java/com/eva/feature_recorder/screen/RecorderViewModel.kt b/feature/recorder/src/main/java/com/eva/feature_recorder/screen/RecorderViewModel.kt
index c10590ea..57aeadd1 100644
--- a/feature/recorder/src/main/java/com/eva/feature_recorder/screen/RecorderViewModel.kt
+++ b/feature/recorder/src/main/java/com/eva/feature_recorder/screen/RecorderViewModel.kt
@@ -79,9 +79,8 @@ internal class RecorderViewModel @Inject constructor(
}
}
-
override fun onCleared() {
+ recorderService.unBindService()
recorderService.cleanUp()
- super.onCleared()
}
}
\ No newline at end of file
diff --git a/feature/recorder/src/main/java/com/eva/feature_recorder/util/RecorderDrawGraphUtil.kt b/feature/recorder/src/main/java/com/eva/feature_recorder/util/RecorderDrawGraphUtil.kt
index 32bc6bc9..f3b52f1b 100644
--- a/feature/recorder/src/main/java/com/eva/feature_recorder/util/RecorderDrawGraphUtil.kt
+++ b/feature/recorder/src/main/java/com/eva/feature_recorder/util/RecorderDrawGraphUtil.kt
@@ -64,22 +64,7 @@ internal fun DrawScope.drawRecorderTimeline(
timelineInMillis.forEachIndexed { idx, millis ->
val xAxis = spikesWidth * idx.toFloat()
if (millis % 2_000 == 0L || idx == 0) {
- val readableTime = buildString {
- val seconds = (millis / 1_000) % 1_000
- val minutes = (seconds / 60) % 60
- val hours = (minutes % 60) % 60
- if (hours > 0) {
- append("$hours".padStart(2, '0'))
- append(":")
- }
- if (minutes >= 0) {
- append("$minutes".padStart(2, '0'))
- append(":")
- }
- if (seconds >= 0) {
- append("$seconds".padStart(2, '0'))
- }
- }
+ val readableTime = toReadableDuration(millis)
if (readableTime.isNotBlank()) {
val layoutResult = textMeasurer.measure(readableTime, style = textStyle)
@@ -152,3 +137,22 @@ internal fun DrawScope.drawRecorderTimeline(
}
}
}
+
+
+private fun toReadableDuration(timeInMillis: Long) = buildString {
+ val totalSeconds = timeInMillis / 1_000
+ val hours = totalSeconds / 3_600
+ val minutes = (totalSeconds % 3_600) / 60
+ val remainingSeconds = totalSeconds % 60
+ if (hours > 0) {
+ append("$hours".padStart(2, '0'))
+ append(":")
+ }
+ if (minutes >= 0) {
+ append("$minutes".padStart(2, '0'))
+ append(":")
+ }
+ if (remainingSeconds >= 0) {
+ append("$remainingSeconds".padStart(2, '0'))
+ }
+}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 54d1c927..1e31ed25 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,7 +2,7 @@
agp = "8.13.0"
concurrentFuturesKtx = "1.3.0"
datastore = "1.1.7"
-coreSplashscreen = "1.0.1"
+coreSplashscreen = "1.2.0"
glance = "1.1.1"
graphicsShapes = "1.1.0"
hiltNavigation = "1.3.0"
@@ -10,6 +10,7 @@ kotlin = "2.2.21"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
+leakcanaryAndroid = "2.14"
runner = "1.7.0"
espressoCore = "3.7.0"
kotlinxCollectionsImmutable = "0.4.0"
@@ -17,11 +18,11 @@ kotlinxDatetime = "0.7.1"
kotlinxSerializationJson = "1.9.0"
lifecycleRuntimeKtx = "2.9.4"
activityCompose = "1.11.0"
-composeBom = "2025.10.01"
+composeBom = "2025.11.00"
ksp = "2.3.0"
hilt = "2.57.2"
media3Common = "1.8.0"
-navigationCompose = "2.9.5"
+navigationCompose = "2.9.6"
playServicesLocationVersion = "21.3.0"
roomCompiler = "2.8.3"
uiTextGoogleFonts = "1.9.4"
@@ -85,6 +86,8 @@ mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
androidx-rules = { group = "androidx.test", name = "rules", version.ref = "runner" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine_version" }
+#leaks
+leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" }
#compose
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
diff --git a/module_graph.md b/module_graph.md
index 9efe53b6..fe1d9b00 100644
--- a/module_graph.md
+++ b/module_graph.md
@@ -35,8 +35,10 @@ graph TB
:data:player["player"]
:data:bookmarks["bookmarks"]
:data:editor["editor"]
+ :data:visualizer["visualizer"]
:data:recorder["recorder"]
:data:location["location"]
+ :data:visualizer["visualizer"]
:data:use_case["use_case"]
:data:categories["categories"]
:data:database["database"]
@@ -122,6 +124,7 @@ graph TB
:feature:player-shared --> :core:utils
:feature:player-shared --> :data:player
:feature:player-shared --> :data:editor
+ :feature:player-shared --> :data:visualizer
:feature:player-shared --> :data:recordings
:feature:player-shared --> :data:interactions
:feature:player-shared --> :data:use_case
@@ -132,6 +135,8 @@ graph TB
:data:recorder --> :data:datastore
:data:recorder --> :data:recordings
:data:recorder --> :data:bookmarks
+ :data:visualizer --> :testing:runtime
+ :data:visualizer --> :data:recordings
:data:use_case --> :testing:runtime
:data:use_case --> :core:utils
:data:use_case --> :data:interactions
@@ -208,6 +213,7 @@ class :feature:player android-library
class :data:bookmarks android-library
class :data:interactions android-library
class :feature:player-shared android-library
+class :data:visualizer android-library
class :feature:onboarding android-library
class :feature:categories android-library
class :feature:editor android-library
diff --git a/settings.gradle.kts b/settings.gradle.kts
index de463b3f..fa05cfe1 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -46,3 +46,4 @@ include(":data:editor")
include(":feature:player-shared")
include(":feature:onboarding")
include(":testing:runtime")
+include(":data:visualizer")