jonnyandrew
11 months ago
committed by
GitHub
68 changed files with 2271 additions and 79 deletions
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
Record and send voice messages |
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<!-- |
||||
~ 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. |
||||
--> |
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" /> |
||||
</manifest> |
@ -0,0 +1,26 @@
@@ -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.features.messages.impl.voicemessages |
||||
|
||||
internal sealed class VoiceMessageException : Exception() { |
||||
data class FileException( |
||||
override val message: String?, override val cause: Throwable? = null |
||||
) : VoiceMessageException() |
||||
data class PermissionMissing( |
||||
override val message: String?, override val cause: Throwable? |
||||
) : VoiceMessageException() |
||||
} |
@ -0,0 +1,37 @@
@@ -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.features.messages.impl.voicemessages |
||||
|
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.res.stringResource |
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog |
||||
import io.element.android.libraries.ui.strings.CommonStrings |
||||
|
||||
@Composable |
||||
internal fun VoiceMessagePermissionRationaleDialog( |
||||
onContinue: () -> Unit, |
||||
onDismiss: () -> Unit, |
||||
appName: String, |
||||
) { |
||||
ConfirmationDialog( |
||||
content = stringResource(CommonStrings.error_missing_microphone_voice_rationale_android, appName), |
||||
onSubmitClicked = onContinue, |
||||
onDismiss = onDismiss, |
||||
submitText = stringResource(CommonStrings.action_continue), |
||||
cancelText = stringResource(CommonStrings.action_cancel), |
||||
) |
||||
} |
@ -0,0 +1,35 @@
@@ -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.core.hash |
||||
|
||||
import java.security.MessageDigest |
||||
import java.util.Locale |
||||
|
||||
/** |
||||
* Compute a Hash of a String, using md5 algorithm. |
||||
*/ |
||||
fun String.md5() = try { |
||||
val digest = MessageDigest.getInstance("md5") |
||||
val locale = Locale.ROOT |
||||
digest.update(toByteArray()) |
||||
digest.digest() |
||||
.joinToString("") { String.format(locale, "%02X", it) } |
||||
.lowercase(locale) |
||||
} catch (exc: Exception) { |
||||
// Should not happen, but just in case |
||||
hashCode().toString() |
||||
} |
@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
|
||||
/* |
||||
* 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.textcomposer.components |
||||
|
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.fillMaxHeight |
||||
import androidx.compose.foundation.layout.fillMaxWidth |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.heightIn |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.shape.CircleShape |
||||
import androidx.compose.material3.MaterialTheme |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.unit.dp |
||||
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||
import io.element.android.libraries.designsystem.theme.components.Text |
||||
import io.element.android.libraries.theme.ElementTheme |
||||
|
||||
@Composable |
||||
internal fun VoiceMessageRecording( |
||||
level: Double, |
||||
modifier: Modifier = Modifier, |
||||
) { |
||||
Row( |
||||
modifier = modifier |
||||
.fillMaxWidth() |
||||
.background( |
||||
color = ElementTheme.colors.bgSubtleSecondary, |
||||
shape = MaterialTheme.shapes.medium, |
||||
) |
||||
.padding(start = 12.dp, end = 20.dp, top = 8.dp, bottom = 8.dp) |
||||
.heightIn(26.dp), |
||||
verticalAlignment = Alignment.CenterVertically, |
||||
) { |
||||
Box( |
||||
modifier = Modifier |
||||
.size(8.dp) |
||||
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape) |
||||
) |
||||
Spacer(Modifier.size(8.dp)) |
||||
|
||||
// TODO Replace with timer UI |
||||
Text( |
||||
text = "Recording...", // Not localized because it is a placeholder |
||||
color = ElementTheme.colors.textSecondary, |
||||
style = ElementTheme.typography.fontBodySmMedium |
||||
) |
||||
|
||||
Spacer(Modifier.size(20.dp)) |
||||
|
||||
// TODO Replace with waveform UI |
||||
DebugAudioLevel( |
||||
modifier = Modifier.weight(1f), level = level |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun DebugAudioLevel( |
||||
level: Double, |
||||
modifier: Modifier = Modifier, |
||||
) { |
||||
Box( |
||||
modifier = modifier |
||||
.height(26.dp) |
||||
) { |
||||
Box( |
||||
modifier = Modifier |
||||
.align(Alignment.CenterEnd) |
||||
.fillMaxWidth(level.toFloat()) |
||||
.background(ElementTheme.colors.iconQuaternary, shape = MaterialTheme.shapes.small) |
||||
.fillMaxHeight() |
||||
) |
||||
} |
||||
} |
||||
|
||||
@PreviewsDayNight |
||||
@Composable |
||||
internal fun VoiceMessageRecordingPreview() = ElementPreview { |
||||
VoiceMessageRecording(0.5) |
||||
} |
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
/* |
||||
* 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. |
||||
*/ |
||||
plugins { |
||||
id("io.element.android-library") |
||||
alias(libs.plugins.anvil) |
||||
} |
||||
|
||||
android { |
||||
namespace = "io.element.android.libraries.voicerecorder.api" |
||||
} |
||||
|
||||
anvil { |
||||
generateDaggerFactories.set(true) |
||||
} |
||||
|
||||
dependencies { |
||||
implementation(libs.androidx.annotationjvm) |
||||
implementation(libs.coroutines.core) |
||||
} |
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
/* |
||||
* 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.api |
||||
|
||||
import android.Manifest |
||||
import androidx.annotation.RequiresPermission |
||||
import kotlinx.coroutines.flow.StateFlow |
||||
|
||||
/** |
||||
* Audio recorder which records audio to opus/ogg files. |
||||
*/ |
||||
interface VoiceRecorder { |
||||
/** |
||||
* Start a recording. |
||||
* |
||||
* Call [stopRecord] to stop the recording and release resources. |
||||
*/ |
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO) |
||||
suspend fun startRecord() |
||||
|
||||
/** |
||||
* Stop the current recording. |
||||
* |
||||
* Call [deleteRecording] to delete any recorded audio. |
||||
* |
||||
* @param cancelled If true, the recording is deleted. |
||||
*/ |
||||
suspend fun stopRecord( |
||||
cancelled: Boolean = false |
||||
) |
||||
|
||||
/** |
||||
* Stop the current recording and delete the output file. |
||||
*/ |
||||
suspend fun deleteRecording() |
||||
|
||||
/** |
||||
* The current state of the recorder. |
||||
*/ |
||||
val state: StateFlow<VoiceRecorderState> |
||||
} |
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
/* |
||||
* 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.api |
||||
|
||||
import java.io.File |
||||
|
||||
sealed class VoiceRecorderState { |
||||
/** |
||||
* The recorder is idle and not recording. |
||||
*/ |
||||
data object Idle : VoiceRecorderState() |
||||
|
||||
/** |
||||
* The recorder is currently recording. |
||||
* |
||||
* @property level The current audio level of the recording as a fraction of 1. |
||||
*/ |
||||
data class Recording(val level: Double) : VoiceRecorderState() |
||||
|
||||
/** |
||||
* The recorder has finished recording. |
||||
* |
||||
* @property file The recorded file. |
||||
* @property mimeType The mime type of the file. |
||||
*/ |
||||
data class Finished( |
||||
val file: File, |
||||
val mimeType: String, |
||||
) : VoiceRecorderState() |
||||
} |
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
/* |
||||
* 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. |
||||
*/ |
||||
plugins { |
||||
id("io.element.android-library") |
||||
alias(libs.plugins.anvil) |
||||
} |
||||
|
||||
android { |
||||
namespace = "io.element.android.libraries.voicerecorder.impl" |
||||
} |
||||
|
||||
anvil { |
||||
generateDaggerFactories.set(true) |
||||
} |
||||
|
||||
dependencies { |
||||
api(projects.libraries.voicerecorder.api) |
||||
api(libs.opusencoder) |
||||
|
||||
implementation(libs.dagger) |
||||
implementation(projects.libraries.matrix.api) |
||||
implementation(projects.libraries.core) |
||||
implementation(projects.libraries.di) |
||||
|
||||
implementation(libs.androidx.annotationjvm) |
||||
implementation(libs.coroutines.core) |
||||
|
||||
testImplementation(projects.tests.testutils) |
||||
testImplementation(libs.test.junit) |
||||
testImplementation(libs.test.truth) |
||||
testImplementation(libs.test.mockk) |
||||
testImplementation(libs.test.turbine) |
||||
testImplementation(libs.coroutines.core) |
||||
testImplementation(libs.coroutines.test) |
||||
} |
@ -0,0 +1,132 @@
@@ -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) |
||||
} |
||||
} |
@ -0,0 +1,139 @@
@@ -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 |
@ -0,0 +1,28 @@
@@ -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() |
||||
} |
@ -0,0 +1,35 @@
@@ -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, |
||||
) |
@ -0,0 +1,28 @@
@@ -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 |
||||
} |
@ -0,0 +1,37 @@
@@ -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 |
||||
} |
||||
|
||||
} |
@ -0,0 +1,49 @@
@@ -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) |
||||
} |
||||
} |
@ -0,0 +1,63 @@
@@ -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 |
||||
} |
||||
} |
@ -0,0 +1,28 @@
@@ -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() |
||||
} |
@ -0,0 +1,24 @@
@@ -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 |
||||
} |
@ -0,0 +1,58 @@
@@ -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() |
||||
} |
@ -0,0 +1,49 @@
@@ -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() |
||||
} |
||||
} |
@ -0,0 +1,30 @@
@@ -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, |
||||
) |
@ -0,0 +1,25 @@
@@ -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) |
||||
} |
@ -0,0 +1,134 @@
@@ -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() |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,46 @@
@@ -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) |
||||
} |
||||
} |
@ -0,0 +1,26 @@
@@ -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 |
||||
} |
||||
} |
@ -0,0 +1,49 @@
@@ -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 |
||||
} |
||||
} |
@ -0,0 +1,30 @@
@@ -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) |
||||
} |
||||
} |
@ -0,0 +1,40 @@
@@ -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 |
||||
} |
||||
} |
@ -0,0 +1,43 @@
@@ -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) |
||||
} |
||||
} |
@ -0,0 +1,37 @@
@@ -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) |
||||
} |
||||
} |
@ -0,0 +1,30 @@
@@ -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. |
||||
*/ |
||||
|
||||
plugins { |
||||
id("io.element.android-library") |
||||
} |
||||
|
||||
android { |
||||
namespace = "io.element.android.libraries.voicerecorder.test" |
||||
} |
||||
|
||||
dependencies { |
||||
api(projects.libraries.voicerecorder.api) |
||||
implementation(projects.tests.testutils) |
||||
|
||||
implementation(libs.coroutines.test) |
||||
} |
@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
|
||||
/* |
||||
* 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.api.VoiceRecorder |
||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState |
||||
import kotlinx.coroutines.flow.MutableStateFlow |
||||
import kotlinx.coroutines.flow.StateFlow |
||||
import java.io.File |
||||
|
||||
class FakeVoiceRecorder( |
||||
private val levels: List<Double> = listOf(0.1, 0.2) |
||||
) : VoiceRecorder { |
||||
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle) |
||||
override val state: StateFlow<VoiceRecorderState> = _state |
||||
|
||||
private var curRecording: File? = null |
||||
|
||||
private var securityException: SecurityException? = null |
||||
|
||||
override suspend fun startRecord() { |
||||
securityException?.let { throw it } |
||||
|
||||
if (curRecording != null) { |
||||
error("Previous recording was not cleared") |
||||
} |
||||
curRecording = File("file.ogg") |
||||
|
||||
levels.forEach { |
||||
_state.emit(VoiceRecorderState.Recording(it)) |
||||
} |
||||
} |
||||
|
||||
override suspend fun stopRecord( |
||||
cancelled: Boolean |
||||
) { |
||||
if (cancelled) { |
||||
deleteRecording() |
||||
} |
||||
|
||||
_state.emit( |
||||
when (curRecording) { |
||||
null -> VoiceRecorderState.Idle |
||||
else -> VoiceRecorderState.Finished(curRecording!!, "audio/ogg") |
||||
} |
||||
) |
||||
} |
||||
|
||||
override suspend fun deleteRecording() { |
||||
curRecording = null |
||||
|
||||
_state.emit( |
||||
VoiceRecorderState.Idle |
||||
) |
||||
} |
||||
|
||||
fun givenThrowsSecurityException(exception: SecurityException) { |
||||
this.securityException = exception |
||||
} |
||||
} |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue