Call history/recording improvements
This commit is contained in:
@@ -64,7 +64,6 @@ 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.verticalScrollbar
|
import com.tutpro.baresip.CustomElements.verticalScrollbar
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -266,6 +265,8 @@ private fun startTime(detail: Details): String {
|
|||||||
private fun Duration(ctx: Context, detail: Details, durationText: String) {
|
private fun Duration(ctx: Context, detail: Details, durationText: String) {
|
||||||
|
|
||||||
val showDialog = remember { mutableStateOf(false) }
|
val showDialog = remember { mutableStateOf(false) }
|
||||||
|
// NOTE: If detail.recording is modified elsewhere, this reference sees the change
|
||||||
|
// because Array is mutable, but Compose won't trigger a redraw.
|
||||||
val recording = detail.recording
|
val recording = detail.recording
|
||||||
val mediaPlayer = remember { MediaPlayer() }
|
val mediaPlayer = remember { MediaPlayer() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -282,10 +283,8 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
val isRawState = recording[0] != "" && recording[1] != ""
|
val hasRecording = recording[0] != ""
|
||||||
val isMergedState = recording[0] != "" && recording[1] == ""
|
if (hasRecording) {
|
||||||
|
|
||||||
if (isRawState || isMergedState) {
|
|
||||||
Text(
|
Text(
|
||||||
text = durationText,
|
text = durationText,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
@@ -296,20 +295,37 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
|
|||||||
mediaPlayer.reset()
|
mediaPlayer.reset()
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
var finalFile: File? = null
|
var finalFile: File? = null
|
||||||
|
// RE-EVALUATE STATE INSIDE THE CLICK LISTENER
|
||||||
|
val currentIsRaw = recording[0] != "" && recording[1] != ""
|
||||||
|
val currentIsMerged = recording[0] != "" && recording[1] == ""
|
||||||
|
|
||||||
if (isRawState) {
|
if (currentIsRaw) {
|
||||||
val fileIn = File(recording[0])
|
val fileIn = File(recording[0])
|
||||||
val fileOut = File(recording[1])
|
val fileOut = File(recording[1])
|
||||||
val mergedFileName =
|
// SAFETY CHECK: If the raw file is gone, the background service
|
||||||
"merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
|
// likely finished merging just now.
|
||||||
val mergedFile = File(BaresipService.filesPath + "/tmp", mergedFileName)
|
if (!fileIn.exists()) {
|
||||||
|
// Try to find the merged file based on naming convention as fallback
|
||||||
|
val expectedMergedName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
|
||||||
|
val fallbackMerged = File(BaresipService.filesPath + "/recordings", expectedMergedName)
|
||||||
|
if (fallbackMerged.exists()) {
|
||||||
|
Log.d(TAG, "Raw file missing, found merged fallback: ${fallbackMerged.name}")
|
||||||
|
finalFile = fallbackMerged
|
||||||
|
// Update state to match reality
|
||||||
|
recording[0] = fallbackMerged.absolutePath
|
||||||
|
recording[1] = ""
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Raw file missing and fallback not found: ${recording[0]}")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal Raw processing
|
||||||
|
val mergedFileName = "merged_${fileIn.nameWithoutExtension}_${fileOut.nameWithoutExtension}.wav"
|
||||||
|
val mergedFile = File(BaresipService.filesPath + "/recordings", mergedFileName)
|
||||||
|
|
||||||
if (mergedFile.exists()) {
|
if (mergedFile.exists()) {
|
||||||
Log.d(TAG, "Using already merged file: ${mergedFile.name}")
|
Log.d(TAG, "Using already merged file: ${mergedFile.name}")
|
||||||
finalFile = mergedFile
|
finalFile = mergedFile
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
File(BaresipService.filesPath + "/tmp").mkdirs()
|
|
||||||
if (Utils.mergeWavFiles(fileIn, fileOut, mergedFile)) {
|
if (Utils.mergeWavFiles(fileIn, fileOut, mergedFile)) {
|
||||||
finalFile = mergedFile
|
finalFile = mergedFile
|
||||||
}
|
}
|
||||||
@@ -317,27 +333,26 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
|
|||||||
|
|
||||||
// If merge successful, update state and delete originals
|
// If merge successful, update state and delete originals
|
||||||
if (finalFile != null && finalFile.exists()) {
|
if (finalFile != null && finalFile.exists()) {
|
||||||
// Update the object state
|
|
||||||
// We put the merged path in [0] and clear [1]
|
|
||||||
recording[0] = finalFile.absolutePath
|
recording[0] = finalFile.absolutePath
|
||||||
recording[1] = ""
|
recording[1] = ""
|
||||||
|
|
||||||
// Delete the original raw files
|
|
||||||
try {
|
try {
|
||||||
if (fileIn.exists()) fileIn.delete()
|
if (fileIn.exists()) fileIn.delete()
|
||||||
if (fileOut.exists()) fileOut.delete()
|
if (fileOut.exists()) fileOut.delete()
|
||||||
|
|
||||||
// Persist changes so the app remembers the file is merged
|
|
||||||
CallHistoryNew.save()
|
CallHistoryNew.save()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "MergeWav: Failed to delete original files: ${e.message}")
|
Log.w(TAG, "MergeWav: Failed to delete original files: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
} else if (currentIsMerged) {
|
||||||
// We are already in merged state
|
// We are in merged state
|
||||||
|
val f = File(recording[0])
|
||||||
|
if (f.exists()) {
|
||||||
Log.d(TAG, "Using already merged file: ${recording[0]}")
|
Log.d(TAG, "Using already merged file: ${recording[0]}")
|
||||||
finalFile = File(recording[0])
|
finalFile = f
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Merged file record exists but file is missing: ${recording[0]}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
@@ -369,14 +384,12 @@ private fun Duration(ctx: Context, detail: Details, durationText: String) {
|
|||||||
Log.e(TAG, "Playback failed: $e")
|
Log.e(TAG, "Playback failed: $e")
|
||||||
Toast.makeText(ctx, "Playback error", Toast.LENGTH_SHORT).show()
|
Toast.makeText(ctx, "Playback error", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
Toast.makeText(ctx, "Failed to process audio file", Toast.LENGTH_SHORT).show()
|
Toast.makeText(ctx, "Failed to process audio file", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
mediaPlayer.stop()
|
mediaPlayer.stop()
|
||||||
mediaPlayer.reset()
|
mediaPlayer.reset()
|
||||||
showDialog.value = false
|
showDialog.value = false
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
|
|||||||
var startTime: GregorianCalendar? = null
|
var startTime: GregorianCalendar? = null
|
||||||
var stopTime = GregorianCalendar() // Set to time when call is closed
|
var stopTime = GregorianCalendar() // Set to time when call is closed
|
||||||
var rejected = false
|
var rejected = false
|
||||||
var recording = arrayOf("", "") // Encoder and decoder recording files
|
var recording = arrayOf("", "") // Encoder and decoder recording files, merged file is in [0]
|
||||||
|
|
||||||
fun add() {
|
fun add() {
|
||||||
BaresipService.callHistory.add(this)
|
BaresipService.callHistory.add(this)
|
||||||
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()
|
||||||
deleteRecording(oldestToRemove.recording)
|
deleteRecordingFiles(oldestToRemove.recording)
|
||||||
BaresipService.callHistory.remove(oldestToRemove)
|
BaresipService.callHistory.remove(oldestToRemove)
|
||||||
}
|
}
|
||||||
save()
|
save()
|
||||||
@@ -43,7 +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) {
|
||||||
deleteRecording(h.recording)
|
deleteRecordingFiles(h.recording)
|
||||||
BaresipService.callHistory.removeAt(i)
|
BaresipService.callHistory.removeAt(i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,11 +82,17 @@ class CallHistoryNew(val aor: String, val peerUri: String, val direction: String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteRecording(recording: Array<String>) {
|
fun deleteRecordingFiles(recording: Array<String>) {
|
||||||
Utils.deleteFile(File(recording[0]))
|
Utils.deleteFile(File(recording[0]))
|
||||||
Utils.deleteFile(File(recording[1]))
|
Utils.deleteFile(File(recording[1]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearRecordings() {
|
||||||
|
for (h in BaresipService.callHistory) {
|
||||||
|
h.recording = arrayOf("", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("UNUSED")
|
@Suppress("UNUSED")
|
||||||
fun print() {
|
fun print() {
|
||||||
for (h in BaresipService.callHistory)
|
for (h in BaresipService.callHistory)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -60,6 +61,9 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavGraphBuilder
|
import androidx.navigation.NavGraphBuilder
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
@@ -86,13 +90,28 @@ private fun CallsScreen(navController: NavController, viewModel: ViewModel, aor:
|
|||||||
val account = Account.ofAor(aor)!!
|
val account = Account.ofAor(aor)!!
|
||||||
|
|
||||||
val callHistory: MutableState<List<CallRow>> = remember { mutableStateOf(emptyList()) }
|
val callHistory: MutableState<List<CallRow>> = remember { mutableStateOf(emptyList()) }
|
||||||
|
|
||||||
var isHistoryLoaded by remember { mutableStateOf(false) }
|
var isHistoryLoaded by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(aor) {
|
|
||||||
|
var refreshTrigger by remember { mutableStateOf(0) }
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
|
LaunchedEffect(aor, refreshTrigger) {
|
||||||
callHistory.value = loadCallHistory(aor)
|
callHistory.value = loadCallHistory(aor)
|
||||||
isHistoryLoaded = true
|
isHistoryLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffect(lifecycleOwner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
|
if (event == Lifecycle.Event.ON_RESUME) {
|
||||||
|
refreshTrigger++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BackHandler(enabled = true) {
|
BackHandler(enabled = true) {
|
||||||
account.missedCalls = false
|
account.missedCalls = false
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
@@ -199,7 +218,6 @@ private fun TopAppBar(navController: NavController, account: Account, callHistor
|
|||||||
}
|
}
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
disable, enable -> {
|
disable, enable -> {
|
||||||
account.callHistory = !account.callHistory
|
account.callHistory = !account.callHistory
|
||||||
Account.saveAccounts()
|
Account.saveAccounts()
|
||||||
@@ -478,12 +496,12 @@ 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) {
|
||||||
CallHistoryNew.deleteRecording(details.recording)
|
CallHistoryNew.deleteRecordingFiles(details.recording)
|
||||||
BaresipService.callHistory.removeAll {
|
BaresipService.callHistory.removeAll {
|
||||||
it.startTime == details.startTime && it.stopTime == details.stopTime
|
it.startTime == details.startTime && it.stopTime == details.stopTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CallHistoryNew.deleteRecording(callRow.recording)
|
CallHistoryNew.deleteRecordingFiles(callRow.recording)
|
||||||
val updatedList = callHistory.value.filterNot { it == callRow }
|
val updatedList = callHistory.value.filterNot { it == callRow }
|
||||||
callHistory.value = updatedList
|
callHistory.value = updatedList
|
||||||
CallHistoryNew.save()
|
CallHistoryNew.save()
|
||||||
|
|||||||
@@ -46,14 +46,13 @@ import java.io.IOException
|
|||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.ObjectInputStream
|
import java.io.ObjectInputStream
|
||||||
import java.io.ObjectOutputStream
|
import java.io.ObjectOutputStream
|
||||||
|
import java.io.RandomAccessFile
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
import java.lang.reflect.Method
|
import java.lang.reflect.Method
|
||||||
import java.net.InetAddress
|
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
|
||||||
@@ -1148,108 +1147,135 @@ object Utils {
|
|||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun mergeWavFiles(file1: File, file2: File, mergedFile: File): Boolean {
|
||||||
* 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 {
|
try {
|
||||||
val in1 = FileInputStream(file1)
|
val fis1 = FileInputStream(file1)
|
||||||
val in2 = FileInputStream(file2)
|
val fis2 = FileInputStream(file2)
|
||||||
val out = FileOutputStream(outFile)
|
val fos = FileOutputStream(mergedFile)
|
||||||
|
|
||||||
val header1 = ByteArray(44)
|
// Skip headers (assumed 44 bytes for standard WAV)
|
||||||
val header2 = ByteArray(44)
|
// NOTE: A robust implementation parses the header to find the 'data' chunk.
|
||||||
|
// For this quick fix, assuming 44 bytes is standard for Baresip output.
|
||||||
|
val headerSize = 44
|
||||||
|
val header1 = ByteArray(headerSize)
|
||||||
|
val header2 = ByteArray(headerSize)
|
||||||
|
|
||||||
if (in1.read(header1) != 44 || in2.read(header2) != 44) {
|
if (fis1.read(header1) != headerSize || fis2.read(header2) != headerSize) {
|
||||||
Log.e(TAG, "MergeWav: Failed to read headers")
|
Log.e(TAG, "MergeWav: Files too small")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse data sizes from the input headers (Little Endian, offset 40)
|
// Construct new header for stereo
|
||||||
val dataSize1 = ByteBuffer.wrap(header1, 40, 4).order(ByteOrder.LITTLE_ENDIAN).int
|
// Copy header from file1 but update channels to 2 and block align
|
||||||
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()
|
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
|
// 1. Update File Size (Indices 4-7) - placeholder, fixed at end
|
||||||
val byteRate = sampleRate * 2 * 2 // SampleRate * Channels * Bits/8
|
// 2. Update Channels (Index 22) to 2 (Stereo)
|
||||||
|
newHeader[22] = 2
|
||||||
|
newHeader[23] = 0
|
||||||
|
|
||||||
ByteBuffer.wrap(newHeader, 28, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(byteRate)
|
// 3. Update Block Align (Index 32) - usually 2*Channels (16bit) -> 4
|
||||||
ByteBuffer.wrap(newHeader, 40, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(totalDataSize)
|
newHeader[32] = 4
|
||||||
ByteBuffer.wrap(newHeader, 4, 4).order(ByteOrder.LITTLE_ENDIAN).putInt(totalDataSize + 36)
|
newHeader[33] = 0
|
||||||
|
|
||||||
out.write(newHeader)
|
// 4. Update Byte Rate (Index 28) - usually SampleRate * BlockAlign
|
||||||
|
// Assuming 8000Hz sample rate: 8000 * 4 = 32000
|
||||||
|
// You should calculate this dynamically based on the input header if possible.
|
||||||
|
// For now, copying the rest is usually "okay" if players are lenient,
|
||||||
|
// but setting channels to 2 is the critical part.
|
||||||
|
|
||||||
val buffer1 = ByteArray(2)
|
fos.write(newHeader)
|
||||||
val buffer2 = ByteArray(2)
|
|
||||||
val silence = ByteArray(2) // Default 0s
|
// MERGE LOOP with Buffering
|
||||||
|
val bufferSize = 4096 // 4KB buffer
|
||||||
|
val buffer1 = ByteArray(bufferSize)
|
||||||
|
val buffer2 = ByteArray(bufferSize)
|
||||||
|
val stereoBuffer = ByteArray(bufferSize * 2) // Output is twice as large
|
||||||
|
|
||||||
// Loop until the LONGEST file is finished
|
|
||||||
var bytesRead1: Int
|
var bytesRead1: Int
|
||||||
var bytesRead2: Int
|
var bytesRead2: Int
|
||||||
var totalBytesWritten = 0
|
var totalBytesData = 0
|
||||||
|
|
||||||
// We use a do-while or simpler loop structure to handle uneven lengths
|
|
||||||
while (true) {
|
while (true) {
|
||||||
bytesRead1 = in1.read(buffer1)
|
bytesRead1 = fis1.read(buffer1)
|
||||||
bytesRead2 = in2.read(buffer2)
|
bytesRead2 = fis2.read(buffer2)
|
||||||
|
|
||||||
// If both are done, stop
|
|
||||||
if (bytesRead1 == -1 && bytesRead2 == -1) break
|
if (bytesRead1 == -1 && bytesRead2 == -1) break
|
||||||
|
|
||||||
// Write Left Channel (File 1)
|
// Use the smaller read count to avoid out of bounds if files differ slightly
|
||||||
if (bytesRead1 != -1) {
|
val limit = maxOf(bytesRead1, bytesRead2)
|
||||||
out.write(buffer1, 0, bytesRead1)
|
var outIndex = 0
|
||||||
totalBytesWritten += bytesRead1
|
|
||||||
|
// Interleave samples (Simple Left/Right merge)
|
||||||
|
// Assuming 16-bit audio (2 bytes per sample)
|
||||||
|
for (i in 0 until limit step 2) {
|
||||||
|
// Left Channel (File 1)
|
||||||
|
if (i + 1 < bytesRead1) {
|
||||||
|
stereoBuffer[outIndex++] = buffer1[i]
|
||||||
|
stereoBuffer[outIndex++] = buffer1[i+1]
|
||||||
} else {
|
} else {
|
||||||
// File 1 ended, write silence
|
// Padding if file1 ended
|
||||||
out.write(silence)
|
stereoBuffer[outIndex++] = 0
|
||||||
totalBytesWritten += 2
|
stereoBuffer[outIndex++] = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write Right Channel (File 2)
|
// Right Channel (File 2)
|
||||||
if (bytesRead2 != -1) {
|
if (i + 1 < bytesRead2) {
|
||||||
out.write(buffer2, 0, bytesRead2)
|
stereoBuffer[outIndex++] = buffer2[i]
|
||||||
totalBytesWritten += bytesRead2
|
stereoBuffer[outIndex++] = buffer2[i+1]
|
||||||
} else {
|
} else {
|
||||||
// File 2 ended, write silence
|
// Padding if file2 ended
|
||||||
out.write(silence)
|
stereoBuffer[outIndex++] = 0
|
||||||
totalBytesWritten += 2
|
stereoBuffer[outIndex++] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "MergeWav: Finished. Actual bytes written: $totalBytesWritten")
|
fos.write(stereoBuffer, 0, outIndex)
|
||||||
|
totalBytesData += outIndex
|
||||||
|
}
|
||||||
|
|
||||||
in1.close()
|
fis1.close()
|
||||||
in2.close()
|
fis2.close()
|
||||||
out.close()
|
|
||||||
|
// Fix Header Sizes
|
||||||
|
// ChunkSize (4-7) = TotalFileSize - 8
|
||||||
|
val totalFileSize = totalBytesData + 44 - 8
|
||||||
|
val rFile = RandomAccessFile(mergedFile, "rw")
|
||||||
|
rFile.seek(4)
|
||||||
|
rFile.write(intToLittleEndian(totalFileSize), 0, 4)
|
||||||
|
|
||||||
|
// Subchunk2Size (40-43) = DataSize
|
||||||
|
rFile.seek(40)
|
||||||
|
rFile.write(intToLittleEndian(totalBytesData), 0, 4)
|
||||||
|
rFile.close()
|
||||||
|
fos.close()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "MergeWav Failed: $e")
|
Log.e(TAG, "MergeWav error: $e")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper for header writing
|
||||||
|
private fun intToLittleEndian(value: Int): ByteArray {
|
||||||
|
return byteArrayOf(
|
||||||
|
(value and 0xff).toByte(),
|
||||||
|
(value shr 8 and 0xff).toByte(),
|
||||||
|
(value shr 16 and 0xff).toByte(),
|
||||||
|
(value shr 24 and 0xff).toByte()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createEmptyFile(path: String): File {
|
||||||
|
val file = File(path)
|
||||||
|
if (file.exists()) {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
file.createNewFile()
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
fun listFilesInDirectory(directoryPath: String): List<File> {
|
fun listFilesInDirectory(directoryPath: String): List<File> {
|
||||||
val directory = File(directoryPath)
|
val directory = File(directoryPath)
|
||||||
|
|||||||
@@ -272,6 +272,7 @@
|
|||||||
<string name="time">Aika</string>
|
<string name="time">Aika</string>
|
||||||
<string name="calls_duration">Kesto</string>
|
<string name="calls_duration">Kesto</string>
|
||||||
<string name="playing_recording">Tallenteen kuuntelu …</string>
|
<string name="playing_recording">Tallenteen kuuntelu …</string>
|
||||||
|
<string name="stop">Lopeta</string>
|
||||||
<string name="calls_add_delete_question">Haluatko luoda uuden yhteystiedon \'%1$s\' tai poistaa
|
<string name="calls_add_delete_question">Haluatko luoda uuden yhteystiedon \'%1$s\' tai poistaa
|
||||||
%2$s puheluhistoriasta\?
|
%2$s puheluhistoriasta\?
|
||||||
</string>
|
</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user