mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 10:25:19 +00:00
Media transfers (#440)
* tor voice wip * worky BLE a bit * can send sound * remove tor * ui cleanup * recording time * progress bar color * nicknames for audio * onboarding permissions no microphone * may work * fix destionation * extend * refactor voice input component * fix keyboard collapse issue * send images * wip image open * image sending works * wip waveforms * better * better animation * fix cursor for sending audio * image sending animation * image sending animation * full screen image viewer * gossip sync for fragments too * reduce delays * fix keyboard focus * use v2 for file transfers * do not sync fragments * scrollable image viewer * ui * ui adjustments * nicer animation * seek through audio * add spec * add more details to documentation * File sharing E2E: - Add TLV BitchatFilePacket, FileSharingManager - Implement sendFileNote in ChatViewModel - File receive path: save to files/incoming and render [file] messages with FileMessageItem or FileSendingAnimation during transfer - SAF FilePickerButton and dispatcher wiring; image/file choice to follow in MediaPickerOptions - Add FileViewerDialog with system open/save, FileProvider and file_paths - Hook transfer progress to file sending UI - Manifest: READ_MEDIA_* and FileProvider - Fix MessageHandler saving and prefix for non-image payloads - Add helper utils (FileUtils) * kinda wip * fix buttons * files half working * wip file transfer * file packet has 2-byte TLV and chunks. it wokrs but it sucks * clean * remove gossip sync for fragments * fix audio and image rendering * adjust FILE_SIZE TLV size too * cleanup * haptic * private messages media * read receipts for media * use enum for message type not string * delivery ack checks dont push content * check * animation fix * refactor * ui adjustments * comments * refactor * fix crash on send and receive of the same file * refactor notifications * tests
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
package com.bitchat.android.features.file
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
object FileUtils {
|
||||
|
||||
private const val TAG = "FileUtils"
|
||||
|
||||
/**
|
||||
* Save a file from URI to app's file directory with unique filename
|
||||
*/
|
||||
fun saveFileFromUri(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
originalName: String? = null
|
||||
): String? {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
if (inputStream == null) {
|
||||
Log.e(TAG, "❌ Failed to open input stream for URI: $uri")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "📂 Opened input stream successfully")
|
||||
|
||||
// Determine file extension
|
||||
val extension = originalName?.substringAfterLast(".") ?: "bin"
|
||||
val fileName = "file_${System.currentTimeMillis()}.$extension"
|
||||
|
||||
// Create incoming dir if needed
|
||||
val incomingDir = File(context.filesDir, "files/incoming").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
val file = File(incomingDir, fileName)
|
||||
|
||||
inputStream.use { input ->
|
||||
FileOutputStream(file).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Saved file to: ${file.absolutePath}")
|
||||
file.absolutePath
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save file from URI", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy file to app's outgoing directory for sending
|
||||
*/
|
||||
fun copyFileForSending(context: Context, uri: Uri, originalName: String? = null): String? {
|
||||
Log.d(TAG, "🔄 Starting file copy from URI: $uri")
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
if (inputStream == null) {
|
||||
Log.e(TAG, "❌ Failed to open input stream for URI: $uri")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "📂 Opened input stream successfully")
|
||||
|
||||
// Determine original filename and extension if available
|
||||
val displayName = originalName ?: run {
|
||||
try {
|
||||
context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(android.provider.MediaStore.MediaColumns.DISPLAY_NAME)
|
||||
if (nameIndex >= 0 && cursor.moveToFirst()) cursor.getString(nameIndex) else null
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
val extension = displayName?.substringAfterLast('.', missingDelimiterValue = "")?.takeIf { it.isNotBlank() }
|
||||
?: run {
|
||||
// Try mime type to extension
|
||||
val mime = try { context.contentResolver.getType(uri) } catch (_: Exception) { null }
|
||||
android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime) ?: "bin"
|
||||
}
|
||||
// Preserve original filename (without artificial prefixes), ensure uniqueness
|
||||
val baseName = displayName?.substringBeforeLast('.')?.take(64)?.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
?: "file"
|
||||
var fileName = if (extension.isNotBlank()) "$baseName.$extension" else baseName
|
||||
|
||||
// Create outgoing dir if needed
|
||||
val outgoingDir = File(context.filesDir, "files/outgoing").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
var target = File(outgoingDir, fileName)
|
||||
if (target.exists()) {
|
||||
var idx = 1
|
||||
val pureBase = baseName
|
||||
val dotExt = if (extension.isNotBlank()) ".${extension}" else ""
|
||||
while (target.exists() && idx < 1000) {
|
||||
fileName = "$pureBase ($idx)$dotExt"
|
||||
target = File(outgoingDir, fileName)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
inputStream.use { input ->
|
||||
FileOutputStream(target).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "✅ Successfully copied file for sending: ${target.absolutePath}")
|
||||
Log.d(TAG, "📊 Final file size: ${target.length()} bytes")
|
||||
target.absolutePath
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ CRITICAL: Failed to copy file for sending", e)
|
||||
Log.e(TAG, "❌ Source URI: $uri")
|
||||
Log.e(TAG, "❌ Original name: $originalName")
|
||||
Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for a file based on extension
|
||||
*/
|
||||
fun getMimeTypeFromExtension(fileName: String): String {
|
||||
return when (fileName.substringAfterLast(".", "").lowercase()) {
|
||||
"pdf" -> "application/pdf"
|
||||
"doc" -> "application/msword"
|
||||
"docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
"xls" -> "application/vnd.ms-excel"
|
||||
"xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
"ppt" -> "application/vnd.ms-powerpoint"
|
||||
"pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
"txt" -> "text/plain"
|
||||
"json" -> "application/json"
|
||||
"xml" -> "application/xml"
|
||||
"csv" -> "text/csv"
|
||||
"html", "htm" -> "text/html"
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"png" -> "image/png"
|
||||
"gif" -> "image/gif"
|
||||
"bmp" -> "image/bmp"
|
||||
"webp" -> "image/webp"
|
||||
"svg" -> "image/svg+xml"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"wav" -> "audio/wav"
|
||||
"m4a" -> "audio/mp4"
|
||||
"mp4" -> "video/mp4"
|
||||
"avi" -> "video/x-msvideo"
|
||||
"mov" -> "video/quicktime"
|
||||
"zip" -> "application/zip"
|
||||
"rar" -> "application/vnd.rar"
|
||||
"7z" -> "application/x-7z-compressed"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display
|
||||
*/
|
||||
fun formatFileSize(bytes: Long): String {
|
||||
val units = arrayOf("B", "KB", "MB", "GB")
|
||||
var size = bytes.toDouble()
|
||||
var unitIndex = 0
|
||||
while (size >= 1024 && unitIndex < units.size - 1) {
|
||||
size /= 1024.0
|
||||
unitIndex++
|
||||
}
|
||||
return "%.1f %s".format(size, units[unitIndex])
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is viewable in system viewer
|
||||
*/
|
||||
fun isFileViewable(fileName: String): Boolean {
|
||||
val extension = fileName.substringAfterLast(".", "").lowercase()
|
||||
return extension in listOf(
|
||||
"pdf", "txt", "json", "xml", "html", "htm", "csv",
|
||||
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an incoming file packet to app storage and return absolute path.
|
||||
* Mirrors existing behavior used in MessageHandler (preserves names and folders).
|
||||
*/
|
||||
fun saveIncomingFile(
|
||||
context: Context,
|
||||
file: com.bitchat.android.model.BitchatFilePacket
|
||||
): String {
|
||||
val lowerMime = file.mimeType.lowercase()
|
||||
val isImage = lowerMime.startsWith("image/")
|
||||
val baseDir = context.filesDir
|
||||
val subdir = if (isImage) "images/incoming" else "files/incoming"
|
||||
val dir = java.io.File(baseDir, subdir).apply { mkdirs() }
|
||||
|
||||
fun extFromMime(m: String): String = when (m.lowercase()) {
|
||||
"image/jpeg", "image/jpg" -> ".jpg"
|
||||
"image/png" -> ".png"
|
||||
"image/webp" -> ".webp"
|
||||
"application/pdf" -> ".pdf"
|
||||
"text/plain" -> ".txt"
|
||||
else -> if (isImage) ".jpg" else ".bin"
|
||||
}
|
||||
|
||||
// Prefer transmitted original name; ensure uniqueness to avoid overwrites
|
||||
val baseName = (file.fileName.takeIf { it.isNotBlank() }
|
||||
?: (if (isImage) "img" else "file"))
|
||||
.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
val ext = extFromMime(lowerMime)
|
||||
var safeName = if (baseName.contains('.')) baseName else baseName + ext
|
||||
var idx = 1
|
||||
while (java.io.File(dir, safeName).exists() && idx < 1000) {
|
||||
val dot = safeName.lastIndexOf('.')
|
||||
safeName = if (dot > 0) {
|
||||
val b = safeName.substring(0, dot)
|
||||
val e = safeName.substring(dot)
|
||||
"$b ($idx)$e"
|
||||
} else {
|
||||
"$safeName ($idx)"
|
||||
}
|
||||
idx++
|
||||
}
|
||||
|
||||
return try {
|
||||
val out = java.io.File(dir, safeName)
|
||||
out.outputStream().use { it.write(file.content) }
|
||||
out.absolutePath
|
||||
} catch (_: Exception) {
|
||||
// Fallback to cache dir with uniqueness
|
||||
try {
|
||||
var fallback = safeName
|
||||
var idx2 = 1
|
||||
while (java.io.File(context.cacheDir, fallback).exists() && idx2 < 1000) {
|
||||
val dot = fallback.lastIndexOf('.')
|
||||
fallback = if (dot > 0) {
|
||||
val b = fallback.substring(0, dot)
|
||||
val e = fallback.substring(dot)
|
||||
"$b ($idx2)$e"
|
||||
} else {
|
||||
"$fallback ($idx2)"
|
||||
}
|
||||
idx2++
|
||||
}
|
||||
val out = java.io.File(context.cacheDir, fallback)
|
||||
out.outputStream().use { it.write(file.content) }
|
||||
out.absolutePath
|
||||
} catch (_: Exception) {
|
||||
val tmp = java.io.File.createTempFile(if (isImage) "img_" else "file_", if (isImage) ".jpg" else ".bin")
|
||||
tmp.writeBytes(file.content)
|
||||
tmp.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify BitchatMessageType from MIME string used in file messages.
|
||||
*/
|
||||
fun messageTypeForMime(mime: String): com.bitchat.android.model.BitchatMessageType {
|
||||
val lower = mime.lowercase()
|
||||
return when {
|
||||
lower.startsWith("image/") -> com.bitchat.android.model.BitchatMessageType.Image
|
||||
lower.startsWith("audio/") -> com.bitchat.android.model.BitchatMessageType.Audio
|
||||
else -> com.bitchat.android.model.BitchatMessageType.File
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.bitchat.android.features.media
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
object ImageUtils {
|
||||
fun downscaleAndSaveToAppFiles(context: Context, uri: android.net.Uri, maxDim: Int = 512, quality: Int = 85): String? {
|
||||
return try {
|
||||
val resolver = context.contentResolver
|
||||
val input = resolver.openInputStream(uri) ?: return null
|
||||
val original = BitmapFactory.decodeStream(input)
|
||||
input.close()
|
||||
original ?: return null
|
||||
val w = original.width
|
||||
val h = original.height
|
||||
val scale = (maxOf(w, h).toFloat() / maxDim.toFloat()).coerceAtLeast(1f)
|
||||
val newW = (w / scale).toInt().coerceAtLeast(1)
|
||||
val newH = (h / scale).toInt().coerceAtLeast(1)
|
||||
val scaled = if (scale > 1f) Bitmap.createScaledBitmap(original, newW, newH, true) else original
|
||||
val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() }
|
||||
val outFile = File(dir, "img_${System.currentTimeMillis()}.jpg")
|
||||
FileOutputStream(outFile).use { fos ->
|
||||
scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos)
|
||||
}
|
||||
if (scaled !== original) try { original.recycle() } catch (_: Exception) {}
|
||||
try { if (scaled != original) scaled.recycle() } catch (_: Exception) {}
|
||||
outFile.absolutePath
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Simple MediaRecorder wrapper that records to M4A (AAC) for wide compatibility.
|
||||
* The resulting file has MIME audio/mp4.
|
||||
*/
|
||||
class VoiceRecorder(private val context: Context) {
|
||||
companion object { private const val TAG = "VoiceRecorder" }
|
||||
|
||||
private var recorder: MediaRecorder? = null
|
||||
private val _amplitude = MutableStateFlow(0)
|
||||
val amplitude: StateFlow<Int> = _amplitude.asStateFlow()
|
||||
|
||||
private var outFile: File? = null
|
||||
|
||||
fun start(): File? {
|
||||
stop() // ensure previous session closed
|
||||
return try {
|
||||
val dir = File(context.filesDir, "voicenotes/outgoing").apply { mkdirs() }
|
||||
val name = "voice_" + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + ".m4a"
|
||||
val file = File(dir, name)
|
||||
val rec = MediaRecorder()
|
||||
rec.setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
rec.setAudioChannels(1)
|
||||
rec.setAudioSamplingRate(44100)
|
||||
// Lower bitrate to keep BLE payloads <= 32KiB for fragmentation
|
||||
rec.setAudioEncodingBitRate(32_000)
|
||||
rec.setOutputFile(file.absolutePath)
|
||||
rec.prepare()
|
||||
rec.start()
|
||||
recorder = rec
|
||||
outFile = file
|
||||
file
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start recording: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun pollAmplitude(): Int {
|
||||
return try {
|
||||
val amp = recorder?.maxAmplitude ?: 0
|
||||
_amplitude.value = amp
|
||||
amp
|
||||
} catch (_: Exception) { 0 }
|
||||
}
|
||||
|
||||
fun stop(): File? {
|
||||
try {
|
||||
recorder?.apply {
|
||||
try { stop() } catch (_: Exception) {}
|
||||
try { reset() } catch (_: Exception) {}
|
||||
try { release() } catch (_: Exception) {}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
val f = outFile
|
||||
recorder = null
|
||||
outFile = null
|
||||
return f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
fun CyberpunkVisualizer(amplitude: Int, color: Color, modifier: Modifier = Modifier) {
|
||||
val norm = min(1f, amplitude / 20_000f)
|
||||
val heightFrac by animateFloatAsState(
|
||||
targetValue = 0.1f + 0.9f * norm,
|
||||
animationSpec = tween(120, easing = LinearEasing), label = "amp"
|
||||
)
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val barCount = 24
|
||||
val gap = 6f
|
||||
val bw = (w - gap * (barCount - 1)) / barCount
|
||||
for (i in 0 until barCount) {
|
||||
val phase = (i.toFloat() / barCount)
|
||||
val barH = (0.2f + heightFrac * (0.8f * (0.5f + 0.5f * kotlin.math.sin(phase * Math.PI * 2).toFloat()))) * h
|
||||
val x = i * (bw + gap)
|
||||
val y = (h - barH) / 2f
|
||||
drawRect(color.copy(alpha = 0.85f), topLeft = androidx.compose.ui.geometry.Offset(x, y), size = androidx.compose.ui.geometry.Size(bw, barH))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.bitchat.android.features.voice
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
object VoiceWaveformCache {
|
||||
private val map = ConcurrentHashMap<String, FloatArray>()
|
||||
fun put(path: String, samples: FloatArray) { map[path] = samples }
|
||||
fun get(path: String): FloatArray? = map[path]
|
||||
}
|
||||
|
||||
fun normalizeAmplitudeSample(amp: Int): Float {
|
||||
val a = max(0, amp)
|
||||
val norm = ln(1.0 + a.toDouble()) / ln(1.0 + 32768.0)
|
||||
return norm.toFloat().coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
fun resampleWave(values: FloatArray, target: Int): FloatArray {
|
||||
if (values.isEmpty() || target <= 0) return FloatArray(target) { 0f }
|
||||
if (values.size == target) return values
|
||||
val out = FloatArray(target)
|
||||
val step = (values.size - 1).toFloat() / (target - 1).toFloat()
|
||||
var x = 0f
|
||||
for (i in 0 until target) {
|
||||
val idx = x.toInt()
|
||||
val frac = x - idx
|
||||
val a = values[idx]
|
||||
val b = values[min(values.size - 1, idx + 1)]
|
||||
out[i] = (a + (b - a) * frac).coerceIn(0f, 1f)
|
||||
x += step
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
object AudioWaveformExtractor {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
fun extractAsync(path: String, sampleCount: Int = 120, onComplete: (FloatArray?) -> Unit) {
|
||||
scope.launch {
|
||||
onComplete(runCatching { extract(path, sampleCount) }.getOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
private fun extract(path: String, sampleCount: Int): FloatArray? {
|
||||
val extractor = MediaExtractor()
|
||||
extractor.setDataSource(path)
|
||||
val trackIndex = (0 until extractor.trackCount).firstOrNull { idx ->
|
||||
val fmt = extractor.getTrackFormat(idx)
|
||||
val mime = fmt.getString(MediaFormat.KEY_MIME) ?: ""
|
||||
mime.startsWith("audio/")
|
||||
} ?: return null
|
||||
extractor.selectTrack(trackIndex)
|
||||
val format = extractor.getTrackFormat(trackIndex)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: return null
|
||||
val codec = MediaCodec.createDecoderByType(mime)
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
|
||||
val durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0L
|
||||
val desiredBins = sampleCount.coerceAtLeast(32)
|
||||
val bins = FloatArray(desiredBins) { 0f }
|
||||
val counts = IntArray(desiredBins) { 0 }
|
||||
|
||||
val inBuffers = codec.inputBuffers
|
||||
val outInfo = MediaCodec.BufferInfo()
|
||||
|
||||
var sawEOS = false
|
||||
while (!sawEOS) {
|
||||
// Queue input
|
||||
val inIndex = codec.dequeueInputBuffer(10_000)
|
||||
if (inIndex >= 0) {
|
||||
val buffer = codec.getInputBuffer(inIndex) ?: inBuffers[inIndex]
|
||||
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||
if (sampleSize < 0) {
|
||||
codec.queueInputBuffer(inIndex, 0, 0, 0L, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
} else {
|
||||
val presentationTimeUs = extractor.sampleTime
|
||||
codec.queueInputBuffer(inIndex, 0, sampleSize, presentationTimeUs, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
|
||||
// Dequeue output
|
||||
var outIndex = codec.dequeueOutputBuffer(outInfo, 10_000)
|
||||
while (outIndex >= 0) {
|
||||
val outBuf = codec.getOutputBuffer(outIndex)
|
||||
if (outBuf != null && outInfo.size > 0) {
|
||||
outBuf.order(ByteOrder.LITTLE_ENDIAN)
|
||||
val shortCount = outInfo.size / 2
|
||||
val shorts = ShortArray(shortCount)
|
||||
outBuf.asShortBuffer().get(shorts)
|
||||
|
||||
// Map this buffer to bins using timestamp range
|
||||
val startUs = outInfo.presentationTimeUs
|
||||
val endUs = startUs + bufferDurationUs(format, outInfo.size)
|
||||
val startBin = binForTime(startUs, durationUs, desiredBins)
|
||||
val endBin = binForTime(endUs, durationUs, desiredBins).coerceAtMost(desiredBins - 1)
|
||||
|
||||
var idx = 0
|
||||
for (bin in startBin..endBin) {
|
||||
// aggregate portion of buffer to this bin
|
||||
val window = shorts.size / max(1, (endBin - startBin + 1))
|
||||
val begin = idx
|
||||
val finish = min(shorts.size, idx + window)
|
||||
var acc = 0.0
|
||||
var cnt = 0
|
||||
for (i in begin until finish) {
|
||||
acc += abs(shorts[i].toInt())
|
||||
cnt += 1
|
||||
}
|
||||
val avg = if (cnt > 0) (acc / cnt) else 0.0
|
||||
val norm = (avg / 32768.0).coerceIn(0.0, 1.0).toFloat()
|
||||
bins[bin] = max(bins[bin], norm)
|
||||
counts[bin] += 1
|
||||
idx += window
|
||||
}
|
||||
}
|
||||
codec.releaseOutputBuffer(outIndex, false)
|
||||
outIndex = codec.dequeueOutputBuffer(outInfo, 0)
|
||||
}
|
||||
|
||||
if (outInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
|
||||
sawEOS = true
|
||||
}
|
||||
}
|
||||
|
||||
codec.stop()
|
||||
codec.release()
|
||||
extractor.release()
|
||||
|
||||
// Smooth + normalize
|
||||
var maxVal = 0f
|
||||
for (i in bins.indices) {
|
||||
if (counts[i] == 0) continue
|
||||
maxVal = max(maxVal, bins[i])
|
||||
}
|
||||
if (maxVal <= 0f) maxVal = 1f
|
||||
for (i in bins.indices) {
|
||||
bins[i] = (bins[i] / maxVal).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
return bins
|
||||
}
|
||||
|
||||
private fun bufferDurationUs(format: MediaFormat, bytes: Int): Long {
|
||||
return try {
|
||||
val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
val channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
val samples = bytes / 2 / max(1, channels)
|
||||
(samples * 1_000_000L) / max(1, sampleRate)
|
||||
} catch (e: Exception) {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private fun binForTime(presentationUs: Long, durationUs: Long, bins: Int): Int {
|
||||
if (durationUs <= 0L) return 0
|
||||
val frac = presentationUs.toDouble() / durationUs.toDouble()
|
||||
return (frac * bins).toInt().coerceIn(0, bins - 1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user