Merge recording enc and dec wav files

This commit is contained in:
Juha Heinanen
2025-12-05 13:24:55 +02:00
parent 04029ce89f
commit ed6faed318
5 changed files with 225 additions and 89 deletions
@@ -73,6 +73,10 @@ import androidx.media.AudioAttributesCompat
import androidx.media.AudioFocusRequestCompat import androidx.media.AudioFocusRequestCompat
import androidx.media.AudioManagerCompat import androidx.media.AudioManagerCompat
import com.tutpro.baresip.Utils.toCircle import com.tutpro.baresip.Utils.toCircle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.net.InetAddress import java.net.InetAddress
import java.nio.charset.Charset import java.nio.charset.Charset
@@ -1095,9 +1099,31 @@ class BaresipService: Service() {
history.stopTime = GregorianCalendar() history.stopTime = GregorianCalendar()
history.startTime = if (completedElsewhere) history.stopTime else call.startTime history.startTime = if (completedElsewhere) history.stopTime else call.startTime
history.rejected = call.rejected history.rejected = call.rejected
if (call.startTime != null && call.dumpfiles[0] != "")
history.recording = call.dumpfiles CoroutineScope(Dispatchers.IO).launch {
history.add() if (call.startTime != null && call.dumpfiles[0] != "") {
delay(500)
val rxFile = File(call.dumpfiles[0])
val txFile = File(call.dumpfiles[1])
val mergedFile = File(filesPath, "${rxFile.nameWithoutExtension}_stereo.wav")
if (Utils.mergeWavFiles(rxFile, txFile, mergedFile)) {
Log.d(TAG, "Automatic merge succeeded.")
history.recording = arrayOf(mergedFile.absolutePath, "")
try {
rxFile.delete()
txFile.delete()
} catch (e: Exception) {
Log.w(TAG, "Could not delete temporary raw files after merge: ${e.message}")
}
} else {
Log.e(TAG, "Automatic merge failed. Storing raw file paths as fallback.")
history.recording = call.dumpfiles
}
}
history.add()
}
ua.account.missedCalls = ua.account.missedCalls || missed ua.account.missedCalls = ua.account.missedCalls || missed
} }
if (!Utils.isVisible()) { if (!Utils.isVisible()) {
@@ -40,6 +40,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -50,15 +51,17 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.tutpro.baresip.CallRow.Details import com.tutpro.baresip.CallRow.Details
import com.tutpro.baresip.CustomElements.AlertDialog import com.tutpro.baresip.CustomElements.AlertDialog
import com.tutpro.baresip.CustomElements.verticalScrollbar import com.tutpro.baresip.CustomElements.verticalScrollbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.IOException import java.io.FileInputStream
import java.text.DateFormat import java.text.DateFormat
import java.util.GregorianCalendar import java.util.GregorianCalendar
@@ -254,8 +257,8 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
val showDialog = remember { mutableStateOf(false) } val showDialog = remember { mutableStateOf(false) }
val recording = detail.recording val recording = detail.recording
val decPlayer = MediaPlayer() val mediaPlayer = remember { MediaPlayer() }
val encPlayer = MediaPlayer() val scope = rememberCoroutineScope()
AlertDialog( AlertDialog(
showDialog = showDialog, showDialog = showDialog,
@@ -263,93 +266,99 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
message = "", message = "",
) )
if (recording[0] != "") { val isRawState = recording[0] != "" && recording[1] != ""
val isMergedState = recording[0] != "" && recording[1] == ""
if (isRawState || isMergedState) {
Text( Text(
text = durationText, text = durationText,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier modifier = Modifier
.padding(end = 12.dp) .padding(end = 12.dp)
.clickable(onClick = { .clickable(onClick = {
if (!decPlayer.isPlaying && !encPlayer.isPlaying) { if (!mediaPlayer.isPlaying) {
decPlayer.reset() mediaPlayer.reset()
encPlayer.reset() scope.launch(Dispatchers.IO) {
Log.d(TAG, "Playing recordings ${recording[0]} and ${recording[1]}") var finalFile: File? = null
decPlayer.apply {
setAudioAttributes( if (isRawState) {
AudioAttributes.Builder() val fileIn = File(recording[0])
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) val fileOut = File(recording[1])
.setUsage(AudioAttributes.USAGE_MEDIA) val mergedFileName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
.build() val mergedFile = File(BaresipService.filesPath + "/tmp", mergedFileName)
)
setOnPreparedListener { if (mergedFile.exists()) {
encPlayer.apply { Log.d(TAG, "Using already merged file: ${mergedFile.name}")
setAudioAttributes( finalFile = mergedFile
AudioAttributes.Builder() } else {
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) File(BaresipService.filesPath + "/tmp").mkdirs()
.setUsage(AudioAttributes.USAGE_MEDIA) if (Utils.mergeWavFiles(fileIn, fileOut, mergedFile)) {
.build() finalFile = mergedFile
)
setOnPreparedListener {
it.start()
decPlayer.start()
Log.d(TAG, "Started players")
showDialog.value = true
}
setOnCompletionListener {
Log.d(TAG, "Stopping encPlayer")
it.stop()
showDialog.value = false
}
try {
val file = recording[0]
val encFile = File(file)
.copyTo(
File(
BaresipService.filesPath +
"/tmp/encode.wav"
), true
)
val encUri = encFile.toUri()
setDataSource(ctx, encUri)
prepareAsync()
} catch (e: IllegalArgumentException) {
Log.e(TAG, "encPlayer IllegalArgumentException: $e")
} catch (e: IOException) {
Log.e(TAG, "encPlayer IOException: $e")
} catch (e: Exception) {
Log.e(TAG, "encPlayer Exception: $e")
} }
} }
// If merge successful, update state and delete originals
if (finalFile != null && finalFile.exists()) {
// Update the object state
// We put the merged path in [0] and clear [1]
recording[0] = finalFile.absolutePath
recording[1] = ""
// Delete the original raw files
try {
if (fileIn.exists()) fileIn.delete()
if (fileOut.exists()) fileOut.delete()
// Persist changes so the app remembers the file is merged
CallHistoryNew.save()
} catch (e: Exception) {
Log.w(TAG, "MergeWav: Failed to delete original files: ${e.message}")
}
}
} else {
// We are already in merged state
Log.d(TAG, "Using already merged file: ${recording[0]}")
finalFile = File(recording[0])
} }
setOnCompletionListener {
Log.d(TAG, "Stopping decPlayer") withContext(Dispatchers.Main) {
it.stop() if (finalFile != null && finalFile.exists()) {
showDialog.value = false try {
} mediaPlayer.apply {
try { setAudioAttributes(
val file = recording[1] AudioAttributes.Builder()
val decFile = File(file) .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.copyTo( .setUsage(AudioAttributes.USAGE_MEDIA)
File( .build()
BaresipService.filesPath + )
"/tmp/decode.wav" val fis = FileInputStream(finalFile)
), true setDataSource(fis.fd)
) fis.close()
val decUri = decFile.toUri() setOnPreparedListener {
setDataSource(ctx, decUri) it.start()
prepareAsync() Log.d(TAG, "Started playback")
} catch (e: IllegalArgumentException) { showDialog.value = true
Log.e(TAG, "decPlayer IllegalArgumentException: $e") }
} catch (e: IOException) { setOnCompletionListener {
Log.e(TAG, "decPlayer IOException: $e") Log.d(TAG, "Playback complete")
} catch (e: Exception) { showDialog.value = false
Log.e(TAG, "decPlayer Exception: $e") it.reset()
}
prepareAsync()
}
} catch (e: Exception) {
Log.e(TAG, "Playback failed: $e")
Toast.makeText(ctx, "Playback error", Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(ctx, "Failed to process audio file", Toast.LENGTH_SHORT).show()
}
} }
} }
} } else {
else if (decPlayer.isPlaying && encPlayer.isPlaying) { mediaPlayer.stop()
decPlayer.stop() mediaPlayer.reset()
encPlayer.stop() showDialog.value = false
} }
}) })
) )
@@ -22,8 +22,7 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
val aorSpecificHistory = BaresipService.callHistory.filter { it.aor == this.aor } val aorSpecificHistory = BaresipService.callHistory.filter { it.aor == this.aor }
if (aorSpecificHistory.size > CALL_HISTORY_SIZE) { if (aorSpecificHistory.size > CALL_HISTORY_SIZE) {
val oldestToRemove = aorSpecificHistory.first() val oldestToRemove = aorSpecificHistory.first()
if (oldestToRemove.recording[0].isNotEmpty()) deleteRecording(oldestToRemove.recording)
deleteRecording(oldestToRemove.recording)
BaresipService.callHistory.remove(oldestToRemove) BaresipService.callHistory.remove(oldestToRemove)
} }
save() save()
@@ -44,8 +43,7 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
for (i in BaresipService.callHistory.indices.reversed()) { for (i in BaresipService.callHistory.indices.reversed()) {
val h = BaresipService.callHistory[i] val h = BaresipService.callHistory[i]
if (h.aor == aor) { if (h.aor == aor) {
if (h.recording[0] != "") deleteRecording(h.recording)
deleteRecording(h.recording)
BaresipService.callHistory.removeAt(i) BaresipService.callHistory.removeAt(i)
} }
} }
@@ -478,8 +478,7 @@ private fun loadCallHistory(aor: String): MutableList<CallRow> {
private fun removeFromHistory(callHistory: MutableState<List<CallRow>>, callRow: CallRow) { private fun removeFromHistory(callHistory: MutableState<List<CallRow>>, callRow: CallRow) {
for (details in callRow.details) { for (details in callRow.details) {
if (details.recording[0] != "") CallHistoryNew.deleteRecording(details.recording)
CallHistoryNew.deleteRecording(details.recording)
BaresipService.callHistory.removeAll { BaresipService.callHistory.removeAll {
it.startTime == details.startTime && it.stopTime == details.stopTime it.startTime == details.startTime && it.stopTime == details.stopTime
} }
@@ -52,6 +52,8 @@ import java.net.InetAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.net.SocketException import java.net.SocketException
import java.net.URL import java.net.URL
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.KeyStore import java.security.KeyStore
import java.security.SecureRandom import java.security.SecureRandom
import java.security.cert.CertificateException import java.security.cert.CertificateException
@@ -1146,6 +1148,108 @@ object Utils {
return bitmap return bitmap
} }
/**
* Merges two mono WAV files into a single stereo WAV file.
* @param file1 The first WAV file (Left channel)* @param file2 The second WAV file (Right channel)
* @param outFile The destination file
* @return True if successful, False otherwise
*/
fun mergeWavFiles(file1: File, file2: File, outFile: File): Boolean {
Log.d(TAG, "MergeWav: Input1 size=${file1.length()}, Input2 size=${file2.length()}")
try {
val in1 = FileInputStream(file1)
val in2 = FileInputStream(file2)
val out = FileOutputStream(outFile)
val header1 = ByteArray(44)
val header2 = ByteArray(44)
if (in1.read(header1) != 44 || in2.read(header2) != 44) {
Log.e(TAG, "MergeWav: Failed to read headers")
return false
}
// Parse data sizes from the input headers (Little Endian, offset 40)
val dataSize1 = ByteBuffer.wrap(header1, 40, 4).order(ByteOrder.LITTLE_ENDIAN).int
val dataSize2 = ByteBuffer.wrap(header2, 40, 4).order(ByteOrder.LITTLE_ENDIAN).int
Log.d(TAG, "MergeWav: DataChunk1=$dataSize1, DataChunk2=$dataSize2")
// Since we are converting 2x Mono to 1x Stereo, the size doubles.
// If one file is shorter, we will pad it with silence (0s).
val maxDataSize = kotlin.math.max(dataSize1, dataSize2)
val totalDataSize = maxDataSize * 2
Log.d(TAG, "MergeWav: Calculated Target DataSize=$totalDataSize")
// Prepare new header based on header1
val newHeader = header1.clone()
newHeader[22] = 2 // Channels = Stereo
newHeader[32] = 4 // BlockAlign = 2 * 16bit / 8 = 4
val sampleRate = ByteBuffer.wrap(header1, 24, 4).order(ByteOrder.LITTLE_ENDIAN).int
val byteRate = sampleRate * 2 * 2 // SampleRate * Channels * Bits/8
ByteBuffer.wrap(newHeader, 28, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(byteRate)
ByteBuffer.wrap(newHeader, 40, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(totalDataSize)
ByteBuffer.wrap(newHeader, 4, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(totalDataSize + 36)
out.write(newHeader)
val buffer1 = ByteArray(2)
val buffer2 = ByteArray(2)
val silence = ByteArray(2) // Default 0s
// Loop until the LONGEST file is finished
var bytesRead1: Int
var bytesRead2: Int
var totalBytesWritten = 0
// We use a do-while or simpler loop structure to handle uneven lengths
while (true) {
bytesRead1 = in1.read(buffer1)
bytesRead2 = in2.read(buffer2)
// If both are done, stop
if (bytesRead1 == -1 && bytesRead2 == -1) break
// Write Left Channel (File 1)
if (bytesRead1 != -1) {
out.write(buffer1, 0, bytesRead1)
totalBytesWritten += bytesRead1
} else {
// File 1 ended, write silence
out.write(silence)
totalBytesWritten += 2
}
// Write Right Channel (File 2)
if (bytesRead2 != -1) {
out.write(buffer2, 0, bytesRead2)
totalBytesWritten += bytesRead2
} else {
// File 2 ended, write silence
out.write(silence)
totalBytesWritten += 2
}
}
Log.d(TAG, "MergeWav: Finished. Actual bytes written: $totalBytesWritten")
in1.close()
in2.close()
out.close()
return true
} catch (e: Exception) {
Log.e(TAG, "MergeWav Failed: $e")
return false
}
}
@Suppress("unused") @Suppress("unused")
fun listFilesInDirectory(directoryPath: String): List<File> { fun listFilesInDirectory(directoryPath: String): List<File> {
val directory = File(directoryPath) val directory = File(directoryPath)