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

View File

@ -73,6 +73,10 @@ import androidx.media.AudioAttributesCompat
import androidx.media.AudioFocusRequestCompat
import androidx.media.AudioManagerCompat
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.net.InetAddress
import java.nio.charset.Charset
@ -1095,9 +1099,31 @@ class BaresipService: Service() {
history.stopTime = GregorianCalendar()
history.startTime = if (completedElsewhere) history.stopTime else call.startTime
history.rejected = call.rejected
if (call.startTime != null && call.dumpfiles[0] != "")
history.recording = call.dumpfiles
history.add()
CoroutineScope(Dispatchers.IO).launch {
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
}
if (!Utils.isVisible()) {

View File

@ -40,6 +40,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.tutpro.baresip.CallRow.Details
import com.tutpro.baresip.CustomElements.AlertDialog
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.IOException
import java.io.FileInputStream
import java.text.DateFormat
import java.util.GregorianCalendar
@ -254,8 +257,8 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
val showDialog = remember { mutableStateOf(false) }
val recording = detail.recording
val decPlayer = MediaPlayer()
val encPlayer = MediaPlayer()
val mediaPlayer = remember { MediaPlayer() }
val scope = rememberCoroutineScope()
AlertDialog(
showDialog = showDialog,
@ -263,93 +266,99 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
message = "",
)
if (recording[0] != "") {
val isRawState = recording[0] != "" && recording[1] != ""
val isMergedState = recording[0] != "" && recording[1] == ""
if (isRawState || isMergedState) {
Text(
text = durationText,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.padding(end = 12.dp)
.clickable(onClick = {
if (!decPlayer.isPlaying && !encPlayer.isPlaying) {
decPlayer.reset()
encPlayer.reset()
Log.d(TAG, "Playing recordings ${recording[0]} and ${recording[1]}")
decPlayer.apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
setOnPreparedListener {
encPlayer.apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
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 (!mediaPlayer.isPlaying) {
mediaPlayer.reset()
scope.launch(Dispatchers.IO) {
var finalFile: File? = null
if (isRawState) {
val fileIn = File(recording[0])
val fileOut = File(recording[1])
val mergedFileName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
val mergedFile = File(BaresipService.filesPath + "/tmp", mergedFileName)
if (mergedFile.exists()) {
Log.d(TAG, "Using already merged file: ${mergedFile.name}")
finalFile = mergedFile
} else {
File(BaresipService.filesPath + "/tmp").mkdirs()
if (Utils.mergeWavFiles(fileIn, fileOut, mergedFile)) {
finalFile = mergedFile
}
}
// 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")
it.stop()
showDialog.value = false
}
try {
val file = recording[1]
val decFile = File(file)
.copyTo(
File(
BaresipService.filesPath +
"/tmp/decode.wav"
), true
)
val decUri = decFile.toUri()
setDataSource(ctx, decUri)
prepareAsync()
} catch (e: IllegalArgumentException) {
Log.e(TAG, "decPlayer IllegalArgumentException: $e")
} catch (e: IOException) {
Log.e(TAG, "decPlayer IOException: $e")
} catch (e: Exception) {
Log.e(TAG, "decPlayer Exception: $e")
withContext(Dispatchers.Main) {
if (finalFile != null && finalFile.exists()) {
try {
mediaPlayer.apply {
setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_MEDIA)
.build()
)
val fis = FileInputStream(finalFile)
setDataSource(fis.fd)
fis.close()
setOnPreparedListener {
it.start()
Log.d(TAG, "Started playback")
showDialog.value = true
}
setOnCompletionListener {
Log.d(TAG, "Playback complete")
showDialog.value = false
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 if (decPlayer.isPlaying && encPlayer.isPlaying) {
decPlayer.stop()
encPlayer.stop()
} else {
mediaPlayer.stop()
mediaPlayer.reset()
showDialog.value = false
}
})
)

View File

@ -22,8 +22,7 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
val aorSpecificHistory = BaresipService.callHistory.filter { it.aor == this.aor }
if (aorSpecificHistory.size > CALL_HISTORY_SIZE) {
val oldestToRemove = aorSpecificHistory.first()
if (oldestToRemove.recording[0].isNotEmpty())
deleteRecording(oldestToRemove.recording)
deleteRecording(oldestToRemove.recording)
BaresipService.callHistory.remove(oldestToRemove)
}
save()
@ -44,8 +43,7 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
for (i in BaresipService.callHistory.indices.reversed()) {
val h = BaresipService.callHistory[i]
if (h.aor == aor) {
if (h.recording[0] != "")
deleteRecording(h.recording)
deleteRecording(h.recording)
BaresipService.callHistory.removeAt(i)
}
}

View File

@ -478,8 +478,7 @@ private fun loadCallHistory(aor: String): MutableList<CallRow> {
private fun removeFromHistory(callHistory: MutableState<List<CallRow>>, callRow: CallRow) {
for (details in callRow.details) {
if (details.recording[0] != "")
CallHistoryNew.deleteRecording(details.recording)
CallHistoryNew.deleteRecording(details.recording)
BaresipService.callHistory.removeAll {
it.startTime == details.startTime && it.stopTime == details.stopTime
}

View File

@ -52,6 +52,8 @@ import java.net.InetAddress
import java.net.NetworkInterface
import java.net.SocketException
import java.net.URL
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.CertificateException
@ -1146,6 +1148,108 @@ object Utils {
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")
fun listFilesInDirectory(directoryPath: String): List<File> {
val directory = File(directoryPath)