Record voice on Android 21

This commit is contained in:
Benoit Marty 2021-07-15 17:04:10 +02:00
parent 6f947e979b
commit bfc70be5bb
7 changed files with 310 additions and 66 deletions

View file

@ -144,7 +144,7 @@ android {
buildConfigField "im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy", "outboundSessionKeySharingStrategy", "im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy.WhenTyping" buildConfigField "im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy", "outboundSessionKeySharingStrategy", "im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy.WhenTyping"
buildConfigField "Long", "VOICE_MESSAGE_DURATION_LIMIT_MS", "120000L" buildConfigField "Long", "VOICE_MESSAGE_DURATION_LIMIT_MS", "120_000L"
// If set, MSC3086 asserted identity messages sent on VoIP calls will cause the call to appear in the room corresponding to the asserted identity. // If set, MSC3086 asserted identity messages sent on VoIP calls will cause the call to appear in the room corresponding to the asserted identity.
// This *must* only be set in trusted environments. // This *must* only be set in trusted environments.
@ -411,6 +411,9 @@ dependencies {
// Passphrase strength helper // Passphrase strength helper
implementation 'com.nulab-inc:zxcvbn:1.5.2' implementation 'com.nulab-inc:zxcvbn:1.5.2'
// To convert voice message on old platforms
implementation 'com.arthenica:ffmpeg-kit-audio:4.4.LTS'
//Alerter //Alerter
implementation 'com.tapadoo.android:alerter:7.0.1' implementation 'com.tapadoo.android:alerter:7.0.1'

View file

@ -19,13 +19,13 @@ package im.vector.app.features.home.room.detail.composer
import android.content.Context import android.content.Context
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.MediaPlayer import android.media.MediaPlayer
import android.media.MediaRecorder
import android.os.Build
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.core.utils.CountUpTimer import im.vector.app.core.utils.CountUpTimer
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.voice.VoiceFailure
import im.vector.app.features.voice.VoiceRecorder
import im.vector.app.features.voice.VoiceRecorderProvider
import im.vector.lib.multipicker.entity.MultiPickerAudioType import im.vector.lib.multipicker.entity.MultiPickerAudioType
import im.vector.lib.multipicker.utils.toMultiPickerAudioType import im.vector.lib.multipicker.utils.toMultiPickerAudioType
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
@ -34,7 +34,6 @@ import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.FileOutputStream
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -42,54 +41,24 @@ import javax.inject.Inject
*/ */
class VoiceMessageHelper @Inject constructor( class VoiceMessageHelper @Inject constructor(
private val context: Context, private val context: Context,
private val playbackTracker: VoiceMessagePlaybackTracker private val playbackTracker: VoiceMessagePlaybackTracker,
voiceRecorderProvider: VoiceRecorderProvider
) { ) {
private var mediaPlayer: MediaPlayer? = null private var mediaPlayer: MediaPlayer? = null
private var mediaRecorder: MediaRecorder? = null private var voiceRecorder: VoiceRecorder = voiceRecorderProvider.provideVoiceRecorder()
private val outputDirectory = File(context.cacheDir, "downloads")
private var outputFile: File? = null
private var lastRecordingFile: File? = null // In case of user pauses recording, plays another one in timeline
private val amplitudeList = mutableListOf<Int>() private val amplitudeList = mutableListOf<Int>()
private var amplitudeTicker: CountUpTimer? = null private var amplitudeTicker: CountUpTimer? = null
private var playbackTicker: CountUpTimer? = null private var playbackTicker: CountUpTimer? = null
init {
if (!outputDirectory.exists()) {
outputDirectory.mkdirs()
}
}
private fun initMediaRecorder() {
MediaRecorder().let {
it.setAudioSource(MediaRecorder.AudioSource.DEFAULT)
it.setOutputFormat(MediaRecorder.OutputFormat.OGG)
it.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
it.setAudioEncodingBitRate(24000)
it.setAudioSamplingRate(48000)
mediaRecorder = it
}
}
fun startRecording() { fun startRecording() {
stopPlayback() stopPlayback()
playbackTracker.makeAllPlaybacksIdle() playbackTracker.makeAllPlaybacksIdle()
outputFile = File(outputDirectory, "Voice message.ogg")
lastRecordingFile = outputFile
amplitudeList.clear() amplitudeList.clear()
try { try {
initMediaRecorder() voiceRecorder.startRecord()
val mr = mediaRecorder!!
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
mr.setOutputFile(outputFile)
} else {
mr.setOutputFile(FileOutputStream(outputFile).fd)
}
mr.prepare()
mr.start()
} catch (failure: Throwable) { } catch (failure: Throwable) {
throw VoiceFailure.UnableToRecord(failure) throw VoiceFailure.UnableToRecord(failure)
} }
@ -97,9 +66,16 @@ class VoiceMessageHelper @Inject constructor(
} }
fun stopRecording(): MultiPickerAudioType? { fun stopRecording(): MultiPickerAudioType? {
internalStopRecording() tryOrNull("Cannot stop media recording amplitude") {
stopRecordingAmplitudes()
}
val voiceMessageFile = tryOrNull("Cannot stop media recorder!") {
voiceRecorder.stopRecord()
voiceRecorder.getVoiceMessageFile()
}
try { try {
outputFile?.let { // TODO Improve this
voiceMessageFile?.let {
val outputFileUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", it) val outputFileUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", it)
return outputFileUri return outputFileUri
?.toMultiPickerAudioType(context) ?.toMultiPickerAudioType(context)
@ -113,38 +89,24 @@ class VoiceMessageHelper @Inject constructor(
} }
} }
private fun internalStopRecording() { /**
* When entering in playback mode actually
*/
fun pauseRecording() {
voiceRecorder.stopRecord()
}
fun deleteRecording() {
tryOrNull("Cannot stop media recording amplitude") { tryOrNull("Cannot stop media recording amplitude") {
stopRecordingAmplitudes() stopRecordingAmplitudes()
} }
tryOrNull("Cannot stop media recorder!") { tryOrNull("Cannot stop media recorder!") {
// Usually throws when the record is less than 1 second. voiceRecorder.cancelRecord()
releaseMediaRecorder()
} }
} }
private fun releaseMediaRecorder() {
mediaRecorder?.let {
it.stop()
it.reset()
it.release()
}
mediaRecorder = null
}
fun pauseRecording() {
releaseMediaRecorder()
}
fun deleteRecording() {
internalStopRecording()
outputFile?.delete()
outputFile = null
}
fun startOrPauseRecordingPlayback() { fun startOrPauseRecordingPlayback() {
lastRecordingFile?.let { voiceRecorder.getCurrentRecord()?.let {
startOrPausePlayback(VoiceMessagePlaybackTracker.RECORDING_ID, it) startOrPausePlayback(VoiceMessagePlaybackTracker.RECORDING_ID, it)
} }
} }
@ -201,9 +163,8 @@ class VoiceMessageHelper @Inject constructor(
} }
private fun onAmplitudeTick() { private fun onAmplitudeTick() {
val mr = mediaRecorder ?: return
try { try {
val maxAmplitude = mr.maxAmplitude val maxAmplitude = voiceRecorder.getMaxAmplitude()
amplitudeList.add(maxAmplitude) amplitudeList.add(maxAmplitude)
playbackTracker.updateCurrentRecording(VoiceMessagePlaybackTracker.RECORDING_ID, amplitudeList) playbackTracker.updateCurrentRecording(VoiceMessagePlaybackTracker.RECORDING_ID, amplitudeList)
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {

View file

@ -0,0 +1,95 @@
/*
* 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 java.io.File
import java.io.FileOutputStream
abstract class AbstractVoiceRecorder(
context: Context,
private val filenameExt: String
) : VoiceRecorder {
private val outputDirectory = File(context.cacheDir, "voice_records")
private var mediaRecorder: MediaRecorder? = null
private var outputFile: File? = null
init {
if (!outputDirectory.exists()) {
outputDirectory.mkdirs()
}
}
abstract fun setOutputFormat(mediaRecorder: MediaRecorder)
abstract fun convertFile(recordedFile: File?): File?
private fun init() {
MediaRecorder().let {
it.setAudioSource(MediaRecorder.AudioSource.DEFAULT)
setOutputFormat(it)
it.setAudioEncodingBitRate(24000)
it.setAudioSamplingRate(48000)
mediaRecorder = it
}
}
override fun startRecord() {
init()
outputFile = File(outputDirectory, "Voice message.$filenameExt")
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() {
// Can throw when the record is less than 1 second.
mediaRecorder?.let {
it.stop()
it.reset()
it.release()
}
mediaRecorder = null
}
override fun cancelRecord() {
stopRecord()
outputFile?.delete()
outputFile = null
}
override fun getMaxAmplitude(): Int {
return mediaRecorder?.maxAmplitude ?: 0
}
override fun getCurrentRecord(): File? {
return outputFile
}
override fun getVoiceMessageFile(): File? {
return convertFile(outputFile)
}
}

View file

@ -0,0 +1,48 @@
/*
* 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 java.io.File
interface VoiceRecorder {
/**
* Start the recording
*/
fun startRecord()
/**
* Stop the recording
*/
fun stopRecord()
/**
* Remove the file
*/
fun cancelRecord()
fun getMaxAmplitude(): Int
/**
* Not guaranteed to be a ogg file
*/
fun getCurrentRecord(): File?
/**
* Guaranteed to be a ogg file
*/
fun getVoiceMessageFile(): File?
}

View file

@ -0,0 +1,67 @@
/*
* 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 com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.Level
import com.arthenica.ffmpegkit.ReturnCode
import im.vector.app.BuildConfig
import timber.log.Timber
import java.io.File
class VoiceRecorderL(context: Context) : AbstractVoiceRecorder(context, "mp4") {
override fun setOutputFormat(mediaRecorder: MediaRecorder) {
// Use AAC/MP4 format here
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
}
override fun convertFile(recordedFile: File?): File? {
if (BuildConfig.DEBUG) {
FFmpegKitConfig.setLogLevel(Level.AV_LOG_INFO)
}
recordedFile ?: return null
// Convert to OGG
val targetFile = File(recordedFile.path.removeSuffix("mp4") + "ogg")
if (targetFile.exists()) {
targetFile.delete()
}
val start = System.currentTimeMillis()
val session = FFmpegKit.execute("-i \"${recordedFile.path}\" -c:a libvorbis \"${targetFile.path}\"")
val duration = System.currentTimeMillis() - start
Timber.d("Convert to ogg in $duration ms. Size in bytes from ${recordedFile.length()} to ${targetFile.length()}")
return when {
ReturnCode.isSuccess(session.returnCode) -> {
// SUCCESS
targetFile
}
ReturnCode.isCancel(session.returnCode) -> {
// CANCEL
null
}
else -> {
// FAILURE
Timber.e("Command failed with state ${session.state} and rc ${session.returnCode}.${session.failStackTrace}")
// TODO throw?
null
}
}
}
}

View file

@ -0,0 +1,33 @@
/*
* 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.os.Build
import javax.inject.Inject
class VoiceRecorderProvider @Inject constructor(
private val context: Context
) {
fun provideVoiceRecorder(): VoiceRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
VoiceRecorderQ(context)
} else {
VoiceRecorderL(context)
}
}
}

View file

@ -0,0 +1,37 @@
/*
* 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 java.io.File
@RequiresApi(Build.VERSION_CODES.Q)
class VoiceRecorderQ(context: Context) : AbstractVoiceRecorder(context, "ogg") {
override fun setOutputFormat(mediaRecorder: MediaRecorder) {
// We can directly use OGG here
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.OGG)
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS)
}
override fun convertFile(recordedFile: File?): File? {
// Nothing to do here
return recordedFile
}
}