diff --git a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt index af025551..5ad74aa5 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/BaresipService.kt @@ -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()) { diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallDetailsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/CallDetailsScreen.kt index f2a3ec4c..32c66dc8 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallDetailsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallDetailsScreen.kt @@ -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 } }) ) diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallHistory.kt b/app/src/main/kotlin/com/tutpro/baresip/CallHistory.kt index 8ec6e67e..8c08fd07 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallHistory.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallHistory.kt @@ -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) } } diff --git a/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt index f8e69019..d7191fdc 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/CallsScreen.kt @@ -478,8 +478,7 @@ private fun loadCallHistory(aor: String): MutableList { private fun removeFromHistory(callHistory: MutableState>, 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 } diff --git a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt index 7bce6599..8409454b 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/Utils.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/Utils.kt @@ -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 { val directory = File(directoryPath)