feat: Add basic recording saving functionality

This commit is contained in:
Myzel394 2023-08-05 15:16:25 +02:00
parent 35c59754b5
commit c8a02567ab
No known key found for this signature in database
GPG Key ID: 79CC92F37B3E1A2B
9 changed files with 454 additions and 132 deletions

View File

@ -9,6 +9,7 @@ android {
compileSdk 33
defaultConfig {
multiDexEnabled true
applicationId "app.myzel394.locationtest"
minSdk 24
targetSdk 33

View File

@ -106,7 +106,7 @@ data class AudioRecorderSettings(
}
fun setMaxDuration(duration: Long): AudioRecorderSettings {
if (duration < 60 * 1000L || duration > 60 * 60 * 1000L) {
if (duration < 60 * 1000L || duration > 24 * 60 * 60 * 1000L) {
throw Exception("Max duration must be between 1 minute and 1 hour")
}

View File

@ -3,6 +3,7 @@ package app.myzel394.locationtest.services
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.media.MediaRecorder
import android.os.Binder
import android.os.Build
@ -12,21 +13,30 @@ import android.os.Looper
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import app.myzel394.locationtest.R
import app.myzel394.locationtest.dataStore
import app.myzel394.locationtest.db.AudioRecorderSettings
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.File
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Date
import java.util.UUID;
const val INTERVAL_DURATION = 10000L
class RecorderService: Service() {
private val binder = LocalBinder()
private val handler = Handler(Looper.getMainLooper())
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
private var mediaRecorder: MediaRecorder? = null
private var onError: MediaRecorder.OnErrorListener? = null
@ -34,27 +44,30 @@ class RecorderService: Service() {
private var counter = 0
lateinit var settings: Settings
var recordingStart = mutableStateOf<LocalDateTime?>(null)
private set
var fileFolder: String? = null
private set
var bitRate: Int? = null
private set
var recordingState: RecorderState = RecorderState.IDLE
private set
val isRecording: Boolean
get() = recordingStart.value != null
val filePaths = mutableListOf<String>()
var originalRecordingStart: LocalDateTime? = null
private set
override fun onBind(p0: Intent?): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
Actions.START.toString() -> {
val fileFolder = intent.getStringExtra("fileFolder")
val bitRate = intent.getIntExtra("bitRate", 320000)
fileFolder = getRandomFileFolder(this)
start(fileFolder, bitRate)
start()
}
Actions.STOP.toString() -> stop()
}
@ -62,6 +75,15 @@ class RecorderService: Service() {
return super.onStartCommand(intent, flags, startId)
}
val progress: Float
get() {
val start = recordingStart.value ?: return 0f
val now = LocalDateTime.now()
val duration = now.toEpochSecond(ZoneId.systemDefault().rules.getOffset(now)) - start.toEpochSecond(ZoneId.systemDefault().rules.getOffset(start))
return duration / (settings.maxDuration / 1000f)
}
fun setOnErrorListener(onError: MediaRecorder.OnErrorListener) {
this.onError = onError
}
@ -70,16 +92,14 @@ class RecorderService: Service() {
this.onStateChange = onStateChange
}
// Yield all recordings from 0 to counter
fun getRecordingFilePaths() = sequence<String> {
for (i in 0 until counter) {
yield("$fileFolder/$i.${getFileExtensions()}")
}
}
fun concatenateFiles(forceConcatenation: Boolean = false): File {
val paths = filePaths.joinToString("|")
val outputFile = "$fileFolder/${originalRecordingStart!!.format(DateTimeFormatter.ISO_DATE_TIME)}.${settings.fileExtension}"
if (File(outputFile).exists() && !forceConcatenation) {
return File(outputFile)
}
fun concatenateAudios(): String {
val paths = getRecordingFilePaths().joinToString("|")
val outputFile = "$fileFolder/concatenated.${getFileExtensions()}"
val command = "-i \"concat:$paths\" -acodec copy $outputFile"
val session = FFmpegKit.execute(command)
@ -98,7 +118,7 @@ class RecorderService: Service() {
throw Exception("Failed to concatenate audios")
}
return outputFile
return File(outputFile)
}
private fun startNewRecording() {
@ -106,6 +126,8 @@ class RecorderService: Service() {
return
}
deleteOldRecordings()
val newRecorder = createRecorder();
newRecorder.prepare()
@ -121,24 +143,61 @@ class RecorderService: Service() {
mediaRecorder = newRecorder
counter++
handler.postDelayed(this::startNewRecording, INTERVAL_DURATION)
}
private fun start(fileFolder: String?, bitRate: Int) {
this.fileFolder = fileFolder ?: getRandomFileFolder(this)
this.bitRate = bitRate
private fun deleteOldRecordings() {
val timeMultiplier = settings.maxDuration / settings.intervalDuration
val earliestCounter = counter - timeMultiplier
File(fileFolder!!).listFiles()?.forEach { file ->
val fileCounter = file.nameWithoutExtension.toIntOrNull() ?: return
if (fileCounter < earliestCounter) {
file.delete()
}
}
}
private fun createRecorder(): MediaRecorder {
filePaths.add(getFilePath())
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(this)
} else {
MediaRecorder()
}.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFile(getFilePath())
setOutputFormat(settings.outputFormat)
setAudioEncoder(settings.encoder)
setAudioEncodingBitRate(settings.bitRate)
setAudioSamplingRate(settings.samplingRate)
setOnErrorListener { mr, what, extra ->
onError?.onError(mr, what, extra)
this@RecorderService.stop()
}
}
}
private fun start() {
filePaths.clear()
// Create folder
File(this.fileFolder!!).mkdirs()
println(this.fileFolder)
scope.launch {
dataStore.data.collectLatest { preferenceSettings ->
settings = Settings.from(preferenceSettings.audioRecorderSettings)
recordingState = RecorderState.RECORDING
recordingStart.value = LocalDateTime.now()
originalRecordingStart = recordingStart.value
recordingState = RecorderState.RECORDING
recordingStart.value = LocalDateTime.now()
showNotification()
startNewRecording()
showNotification()
startNewRecording()
}
}
}
private fun stop() {
@ -187,33 +246,12 @@ class RecorderService: Service() {
val offset = ZoneId.of("UTC").rules.getOffset(recordingStart.value)
return (
recordingStart.value!!.toEpochSecond(offset) -
LocalDateTime.of(2023, 1, 1, 1, 1).toEpochSecond(offset)
).toInt()
recordingStart.value!!.toEpochSecond(offset) -
LocalDateTime.of(2023, 1, 1, 1, 1).toEpochSecond(offset)
).toInt()
}
private fun createRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(this)
} else {
MediaRecorder()
}.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFile(getFilePath())
setOutputFormat(getOutputFormat())
setAudioEncoder(getAudioEncoder())
setAudioEncodingBitRate(bitRate!!)
setAudioSamplingRate(getAudioSamplingRate())
setOnErrorListener { mr, what, extra ->
onError?.onError(mr, what, extra)
this@RecorderService.stop()
}
}
}
private fun getFilePath() = "${fileFolder}/${counter}.${getFileExtensions()}"
private fun getFilePath(): String = "$fileFolder/$counter.${settings.fileExtension}"
inner class LocalBinder: Binder() {
fun getService(): RecorderService = this@RecorderService
@ -231,37 +269,67 @@ class RecorderService: Service() {
}
companion object {
fun getOutputFormat(): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
MediaRecorder.OutputFormat.AAC_ADTS
else
MediaRecorder.OutputFormat.THREE_GPP
fun getFileExtensions(): String =
when(getOutputFormat()) {
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
else -> throw Exception("Unknown output format")
}
fun getAudioSamplingRate(): Int =
when(getOutputFormat()) {
MediaRecorder.OutputFormat.AAC_ADTS -> 96000
MediaRecorder.OutputFormat.THREE_GPP -> 44100
else -> throw Exception("Unknown output format")
}
fun getAudioEncoder(): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
MediaRecorder.AudioEncoder.AAC
else
MediaRecorder.AudioEncoder.AMR_NB
fun getRandomFileFolder(context: Context): String {
// uuid
val folder = UUID.randomUUID().toString()
return "${context.externalCacheDir!!.absolutePath}/$folder"
}
fun startService(context: Context, connection: ServiceConnection?) {
Intent(context, RecorderService::class.java).also { intent ->
intent.action = RecorderService.Actions.START.toString()
ContextCompat.startForegroundService(context, intent)
if (connection != null) {
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
}
}
fun stopService(context: Context) {
Intent(context, RecorderService::class.java).also { intent ->
intent.action = RecorderService.Actions.STOP.toString()
context.startService(intent)
}
}
}
}
}
data class Settings(
val maxDuration: Long,
val intervalDuration: Long,
val bitRate: Int,
val samplingRate: Int,
val outputFormat: Int,
val encoder: Int,
) {
val fileExtension: String
get() = when(outputFormat) {
MediaRecorder.OutputFormat.AAC_ADTS -> "aac"
MediaRecorder.OutputFormat.THREE_GPP -> "3gp"
MediaRecorder.OutputFormat.MPEG_4 -> "mp4"
MediaRecorder.OutputFormat.MPEG_2_TS -> "ts"
MediaRecorder.OutputFormat.WEBM -> "webm"
MediaRecorder.OutputFormat.AMR_NB -> "amr"
MediaRecorder.OutputFormat.AMR_WB -> "awb"
MediaRecorder.OutputFormat.OGG -> "ogg"
else -> "raw"
}
companion object {
fun from(audioRecorderSettings: AudioRecorderSettings): Settings {
return Settings(
intervalDuration = audioRecorderSettings.intervalDuration,
bitRate = audioRecorderSettings.bitRate,
samplingRate = audioRecorderSettings.getSamplingRate(),
outputFormat = audioRecorderSettings.getOutputFormat(),
encoder = audioRecorderSettings.getEncoder(),
maxDuration = audioRecorderSettings.maxDuration,
)
}
}
}

View File

@ -0,0 +1,120 @@
package app.myzel394.locationtest.ui.components.AudioRecorder.atoms
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import app.myzel394.locationtest.services.RecorderService
import app.myzel394.locationtest.ui.components.atoms.Pulsating
import app.myzel394.locationtest.ui.utils.formatDuration
import app.myzel394.locationtest.ui.utils.rememberFileSaverDialog
import java.time.Duration
import java.time.LocalDateTime
@Composable
fun RecordingStatus(
service: RecorderService,
) {
val context = LocalContext.current
val saveFile = rememberFileSaverDialog("audio/*")
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Forces real time update for the text
val transition = rememberInfiniteTransition()
val forceUpdateValue by transition.animateFloat(
initialValue = .999999f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
val distance = Duration.between(service.recordingStart.value, LocalDateTime.now()).toMillis()
Pulsating {
Box(
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.background(Color.Red)
)
}
Spacer(modifier = Modifier.width(16.dp))
Text(
text = formatDuration(distance),
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.alpha(forceUpdateValue)
)
}
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = service.progress,
modifier = Modifier
.width(300.dp)
.alpha(forceUpdateValue)
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = {
RecorderService.stopService(context)
saveFile(service.concatenateFiles())
},
) {
Icon(
Icons.Default.Save,
contentDescription = null,
)
Text("Save Recording")
}
Button(
onClick = {
RecorderService.stopService(context)
},
colors = ButtonDefaults.textButtonColors(),
) {
Text("Cancel")
}
}
}

View File

@ -0,0 +1,94 @@
package app.myzel394.locationtest.ui.components.AudioRecorder.atoms
import android.content.ServiceConnection
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import app.myzel394.locationtest.services.RecorderService
import app.myzel394.locationtest.ui.utils.rememberFileSaverDialog
import java.time.format.DateTimeFormatter
@Composable
fun StartRecording(
connection: ServiceConnection,
service: RecorderService? = null,
) {
val context = LocalContext.current
val saveFile = rememberFileSaverDialog("audio/*")
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box {}
Button(
onClick = {
RecorderService.startService(context, connection)
},
modifier = Modifier
.semantics {
contentDescription = "Start recording"
}
.size(200.dp)
.clip(shape = CircleShape),
colors = ButtonDefaults.outlinedButtonColors(),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Default.Mic,
contentDescription = null,
modifier = Modifier
.size(80.dp),
)
Spacer(modifier = Modifier.height(ButtonDefaults.IconSpacing))
Text(
"Start Recording",
style = MaterialTheme.typography.titleSmall,
)
}
}
if (service?.originalRecordingStart != null)
Button(
onClick = {
saveFile(service.concatenateFiles())
}
) {
Icon(
Icons.Default.Save,
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize),
)
Spacer(modifier = Modifier.width(ButtonDefaults.IconSpacing))
Text("Save Recording from ${service.originalRecordingStart!!.format(DateTimeFormatter.ISO_DATE_TIME)}")
}
else
Box {}
}
}

