Record and send voice messages (#1596)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew 2023-10-23 18:28:00 +01:00 committed by GitHub
parent da345f9afa
commit 5c582bba1b
68 changed files with 2274 additions and 82 deletions

View file

@ -0,0 +1,132 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl
import android.Manifest
import androidx.annotation.RequiresPermission
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.libraries.voicerecorder.impl.audio.Audio
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
import io.element.android.libraries.voicerecorder.impl.audio.Encoder
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import java.util.UUID
import javax.inject.Inject
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class VoiceRecorderImpl @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val audioReaderFactory: AudioReader.Factory,
private val encoder: Encoder,
private val fileManager: VoiceFileManager,
private val config: AudioConfig,
private val fileConfig: VoiceFileConfig,
private val audioLevelCalculator: AudioLevelCalculator,
appCoroutineScope: CoroutineScope,
) : VoiceRecorder {
private val voiceCoroutineScope by lazy {
appCoroutineScope.childScope(dispatchers.io, "VoiceRecorder-${UUID.randomUUID()}")
}
private var outputFile: File? = null
private var audioReader: AudioReader? = null
private var recordingJob: Job? = null
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
override val state: StateFlow<VoiceRecorderState> = _state
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
override suspend fun startRecord() {
Timber.i("Voice recorder started recording")
outputFile = fileManager.createFile()
.also(encoder::init)
val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it }
recordingJob = voiceCoroutineScope.launch {
audioRecorder.record { audio ->
when (audio) {
is Audio.Data -> {
val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer)
_state.emit(VoiceRecorderState.Recording(audioLevel))
encoder.encode(audio.buffer, audio.readSize)
}
is Audio.Error -> {
Timber.e("Voice message error: code=${audio.audioRecordErrorCode}")
_state.emit(VoiceRecorderState.Recording(0.0))
}
}
}
}
}
/**
* Stop the current recording.
*
* Call [deleteRecording] to delete any recorded audio.
*/
override suspend fun stopRecord(
cancelled: Boolean
) {
recordingJob?.cancel()?.also {
Timber.i("Voice recorder stopped recording")
}
recordingJob = null
audioReader?.stop()
audioReader = null
encoder.release()
if (cancelled) {
deleteRecording()
}
_state.emit(
when (val file = outputFile) {
null -> VoiceRecorderState.Idle
else -> VoiceRecorderState.Finished(file, fileConfig.mimeType)
}
)
}
/**
* Stop the current recording and delete the output file.
*/
override suspend fun deleteRecording() {
outputFile?.let(fileManager::deleteFile)?.also {
Timber.i("Voice recorder deleted recording")
}
outputFile = null
_state.emit(VoiceRecorderState.Idle)
}
}

View file

