First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.voicerecorder.api"
}
dependencies {
implementation(libs.androidx.annotationjvm)
implementation(libs.coroutines.core)
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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>
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.api
import androidx.compose.runtime.Immutable
import java.io.File
import kotlin.time.Duration
@Immutable
sealed interface VoiceRecorderState {
/**
* The recorder is idle and not recording.
*/
data object Idle : VoiceRecorderState
/**
* The recorder is currently recording.
*
* @property elapsedTime The elapsed time since the recording started.
* @property levels The current audio levels of the recording as a fraction of 1. All values are between 0 and 1.
*/
data class Recording(
val elapsedTime: Duration,
val levels: List<Float>,
) : VoiceRecorderState
/**
* The recorder has finished recording.
*
* @property file The recorded file.
* @property mimeType The mime type of the file.
* @property waveform The waveform of the recording. All values are between 0 and 1.
* @property duration The total time spent recording.
*/
data class Finished(
val file: File,
val mimeType: String,
val waveform: List<Float>,
val duration: Duration,
) : VoiceRecorderState
}

View File

@@ -0,0 +1,35 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.voicerecorder.impl"
}
setupDependencyInjection()
dependencies {
api(projects.libraries.voicerecorder.api)
api(libs.opusencoder)
implementation(projects.appconfig)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(libs.androidx.annotationjvm)
implementation(libs.coroutines.core)
testCommonDependencies(libs)
testImplementation(libs.coroutines.core)
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.impl
import android.Manifest
import androidx.annotation.RequiresPermission
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.VoiceMessageConfig
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.annotations.SessionCoroutineScope
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.audio.resample
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 kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.yield
import timber.log.Timber
import java.io.File
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class DefaultVoiceRecorder(
private val dispatchers: CoroutineDispatchers,
private val timeSource: TimeSource,
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,
@SessionCoroutineScope
sessionCoroutineScope: CoroutineScope,
) : VoiceRecorder {
private val voiceCoroutineScope by lazy {
sessionCoroutineScope.childScope(dispatchers.io, "VoiceRecorder-${UUID.randomUUID()}")
}
private var outputFile: File? = null
private var audioReader: AudioReader? = null
private var recordingJob: Job? = null
// List of Float between 0 and 1 representing the audio levels
private val levels: MutableList<Float> = mutableListOf()
private val lock = Mutex()
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)
lock.withLock {
levels.clear()
}
val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it }
recordingJob = voiceCoroutineScope.launch {
val startedAt = timeSource.markNow()
audioRecorder.record { audio ->
yield()
val elapsedTime = startedAt.elapsedNow()
if (elapsedTime > VoiceMessageConfig.maxVoiceMessageDuration) {
Timber.w("Voice message time limit reached")
stopRecord(false)
return@record
}
when (audio) {
is Audio.Data -> {
val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer)
lock.withLock {
levels.add(audioLevel)
_state.emit(VoiceRecorderState.Recording(elapsedTime, levels.toList()))
}
encoder.encode(audio.buffer, audio.readSize)
}
is Audio.Error -> {
Timber.e("Voice message error: code=${audio.audioRecordErrorCode}")
_state.emit(VoiceRecorderState.Recording(elapsedTime, listOf()))
}
}
}
}
}
/**
* 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()
lock.withLock {
if (cancelled) {
deleteRecording()
levels.clear()
}
_state.emit(
when (val file = outputFile) {
null -> VoiceRecorderState.Idle
else -> {
val duration = (state.value as? VoiceRecorderState.Recording)?.elapsedTime
VoiceRecorderState.Finished(
file = file,
mimeType = fileConfig.mimeType,
waveform = levels.resample(100),
duration = duration ?: 0.milliseconds
)
}
}
)
}
}
/**
* 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,132 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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 dev.zacsweers.metro.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,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.impl.audio
sealed interface Audio {
data class Data(
val readSize: Int,
val buffer: ShortArray,
) : Audio {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Data
if (readSize != other.readSize) return false
if (!buffer.contentEquals(other.buffer)) return false
return true
}
override fun hashCode(): Int {
var result = readSize
result = 31 * result + buffer.contentHashCode()
return result
}
}
data class Error(
val audioRecordErrorCode: Int
) : Audio
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import androidx.annotation.FloatRange
interface AudioLevelCalculator {
/**
* Calculate the audio level of the audio buffer.
*
* @param buffer The audio buffer containing 16bit PCM audio data.
* @return A float value between 0 and 1 proportional to the audio level.
*/
@FloatRange(from = 0.0, to = 1.0)
fun calculateAudioLevel(buffer: ShortArray): Float
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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,52 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.RoomScope
import kotlin.math.log10
import kotlin.math.sqrt
/**
* Default implementation of [AudioLevelCalculator].
*
* It computes the normalized [0;1] dBov value of the given PCM16 encoded [ShortArray].
* See: https://en.wikipedia.org/wiki/DBFS
*/
@ContributesBinding(RoomScope::class)
class DBovAudioLevelCalculator : AudioLevelCalculator {
override fun calculateAudioLevel(buffer: ShortArray): Float {
return buffer.rms().dBov().normalize().coerceIn(0f, 1f)
}
}
/**
* Computes the normalized (range 0.0 to 1.0) root mean square
* value of the given PCM16 encoded [ShortArray].
*/
private fun ShortArray.rms(): Float {
val floats = FloatArray(this.size) { i -> this[i] / Short.MAX_VALUE.toFloat() }
val squared = FloatArray(this.size) { i -> floats[i] * floats[i] }
val sum = squared.fold(0.0f) { acc, f -> acc + f }
val average = sum / this.size
return sqrt(average)
}
/**
* Converts the given RMS value to decibels relative to overload (dBov).
* It has range [-96.0, 0.0] where 0.0 is the value of a full scale square wave.
*/
private fun Float.dBov(): Float = 20 * log10(this)
/**
* Normalizes the given dBov value to the range [0.0, 1.0].
*/
private fun Float.normalize(): Float = (this + DYNAMIC_RANGE_PCM16) / DYNAMIC_RANGE_PCM16
private const val DYNAMIC_RANGE_PCM16: Float = 96.0f

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Provider
import io.element.android.libraries.di.RoomScope
import io.element.android.opusencoder.OggOpusEncoder
import timber.log.Timber
import java.io.File
/**
* Safe wrapper for OggOpusEncoder.
*/
@ContributesBinding(RoomScope::class)
class DefaultEncoder(
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().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()
encoder = null
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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,30 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.impl.audio
/**
* Resamples [this] list to [size] using linear interpolation.
*/
fun List<Float>.resample(size: Int): List<Float> {
require(size > 0)
val input = this
if (input.isEmpty()) return List(size) { 0f } // fast path.
if (input.size == 1) return List(size) { input[0] } // fast path.
if (input.size == size) return this // fast path.
val step: Float = input.size.toFloat() / size.toFloat()
return buildList(size) {
for (i in 0 until size) {
val x0 = (i * step).toInt()
val x1 = (x0 + 1).coerceAtMost(input.size - 1)
val x = i * step - x0
val y = input[x0] * (1 - x) + input[x1] * x
add(i, y)
}
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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,52 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.impl.di
import android.media.AudioFormat
import android.media.MediaRecorder
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.core.mimetype.MimeTypes
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
@BindingContainer
@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(),
// 24 kbps
bitRate = 24_000,
sampleRate = sampleRate,
source = MediaRecorder.AudioSource.MIC,
)
}
@Provides
public fun provideVoiceFileConfig(): VoiceFileConfig =
VoiceFileConfig(
cacheSubdir = "voice_recordings",
fileExt = "ogg",
mimeType = MimeTypes.Ogg,
)
@Provides
fun provideOggOpusEncoder(): OggOpusEncoder = OggOpusEncoder.create()
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.impl.file
import dev.zacsweers.metro.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.BaseRoom
import java.io.File
import java.util.UUID
@ContributesBinding(RoomScope::class)
class DefaultVoiceFileManager(
@CacheDirectory private val cacheDir: File,
private val config: VoiceFileConfig,
room: BaseRoom,
) : 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,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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,169 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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.appconfig.VoiceMessageConfig
import io.element.android.libraries.core.mimetype.MimeTypes
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.FakeAudioReaderFactory
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
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
class DefaultVoiceRecorderTest {
private val fakeFileSystem = FakeFileSystem()
private val timeSource = TestTimeSource()
@Test
fun `it emits the initial state`() = runTest {
val voiceRecorder = createDefaultVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
}
}
@Test
fun `when recording, it emits the recording state`() = runTest {
val voiceRecorder = createDefaultVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.seconds, listOf(1.0f)))
timeSource += 1.seconds
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.seconds, listOf()))
timeSource += 1.seconds
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(2.seconds, listOf(1.0f, 1.0f)))
}
}
@Test
fun `when elapsed time reaches 30 minutes, it stops recording`() = runTest {
val voiceRecorder = createDefaultVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.minutes, listOf(1.0f)))
timeSource += VoiceMessageConfig.maxVoiceMessageDuration
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(VoiceMessageConfig.maxVoiceMessageDuration, listOf()))
timeSource += 1.milliseconds
assertThat(awaitItem()).isEqualTo(
VoiceRecorderState.Finished(
file = File(FILE_PATH),
mimeType = MimeTypes.Ogg,
waveform = List(100) { 1f },
duration = VoiceMessageConfig.maxVoiceMessageDuration,
)
)
}
}
@Test
fun `when stopped, it provides a file and duration`() = runTest {
val voiceRecorder = createDefaultVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
skipItems(1)
timeSource += 5.seconds
skipItems(2)
voiceRecorder.stopRecord()
assertThat(awaitItem()).isEqualTo(
VoiceRecorderState.Finished(
file = File(FILE_PATH),
mimeType = MimeTypes.Ogg,
waveform = List(100) { 1f },
duration = 5.seconds,
)
)
assertThat(fakeFileSystem.files[File(FILE_PATH)]).isEqualTo(ENCODED_DATA)
}
}
@Test
fun `when cancelled, it deletes the file`() = runTest {
val voiceRecorder = createDefaultVoiceRecorder()
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.createDefaultVoiceRecorder(): DefaultVoiceRecorder {
val fileConfig = VoiceRecorderModule.provideVoiceFileConfig()
return DefaultVoiceRecorder(
dispatchers = testCoroutineDispatchers(),
timeSource = timeSource,
audioReaderFactory = FakeAudioReaderFactory(
audio = AUDIO,
),
encoder = FakeEncoder(fakeFileSystem),
config = AudioConfig(
format = audioFormat,
// 24 kbps
bitRate = 24_000,
sampleRate = SampleRate,
source = MediaRecorder.AudioSource.MIC,
),
fileConfig = fileConfig,
fileManager = FakeVoiceFileManager(fakeFileSystem, fileConfig, FILE_ID),
audioLevelCalculator = FakeAudioLevelCalculator(),
sessionCoroutineScope = backgroundScope,
)
}
companion object {
const val FILE_ID: String = "recording"
const val FILE_PATH = "voice_recordings/$FILE_ID.ogg"
private lateinit var audioFormat: 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() {
audioFormat = mockk()
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class DBovAudioLevelCalculatorTest {
@Test
fun `given max values, it returns 1`() {
val calculator = DBovAudioLevelCalculator()
val buffer = ShortArray(100) { Short.MAX_VALUE }
val level = calculator.calculateAudioLevel(buffer)
assertThat(level).isEqualTo(1.0f)
}
@Test
fun `given mixed values, it returns values within range`() {
val calculator = DBovAudioLevelCalculator()
val buffer = shortArrayOf(100, -200, 300, -400, 500, -600, 700, -800, 900, -1000)
val level = calculator.calculateAudioLevel(buffer)
assertThat(level).apply {
isGreaterThan(0f)
isLessThan(1f)
}
}
@Test
fun `given min values, it returns 0`() {
val calculator = DBovAudioLevelCalculator()
val buffer = ShortArray(100) { 0 }
val level = calculator.calculateAudioLevel(buffer)
assertThat(level).isEqualTo(0.0f)
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.impl.audio
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class ResampleTest {
@Test
fun `resample works`() {
listOf(0.0f).resample(10).let {
assertThat(it).isEqualTo(listOf(0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f))
}
listOf(1.0f).resample(10).let {
assertThat(it).isEqualTo(listOf(1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f))
}
listOf(0.0f, 1.0f).resample(10).let {
assertThat(it).isEqualTo(listOf(0.0f, 0.2f, 0.4f, 0.6f, 0.8f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f))
}
listOf(0.0f, 0.5f, 1.0f).resample(10).let {
assertThat(it).isEqualTo(listOf(0.0f, 0.15f, 0.3f, 0.45000002f, 0.6f, 0.75f, 0.90000004f, 1.0f, 1.0f, 1.0f))
}
List(100) { it.toFloat() }.resample(10).let {
assertThat(it).isEqualTo(listOf(0.0f, 10.0f, 20.0f, 30.0f, 40.0f, 50.0f, 60.0f, 70.0f, 80.0f, 90.0f))
}
}
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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): Float {
return buffer.map { abs(it.toFloat()) }.average().toFloat() / Short.MAX_VALUE
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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())
yield()
}
while (isActive) {
// do not return from the coroutine until it is cancelled
yield()
}
}
}
override fun stop() {
isRecording = false
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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 FakeAudioReaderFactory(
private val audio: List<Audio>
) : AudioReader.Factory {
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader {
return FakeAudioReader(dispatchers, audio)
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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,35 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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)
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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)
implementation(libs.test.truth)
implementation(projects.libraries.core)
}

View File

@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.voicerecorder.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
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 kotlinx.coroutines.yield
import java.io.File
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
class FakeVoiceRecorder(
private val timeSource: TestTimeSource = TestTimeSource(),
private val recordingDuration: Duration = 0.seconds,
private val levels: List<Float> = listOf(0.1f, 0.2f)
) : VoiceRecorder {
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
override val state: StateFlow<VoiceRecorderState> = _state
private var curRecording: File? = null
private var securityException: SecurityException? = null
private var startedCount = 0
private var stoppedCount = 0
private var deletedCount = 0
var waveform: List<Float> = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f)
override suspend fun startRecord() {
startedCount += 1
val startedAt = timeSource.markNow()
securityException?.let { throw it }
if (curRecording != null) {
error("Previous recording was not cleared")
}
curRecording = File("file.ogg")
timeSource += recordingDuration
for (i in 1..levels.size) {
_state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), levels.take(i)))
yield()
}
}
override suspend fun stopRecord(
cancelled: Boolean
) {
stoppedCount++
if (cancelled) {
deleteRecording()
}
_state.emit(
when (curRecording) {
null -> VoiceRecorderState.Idle
else -> VoiceRecorderState.Finished(
file = curRecording!!,
mimeType = MimeTypes.Ogg,
duration = recordingDuration,
waveform = waveform,
)
}
)
}
override suspend fun deleteRecording() {
deletedCount++
curRecording = null
_state.emit(
VoiceRecorderState.Idle
)
}
fun assertCalls(
started: Int = 0,
stopped: Int = 0,
deleted: Int = 0,
) {
assertThat(startedCount).isEqualTo(started)
assertThat(stoppedCount).isEqualTo(stopped)
assertThat(deletedCount).isEqualTo(deleted)
}
fun givenThrowsSecurityException(exception: SecurityException) {
this.securityException = exception
}
}