View File

@ -0,0 +1,30 @@
package app.myzel394.locationtest.ui.components.atoms
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@Composable
fun Pulsating(content: @Composable () -> Unit) {
val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
)
)
Box(modifier = Modifier.alpha(alpha)) {
content()
}
}

View File

@ -9,24 +9,41 @@ import android.media.MediaPlayer
import android.os.IBinder
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.navigation.NavController
import app.myzel394.locationtest.services.RecorderService
import app.myzel394.locationtest.ui.components.AudioRecorder.atoms.RecordingStatus
import app.myzel394.locationtest.ui.components.AudioRecorder.atoms.StartRecording
import app.myzel394.locationtest.ui.enums.Screen
@OptIn(ExperimentalMaterial3Api::class)
@ -35,10 +52,6 @@ fun AudioRecorder(
navController: NavController,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted ->
}
var service by remember { mutableStateOf<RecorderService?>(null) }
val connection = remember {
@ -85,51 +98,15 @@ fun AudioRecorder(
)
},
) {padding ->
Row(
modifier = Modifier.padding(padding),
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
Button(
onClick = {
// Check audio recording permission
if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
launcher.launch(Manifest.permission.RECORD_AUDIO)
return@Button
}
if (isRecording) {
Intent(context, RecorderService::class.java).also { intent ->
intent.action = RecorderService.Actions.STOP.toString()
context.startService(intent)
}
} else {
Intent(context, RecorderService::class.java).also { intent ->
intent.action = RecorderService.Actions.START.toString()
ContextCompat.startForegroundService(context, intent)
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
}
},
) {
Text(text = if (isRecording) "Stop" else "Start")
}
if (!isRecording && service != null)
Button(
onClick = {
val path = service!!.concatenateAudios()
val player = MediaPlayer().apply {
setDataSource(path)
prepare()
}
player.start()
},
) {
Text(text = "Convert")
}
if (isRecording && service != null)
RecordingStatus(service = service!!)
else
StartRecording(connection = connection, service = service)
}
}
}

