From e775404e35822ab5e9e9307d589762138ff9dac6 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Thu, 13 Oct 2022 23:15:06 +0200 Subject: [PATCH] Improve VoiceRecorder abstraction --- .../detail/composer/AudioMessageHelper.kt | 4 +- .../composer/MessageComposerViewModel.kt | 2 +- .../features/voice/AbstractVoiceRecorder.kt | 75 ++--------- .../features/voice/AbstractVoiceRecorderQ.kt | 124 ++++++++++++++++++ .../app/features/voice/VoiceRecorder.kt | 14 +- .../app/features/voice/VoiceRecorderL.kt | 33 +---- .../app/features/voice/VoiceRecorderQ.kt | 23 +--- 7 files changed, 163 insertions(+), 112 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorderQ.kt diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt index f068330cb1..d98240904c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt @@ -55,8 +55,8 @@ class AudioMessageHelper @Inject constructor( private var amplitudeTicker: CountUpTimer? = null private var playbackTicker: CountUpTimer? = null - fun initializeRecorder(attachmentData: ContentAttachmentData) { - voiceRecorder.initializeRecord(attachmentData) + fun initializeRecorder(roomId: String, attachmentData: ContentAttachmentData) { + voiceRecorder.initializeRecord(roomId, attachmentData) amplitudeList.clear() attachmentData.waveform?.let { amplitudeList.addAll(it) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index 5d3465ab2e..6f1210a584 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -943,7 +943,7 @@ class MessageComposerViewModel @AssistedInject constructor( } private fun handleInitializeVoiceRecorder(attachmentData: ContentAttachmentData) { - audioMessageHelper.initializeRecorder(attachmentData) + audioMessageHelper.initializeRecorder(room.roomId, attachmentData) setState { copy(voiceRecordingUiState = VoiceMessageRecorderView.RecordingUiState.Draft) } } diff --git a/vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorder.kt b/vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorder.kt index b28d76f176..9755a0b3fb 100644 --- a/vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorder.kt +++ b/vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorder.kt @@ -17,77 +17,24 @@ package im.vector.app.features.voice import android.content.Context -import android.media.MediaRecorder -import android.os.Build -import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.util.md5 import java.io.File -import java.io.FileOutputStream import java.util.UUID abstract class AbstractVoiceRecorder( private val context: Context, - private val filenameExt: String, ) : VoiceRecorder { + private val outputDirectory: File by lazy { ensureAudioDirectory(context) } + protected var outputFile: File? = null - protected var mediaRecorder: MediaRecorder? = null - private var outputFile: File? = null - - abstract fun setOutputFormat(mediaRecorder: MediaRecorder) - - private fun init() { - createMediaRecorder().let { - it.setAudioSource(MediaRecorder.AudioSource.DEFAULT) - setOutputFormat(it) - it.setAudioEncodingBitRate(24000) - it.setAudioSamplingRate(48000) - mediaRecorder = it + override fun initializeRecord(roomId: String, attachmentData: ContentAttachmentData?) { + if (attachmentData != null) { + outputFile = attachmentData.findVoiceFile(outputDirectory) } } - private fun createMediaRecorder(): MediaRecorder { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - MediaRecorder(context) - } else { - @Suppress("DEPRECATION") - MediaRecorder() - } - } - - override fun initializeRecord(attachmentData: ContentAttachmentData) { - outputFile = attachmentData.findVoiceFile(outputDirectory) - } - - override fun startRecord(roomId: String) { - init() - val fileName = "${UUID.randomUUID()}.$filenameExt" - val outputDirectoryForRoom = File(outputDirectory, roomId.md5()).apply { - mkdirs() - } - outputFile = File(outputDirectoryForRoom, fileName) - - val mr = mediaRecorder ?: return - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - mr.setOutputFile(outputFile) - } else { - mr.setOutputFile(FileOutputStream(outputFile).fd) - } - mr.prepare() - mr.start() - } - - override fun stopRecord() { - mediaRecorder?.let { - // Can throw when the record is less than 1 second. - tryOrNull { it.stop() } - it.reset() - it.release() - } - mediaRecorder = null - } - override fun cancelRecord() { stopRecord() @@ -95,11 +42,15 @@ abstract class AbstractVoiceRecorder( outputFile = null } - override fun getMaxAmplitude(): Int { - return mediaRecorder?.maxAmplitude ?: 0 - } - override fun getVoiceMessageFile(): File? { return outputFile } + + protected fun createOutputFile(roomId: String): File { + val fileName = "${UUID.randomUUID()}.$fileNameExt" + val outputDirectoryForRoom = File(outputDirectory, roomId.md5()).apply { + mkdirs() + } + return File(outputDirectoryForRoom, fileName) + } } diff --git a/vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorderQ.kt b/vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorderQ.kt new file mode 100644 index 0000000000..bd30c38366 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voice/AbstractVoiceRecorderQ.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voice + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import androidx.annotation.RequiresApi +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.content.ContentAttachmentData +import java.io.File + +/** + * VoiceRecorder abstraction to be used on Android versions >= [Build.VERSION_CODES.Q]. + */ +@RequiresApi(Build.VERSION_CODES.Q) +abstract class AbstractVoiceRecorderQ(private val context: Context) : AbstractVoiceRecorder(context) { + + var mediaRecorder: MediaRecorder? = null + protected var nextOutputFile: File? = null + + private val audioSource: Int = MediaRecorder.AudioSource.DEFAULT + private val audioEncodingBitRate: Int = 24_000 + private val audioSamplingRate: Int = 48_000 + + abstract val outputFormat: Int // see MediaRecorder.OutputFormat + abstract val audioEncoder: Int // see MediaRecorder.AudioEncoder + + override fun initializeRecord(roomId: String, attachmentData: ContentAttachmentData?) { + super.initializeRecord(roomId, attachmentData) + mediaRecorder = createMediaRecorder().apply { + setAudioSource(audioSource) + setOutputFormat() + setAudioEncodingBitRate(audioEncodingBitRate) + setAudioSamplingRate(audioSamplingRate) + } + setOutputFile(roomId) + } + + override fun startRecord(roomId: String) { + initializeRecord(roomId = roomId) + mediaRecorder?.prepare() + mediaRecorder?.start() + } + + override fun pauseRecord() { + // Can throw when the record is less than 1 second. + tryOrNull { mediaRecorder?.pause() } + } + + override fun resumeRecord() { + mediaRecorder?.resume() + } + + override fun stopRecord() { + // Can throw when the record is less than 1 second. + tryOrNull { mediaRecorder?.stop() } + mediaRecorder?.reset() + release() + } + + override fun cancelRecord() { + super.cancelRecord() + nextOutputFile?.delete() + nextOutputFile = null + } + + override fun getMaxAmplitude(): Int { + return mediaRecorder?.maxAmplitude ?: 0 + } + + protected open fun release() { + mediaRecorder?.release() + mediaRecorder = null + } + + fun setNextOutputFile(roomId: String) { + val mediaRecorder = mediaRecorder ?: return + nextOutputFile = createOutputFile(roomId) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + mediaRecorder.setNextOutputFile(nextOutputFile) + } else { + mediaRecorder.setNextOutputFile(nextOutputFile?.outputStream()?.fd) + } + } + + private fun createMediaRecorder(): MediaRecorder { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + @Suppress("DEPRECATION") + MediaRecorder() + } + } + + private fun MediaRecorder.setOutputFormat() { + setOutputFormat(outputFormat) + setAudioEncoder(audioEncoder) + } + + private fun setOutputFile(roomId: String) { + val mediaRecorder = mediaRecorder ?: return + outputFile = outputFile ?: createOutputFile(roomId) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + mediaRecorder.setOutputFile(outputFile) + } else { + mediaRecorder.setOutputFile(outputFile?.outputStream()?.fd) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voice/VoiceRecorder.kt b/vector/src/main/java/im/vector/app/features/voice/VoiceRecorder.kt index 761aad2334..bf38e4adbf 100644 --- a/vector/src/main/java/im/vector/app/features/voice/VoiceRecorder.kt +++ b/vector/src/main/java/im/vector/app/features/voice/VoiceRecorder.kt @@ -22,11 +22,19 @@ import org.matrix.android.sdk.api.session.content.ContentAttachmentData import java.io.File interface VoiceRecorder { + /** - * Initialize recording with a pre-recorded file. - * @param attachmentData data of the recorded file + * Audio file extension (eg. `mp4`). */ - fun initializeRecord(attachmentData: ContentAttachmentData) + val fileNameExt: String + + /** + * Initialize recording with an optional pre-recorded file. + * + * @param roomId id of the room to initialize record + * @param attachmentData data of the pre-recorded file, if any. + */ + fun initializeRecord(roomId: String, attachmentData: ContentAttachmentData? = null) /** * Start the recording. diff --git a/vector/src/main/java/im/vector/app/features/voice/VoiceRecorderL.kt b/vector/src/main/java/im/vector/app/features/voice/VoiceRecorderL.kt index 1460f1a88f..13ddf60620 100644 --- a/vector/src/main/java/im/vector/app/features/voice/VoiceRecorderL.kt +++ b/vector/src/main/java/im/vector/app/features/voice/VoiceRecorderL.kt @@ -31,10 +31,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.matrix.android.sdk.api.extensions.tryOrNull -import org.matrix.android.sdk.api.session.content.ContentAttachmentData -import org.matrix.android.sdk.api.util.md5 -import java.io.File -import java.util.UUID import kotlin.coroutines.CoroutineContext /** @@ -44,16 +40,13 @@ class VoiceRecorderL( private val context: Context, coroutineContext: CoroutineContext, private val codec: OggOpusEncoder, -) : VoiceRecorder { +) : AbstractVoiceRecorder(context) { companion object { private val SAMPLE_RATE = SampleRate.Rate48kHz private const val BITRATE = 24 * 1024 } - private val outputDirectory: File by lazy { ensureAudioDirectory(context) } - private var outputFile: File? = null - private val recorderScope = CoroutineScope(coroutineContext) private var recordingJob: Job? = null @@ -65,6 +58,8 @@ class VoiceRecorderL( private var bufferSizeInShorts = 0 private var maxAmplitude = 0 + override val fileNameExt: String = "ogg" + private fun initializeCodec(filePath: String) { codec.init(filePath, SAMPLE_RATE) codec.setBitrate(BITRATE) @@ -86,19 +81,10 @@ class VoiceRecorderL( } } - override fun initializeRecord(attachmentData: ContentAttachmentData) { - outputFile = attachmentData.findVoiceFile(outputDirectory) - } - override fun startRecord(roomId: String) { - val fileName = "${UUID.randomUUID()}.ogg" - val outputDirectoryForRoom = File(outputDirectory, roomId.md5()).apply { - mkdirs() + outputFile = createOutputFile(roomId).also { + initializeCodec(it.absolutePath) } - val outputFile = File(outputDirectoryForRoom, fileName) - this.outputFile = outputFile - - initializeCodec(outputFile.absolutePath) recordingJob = recorderScope.launch { audioRecorder?.startRecording() @@ -140,19 +126,10 @@ class VoiceRecorderL( codec.release() } - override fun cancelRecord() { - outputFile?.delete() - outputFile = null - } - override fun getMaxAmplitude(): Int { return maxAmplitude } - override fun getVoiceMessageFile(): File? { - return outputFile - } - private fun createAudioRecord() { val channelConfig = AudioFormat.CHANNEL_IN_MONO val format = AudioFormat.ENCODING_PCM_16BIT diff --git a/vector/src/main/java/im/vector/app/features/voice/VoiceRecorderQ.kt b/vector/src/main/java/im/vector/app/features/voice/VoiceRecorderQ.kt index 1eb850b8f5..f128673e27 100644 --- a/vector/src/main/java/im/vector/app/features/voice/VoiceRecorderQ.kt +++ b/vector/src/main/java/im/vector/app/features/voice/VoiceRecorderQ.kt @@ -20,26 +20,17 @@ import android.content.Context import android.media.MediaRecorder import android.os.Build import androidx.annotation.RequiresApi -import org.matrix.android.sdk.api.extensions.tryOrNull /** - * VoiceRecorder to be used on Android versions >= [Build.VERSION_CODES.Q]. It uses the native OPUS support on Android 10+. + * VoiceRecorder to be used on Android versions >= [Build.VERSION_CODES.Q]. + * It uses the native OPUS support on Android 10+. */ @RequiresApi(Build.VERSION_CODES.Q) -class VoiceRecorderQ(context: Context) : AbstractVoiceRecorder(context, "ogg") { +class VoiceRecorderQ(context: Context) : AbstractVoiceRecorderQ(context) { - override fun pauseRecord() { - // Can throw when the record is less than 1 second. - tryOrNull { mediaRecorder?.pause() } - } + // We can directly use OGG here + override val outputFormat = MediaRecorder.OutputFormat.OGG + override val audioEncoder = MediaRecorder.AudioEncoder.OPUS - override fun resumeRecord() { - mediaRecorder?.resume() - } - - override fun setOutputFormat(mediaRecorder: MediaRecorder) { - // We can directly use OGG here - mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.OGG) - mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS) - } + override val fileNameExt: String = "ogg" }