Merge recording enc and dec wav files
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user