@ -0,0 +1,139 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.audio
import android.Manifest
import android.media.AudioRecord
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import androidx.annotation.RequiresPermission
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.di.RoomScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
class AndroidAudioReader
@RequiresPermission(Manifest.permission.RECORD_AUDIO) private constructor(
private val config: AudioConfig,
private val dispatchers: CoroutineDispatchers,
) : AudioReader {
private val audioRecord: AudioRecord
private var noiseSuppressor: NoiseSuppressor? = null
private var automaticGainControl: AutomaticGainControl? = null
private val outputBuffer: ShortArray
init {
outputBuffer = createOutputBuffer(config.sampleRate)
audioRecord = AudioRecord.Builder().setAudioSource(config.source).setAudioFormat(config.format).setBufferSizeInBytes(outputBuffer.sizeInBytes()).build()
noiseSuppressor = requestNoiseSuppressor(audioRecord)
automaticGainControl = requestAutomaticGainControl(audioRecord)
}
/**
* Record audio data continuously.
*
* @param onAudio callback when audio is read.
*/
override suspend fun record(
onAudio: suspend (Audio) -> Unit,
) {
audioRecord.startRecording()
withContext(dispatchers.io) {
while (isActive) {
if (audioRecord.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
break
}
onAudio(read())
}
}
}
private fun read(): Audio {
val result = audioRecord.read(outputBuffer, 0, outputBuffer.size)
if (isAudioRecordErrorResult(result)) {
return Audio.Error(result)
}
return Audio.Data(
result,
outputBuffer,
)
}
override fun stop() {
if (audioRecord.state == AudioRecord.STATE_INITIALIZED) {
audioRecord.stop()
}
audioRecord.release()
noiseSuppressor?.release()
noiseSuppressor = null
automaticGainControl?.release()
automaticGainControl = null
}
private fun createOutputBuffer(sampleRate: SampleRate): ShortArray {
val bufferSizeInShorts = AudioRecord.getMinBufferSize(
sampleRate.hz,
config.format.channelMask,
config.format.encoding
)
return ShortArray(bufferSizeInShorts)
}
private fun requestNoiseSuppressor(audioRecord: AudioRecord): NoiseSuppressor? {
if (!NoiseSuppressor.isAvailable()) {
return null
}
return tryOrNull {
NoiseSuppressor.create(audioRecord.audioSessionId).apply {
enabled = true
}
}
}
private fun requestAutomaticGainControl(audioRecord: AudioRecord): AutomaticGainControl? {
if (!AutomaticGainControl.isAvailable()) {
return null
}
return tryOrNull {
AutomaticGainControl.create(audioRecord.audioSessionId).apply {
enabled = true
}
}
}
@ContributesBinding(RoomScope::class)
companion object Factory : AudioReader.Factory {
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AndroidAudioReader {
return AndroidAudioReader(config, dispatchers)
}
}
}
private fun isAudioRecordErrorResult(result: Int): Boolean {
return result < 0
}
private fun ShortArray.sizeInBytes(): Int = size * Short.SIZE_BYTES

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.audio
sealed class Audio {
class Data(
val readSize: Int,
val buffer: ShortArray,
) : Audio()
data class Error(
val audioRecordErrorCode: Int
) : Audio()
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.audio
import android.media.AudioFormat
import android.media.MediaRecorder.AudioSource
/**
* Audio configuration for voice recording.
*
* @property source the audio source to use, see constants in [AudioSource]
* @property format the audio format to use, see [AudioFormat]
* @property sampleRate the sample rate to use. Ensure this matches the value set in [format].
* @property bitRate the bitrate in bps
*/
data class AudioConfig(
val source: Int,
val format: AudioFormat,
val sampleRate: SampleRate,
val bitRate: Int,
)

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.audio
interface AudioLevelCalculator {
/**
* Calculate the audio level of the audio buffer.
*
* @param buffer The audio buffer containing raw audio data.
*
* @return A value between 0 and 1.
*/
fun calculateAudioLevel(buffer: ShortArray): Double
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.audio
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
interface AudioReader {
/**
* Record audio data continuously.
*
* @param onAudio callback when audio is read.
*/
suspend fun record(
onAudio: suspend (Audio) -> Unit,
)
fun stop()
interface Factory {
fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.audio
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
import javax.inject.Inject
import kotlin.math.log10
import kotlin.math.min
import kotlin.math.sqrt
@ContributesBinding(RoomScope::class)
class DecibelAudioLevelCalculator @Inject constructor() : AudioLevelCalculator {
companion object {
private const val REFERENCE_DB = 50.0 // Reference dB for normal conversation
}
override fun calculateAudioLevel(buffer: ShortArray): Double {
val rms = buffer.rootMeanSquare()
// Convert to decibels and clip
val db = 20 * log10(rms / REFERENCE_DB)
val clipped = min(db, REFERENCE_DB)
// Scale to the range [0.0, 1.0]
return clipped / REFERENCE_DB
}
private fun ShortArray.rootMeanSquare(): Double {
// Use Double to avoid overflow
val sumOfSquares: Double = sumOf { it.toDouble() * it.toDouble() }
val avgSquare = sumOfSquares / size.toDouble()
return sqrt(avgSquare)
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.audio
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.opusencoder.OggOpusEncoder
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import javax.inject.Provider
/**
* Safe wrapper for OggOpusEncoder.
*/
@ContributesBinding(RoomScope::class)
class DefaultEncoder @Inject constructor(
private val encoderProvider: Provider<OggOpusEncoder>,
config: AudioConfig,
) : Encoder {
private val bitRate = config.bitRate
private val sampleRate = config.sampleRate.asEncoderModel()
private var encoder: OggOpusEncoder? = null
override fun init(
file: File,
) {
encoder?.release()
encoder = encoderProvider.get().apply {
init(file.absolutePath, sampleRate)
setBitrate(bitRate)
// TODO check encoder application: 2048 (voice, default is typically 2049 as audio)
}
}
override fun encode(
buffer: ShortArray,
readSize: Int,
) {
encoder?.encode(buffer, readSize)
?: Timber.w("Can't encode when encoder not initialized")
}
override fun release() {
encoder?.release()
?: Timber.w("Can't release encoder that is not initialized")
encoder = null
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.audio
import java.io.File
interface Encoder {
fun init(file: File)
fun encode(buffer: ShortArray, readSize: Int)
fun release()
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.audio
import io.element.android.opusencoder.configuration.SampleRate as LibOpusOggSampleRate
data object SampleRate {
const val hz = 48_000
fun asEncoderModel() = LibOpusOggSampleRate.Rate48kHz
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.di
import android.media.AudioFormat
import android.media.MediaRecorder
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
import io.element.android.libraries.voicerecorder.impl.audio.SampleRate
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
import io.element.android.opusencoder.OggOpusEncoder
@Module
@ContributesTo(RoomScope::class)
object VoiceRecorderModule {
@Provides
fun provideAudioConfig(): AudioConfig {
val sampleRate = SampleRate
return AudioConfig(
format = AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(sampleRate.hz)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build(),
bitRate = 24_000, // 24 kbps
sampleRate = sampleRate,
source = MediaRecorder.AudioSource.MIC,
)
}
@Provides
fun provideVoiceFileConfig(): VoiceFileConfig =
VoiceFileConfig(
cacheSubdir = "voice_recordings",
fileExt = "ogg",
mimeType = "audio/ogg",
)
@Provides
fun provideOggOpusEncoder(): OggOpusEncoder = OggOpusEncoder.create()
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.file
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.hash.md5
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import java.io.File
import java.util.UUID
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultVoiceFileManager @Inject constructor(
@CacheDirectory private val cacheDir: File,
private val config: VoiceFileConfig,
room: MatrixRoom,
) : VoiceFileManager {
private val roomId: RoomId = room.roomId
override fun createFile(): File {
val fileName = "${UUID.randomUUID()}.${config.fileExt}"
val outputDirectory = File(cacheDir, config.cacheSubdir)
val roomDir = File(outputDirectory, roomId.value.md5())
.apply(File::mkdirs)
return File(roomDir, fileName)
}
override fun deleteFile(file: File) {
file.delete()
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.file
/**
* File configuration for voice recording.
*
* @property cacheSubdir the subdirectory in the cache dir to use.
* @property fileExt the file extension for audio files.
* @property mimeType the mime type of audio files.
*/
data class VoiceFileConfig(
val cacheSubdir: String,
val fileExt: String,
val mimeType: String,
)

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.file
import java.io.File
interface VoiceFileManager {
fun createFile(): File
fun deleteFile(file: File)
}

View file

@ -0,0 +1,134 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl
import android.media.AudioFormat
import android.media.MediaRecorder
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.libraries.voicerecorder.impl.audio.Audio
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
import io.element.android.libraries.voicerecorder.impl.audio.SampleRate
import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderModule
import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator
import io.element.android.libraries.voicerecorder.test.FakeAudioRecorderFactory
import io.element.android.libraries.voicerecorder.test.FakeEncoder
import io.element.android.libraries.voicerecorder.test.FakeFileSystem
import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.BeforeClass
import org.junit.Test
import java.io.File
class VoiceRecorderImplTest {
private val fakeFileSystem = FakeFileSystem()
@Test
fun `it emits the initial state`() = runTest {
val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
}
}
@Test
fun `when recording, it emits the recording state`() = runTest {
val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0))
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.0))
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0))
}
}
@Test
fun `when stopped, it provides a file`() = runTest {
val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
skipItems(3)
voiceRecorder.stopRecord()
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Finished(File(FILE_PATH), "audio/ogg"))
assertThat(fakeFileSystem.files[File(FILE_PATH)]).isEqualTo(ENCODED_DATA)
}
}
@Test
fun `when cancelled, it deletes the file`() = runTest {
val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
skipItems(3)
voiceRecorder.stopRecord(cancelled = true)
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
assertThat(fakeFileSystem.files[File(FILE_PATH)]).isNull()
}
}
private fun TestScope.createVoiceRecorder(): VoiceRecorderImpl {
val fileConfig = VoiceRecorderModule.provideVoiceFileConfig()
return VoiceRecorderImpl(
dispatchers = testCoroutineDispatchers(),
audioReaderFactory = FakeAudioRecorderFactory(
audio = AUDIO,
),
encoder = FakeEncoder(fakeFileSystem),
config = AudioConfig(
format = AUDIO_FORMAT,
bitRate = 24_000, // 24 kbps
sampleRate = SampleRate,
source = MediaRecorder.AudioSource.MIC,
),
fileConfig = fileConfig,
fileManager = FakeVoiceFileManager(fakeFileSystem, fileConfig, FILE_ID),
audioLevelCalculator = FakeAudioLevelCalculator(),
appCoroutineScope = backgroundScope,
)
}
companion object {
const val FILE_ID: String = "recording"
const val FILE_PATH = "voice_recordings/${FILE_ID}.ogg"
private lateinit var AUDIO_FORMAT: AudioFormat
// FakeEncoder doesn't actually encode, it just writes the data to the file
private const val ENCODED_DATA = "[32767, 32767, 32767][32767, 32767, 32767]"
private const val MAX_AMP = Short.MAX_VALUE
private val AUDIO = listOf(
Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)),
Audio.Error(-1),
Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)),
)
@BeforeClass
@JvmStatic
fun initAudioFormat() {
AUDIO_FORMAT = mockk()
}
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.impl.audio
import org.junit.Test
class DecibelAudioLevelCalculatorTest {
@Test
fun `given max values, it returns values within range`() {
val calculator = DecibelAudioLevelCalculator()
val buffer = ShortArray(100) { Short.MAX_VALUE }
val level = calculator.calculateAudioLevel(buffer)
assert(level in 0.0..1.0)
}
@Test
fun `given mixed values, it returns values within range`() {
val calculator = DecibelAudioLevelCalculator()
val buffer = shortArrayOf(Short.MAX_VALUE, Short.MIN_VALUE, -1, 1)
val level = calculator.calculateAudioLevel(buffer)
assert(level in 0.0..1.0)
}
@Test
fun `given min values, it returns values within range`() {
val calculator = DecibelAudioLevelCalculator()
val buffer = ShortArray(100) { Short.MIN_VALUE }
val level = calculator.calculateAudioLevel(buffer)
assert(level in 0.0..1.0)
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.test
import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator
import kotlin.math.abs
class FakeAudioLevelCalculator: AudioLevelCalculator {
override fun calculateAudioLevel(buffer: ShortArray): Double {
return buffer.map { abs(it.toDouble()) }.average() / Short.MAX_VALUE
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.test
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.voicerecorder.impl.audio.Audio
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
class FakeAudioReader(
private val dispatchers: CoroutineDispatchers,
private val audio: List<Audio>,
) : AudioReader {
private var isRecording = false
override suspend fun record(onAudio: suspend (Audio) -> Unit) {
isRecording = true
withContext(dispatchers.io) {
val audios = audio.iterator()
while (audios.hasNext()) {
if (!isRecording) break
onAudio(audios.next())
}
while (isActive) {
// do not return from the coroutine until it is cancelled
yield()
}
}
}
override fun stop() {
isRecording = false
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.test
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.voicerecorder.impl.audio.Audio
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
class FakeAudioRecorderFactory(
private val audio: List<Audio>
): AudioReader.Factory {
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader {
return FakeAudioReader(dispatchers, audio)
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.test
import io.element.android.libraries.voicerecorder.impl.audio.Encoder
import java.io.File
class FakeEncoder(
private val fakeFileSystem: FakeFileSystem
) : Encoder {
private var curFile: File? = null
override fun init(file: File) {
curFile = file
}
override fun encode(buffer: ShortArray, readSize: Int) {
val file = curFile
?: error("Encoder not initialized")
fakeFileSystem.appendToFile(file, buffer, readSize)
}
override fun release() {
curFile = null
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.test
import java.io.File
class FakeFileSystem {
// Map of file to file content
val files = mutableMapOf<File, String>()
fun createFile(file: File) {
if(files.containsKey(file)) {
return
}
files[file] = ""
}
fun appendToFile(file: File, buffer: ShortArray, readSize: Int) {
val content = files[file]
?: error("File ${file.path} does not exist")
files[file] = content + buffer.sliceArray(0 until readSize).contentToString()
}
fun deleteFile(file: File) {
files.remove(file)
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 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 io.element.android.libraries.voicerecorder.test
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileManager
import java.io.File
class FakeVoiceFileManager(
private val fakeFileSystem: FakeFileSystem,
private val config: VoiceFileConfig,
private val fileId: String,
) : VoiceFileManager {
override fun createFile(): File {
val file = File("${config.cacheSubdir}/$fileId.${config.fileExt}")
fakeFileSystem.createFile(file)
return file
}
override fun deleteFile(file: File) {
fakeFileSystem.deleteFile(file)
}
}