View File

@ -41,6 +41,7 @@ import app.myzel394.locationtest.db.AudioRecorderSettings
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.BitrateTile
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.EncoderTile
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.IntervalDurationTile
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.MaxDurationTile
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.OutputFormatTile
import app.myzel394.locationtest.ui.components.SettingsScreen.atoms.SamplingRateTile
import app.myzel394.locationtest.ui.components.atoms.GlobalSwitch
@ -107,9 +108,8 @@ fun SettingsScreen(
}
}
)
MaxDurationTile()
IntervalDurationTile()
BitrateTile()
SamplingRateTile()
AnimatedVisibility(visible = settings.showAdvancedSettings) {
Column {
Divider(
@ -117,6 +117,8 @@ fun SettingsScreen(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 32.dp)
)
BitrateTile()
SamplingRateTile()
OutputFormatTile()
EncoderTile()
}

View File

@ -0,0 +1,30 @@
package app.myzel394.locationtest.ui.utils
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
// From @Bnyro
object PermissionHelper {
fun checkPermissions(context: Context, permissions: Array<String>): Boolean {
permissions.forEach {
if (!hasPermission(context, it)) {
ActivityCompat.requestPermissions(
context as Activity,
arrayOf(it),
1
)
return false
}
}
return true
}
fun hasPermission(context: Context, permission: String): Boolean {
return ActivityCompat.checkSelfPermission(
context,
permission
) == PackageManager.PERMISSION_GRANTED
}
}