Android音频编辑器波形与播放/滚动同步问题求助
音频编辑器双向同步问题解决
我正在开发一款音频编辑器,实现流程如下:
- 通过FFmpeg提取音频PCM数据
- 调用
generateWaveformBitmap生成波形Bitmap - 使用Jetpack Compose的LazyRow展示波形列表,屏幕中央设置固定指示线标记播放位置
期望实现双向同步:
- 音频播放时,LazyRow自动滚动,让当前播放位置对齐中央指示线
- 手动滚动LazyRow时,音频自动定位到对应位置
但目前两者无法同步,请求协助解决。
现有代码
波形生成函数
fun generateWaveformBitmap(pcmFile: File, width: Int, height: Int): Bitmap { val pcmData = pcmFile.readBytes() val bytesPerSample = 2 // 16-bit PCM data val samples = pcmData.size / bytesPerSample val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val canvas = android.graphics.Canvas(bitmap) val paint = Paint().apply { color = android.graphics.Color.BLACK strokeWidth = 2f } val maxAmplitude = 32768 // max value for 16-bit PCM data val centerY = height / 2 Log.d("Waveform", "pcmData size: ${pcmData.size}, samples: $samples, width: $width") for (i in 0 until width) { val sampleIndex = (i * samples) / width // Check if sampleIndex is within bounds if (sampleIndex in 0 until samples) { val startIndex = sampleIndex * bytesPerSample // Check if startIndex + bytesPerSample is within pcmData size if (startIndex + bytesPerSample <= pcmData.size) { val sampleValue = ByteBuffer.wrap(pcmData, startIndex, bytesPerSample).short.toInt() val amplitude = (sampleValue.toFloat() / maxAmplitude * centerY).toInt() canvas.drawLine(i.toFloat(), (centerY - amplitude).toFloat(), i.toFloat(), (centerY + amplitude).toFloat(), paint) } else { Log.d("Waveform", "Out of bounds: startIndex + bytesPerSample > pcmData.size, startIndex: $startIndex, bytesPerSample: $bytesPerSample, pcmData size: ${pcmData.size}") } } else { Log.d("Waveform", "sampleIndex out of bounds: $sampleIndex, samples: $samples") } } return bitmap }
Compose音频编辑界面
@OptIn(ExperimentalFoundationApi::class) @Composable fun AudioEdit( context: Context, audioToEdit: File, ) { val waveformBitmapList = remember { mutableListOf<Bitmap>() } val lazyListState = rememberLazyListState() val scope = rememberCoroutineScope() val density = LocalDensity.current val player = remember { MediaPlayer.create(context, Uri.fromFile(audioToEdit))} var isPlaying by remember{ mutableStateOf(false) } val folderName = ObjectFolderPath.MY_APP_INTERNAL_STORAGE_SAVE_FOLDER_NAME val appStorageFile = File(context.filesDir, folderName) val pcmFilePath = File(appStorageFile, "output.pcm") BoxWithConstraints(modifier = Modifier .fillMaxWidth() .fillMaxHeight() ) { val constraintsBox = constraints val halfScreenWidthPx = with(density) { constraintsBox.maxWidth.toPx() } / 2 val halfScreenWidthDp = with(density) { halfScreenWidthPx.toDp() } // 处理FFmpeg提取PCM和生成波形 LaunchedEffect(Unit) { scope.launch(Dispatchers.IO) { if (pcmFilePath.exists()) { pcmFilePath.delete() } // 等待FFmpeg执行完成 val session = FFmpegKit.execute("-i ${audioToEdit.absolutePath} -f s16le -acodec pcm_s16le -vn -ac 1 -ar 44100 -y ${pcmFilePath.path}") val returnCode = session.returnCode if (returnCode.isValueSuccess) { // 生成波形Bitmap val waveformWidthPx = with(density) { player.duration.toDp().toPx() } / 40 val waveformHeightPx = with(density) { 25.dp.roundToPx() } val waveformBitmap = generateWaveformBitmap(pcmFilePath, waveformWidthPx.roundToInt(), waveformHeightPx) waveformBitmapList.add(waveformBitmap) } else { Log.e("FFmpeg", "提取PCM失败: ${session.failStackTrace}") } } } // 播放时同步LazyRow滚动 LaunchedEffect(isPlaying) { if (!isPlaying) return@LaunchedEffect while (isPlaying) { if (waveformBitmapList.isEmpty()) { delay(16) continue } val waveformWidthPx = waveformBitmapList[0].width.toFloat() val totalContentWidth = halfScreenWidthPx * 2 + waveformWidthPx // 计算当前播放进度对应的滚动偏移 val progress = player.currentPosition.toFloat() / player.duration.toFloat() val targetScrollOffset = halfScreenWidthPx + (waveformWidthPx * progress) // 计算目标索引和偏移:第一个item是半屏透明框,所以波形在索引1的位置 val targetIndex = if (targetScrollOffset <= halfScreenWidthPx) 0 else if (targetScrollOffset <= halfScreenWidthPx + waveformWidthPx) 1 else 2 val targetItemOffset = when (targetIndex) { 0 -> targetScrollOffset.roundToInt() 1 -> (targetScrollOffset - halfScreenWidthPx).roundToInt() else -> (targetScrollOffset - halfScreenWidthPx - waveformWidthPx).roundToInt() } lazyListState.animateScrollToItem(targetIndex, targetItemOffset) // 检查是否播放完毕 if (player.currentPosition >= player.duration) { isPlaying = false player.pause() } delay(16) } } // 滚动LazyRow时同步音频定位 LaunchedEffect(lazyListState) { snapshotFlow { lazyListState.firstVisibleItemIndex to lazyListState.firstVisibleItemScrollOffset } .distinctUntilChanged() .collectLatest { (index, offset) -> if (waveformBitmapList.isEmpty() || isPlaying) return@collectLatest val waveformWidthPx = waveformBitmapList[0].width.toFloat() // 计算总滚动偏移量 val totalScrollOffset = when (index) { 0 -> offset.toFloat() 1 -> halfScreenWidthPx + offset.toFloat() 2 -> halfScreenWidthPx + waveformWidthPx + offset.toFloat() else -> 0f } // 计算波形上的相对偏移 val waveformRelativeOffset = (totalScrollOffset - halfScreenWidthPx).coerceIn(0f, waveformWidthPx) // 转换为音频进度 val progress = waveformRelativeOffset / waveformWidthPx val targetPosition = (player.duration * progress).roundToInt() player.seekTo(targetPosition) } } // MediaPlayer资源释放 DisposableEffect(Unit) { onDispose { player.release() } } Column( modifier = Modifier .fillMaxWidth() .fillMaxHeight() .background(Color.DarkGray) ) { Button( onClick = { if (player.currentPosition < player.duration) { if (!isPlaying) { isPlaying = true player.start() } else { player.pause() isPlaying = false } } } ) { Text(text = if (isPlaying) "Pause" else "Play") } val isItemSelected by remember { mutableStateOf(true) } Box(modifier = Modifier .fillMaxWidth() .fillMaxHeight() ) { LazyRow( modifier = Modifier .fillMaxWidth() .wrapContentHeight(), state = lazyListState, ) { item { Box( modifier = Modifier .width(halfScreenWidthDp) .background(Color.Transparent) .height(10.dp) ) } if (waveformBitmapList.isNotEmpty()) { itemsIndexed(waveformBitmapList, key = { idx, _ -> idx }) { _, bitmapItem -> Image( bitmap = bitmapItem.asImageBitmap(), contentDescription = null, modifier = Modifier .background(Color.White) .animateItemPlacement(animationSpec = tween(durationMillis = 100)) ) } } item { Box( modifier = Modifier .width(halfScreenWidthDp) .background(Color.Transparent) .height(10.dp) ) } } // 中央播放指示线 Canvas( modifier = Modifier.fillMaxSize(), contentDescription = "播放指示线" ) { val segmentHeight = size.height / 17 for (i in 0 until 17) { val startY = i * segmentHeight val endY = startY + segmentHeight val color = if (isItemSelected) { if (i % 2 == 0) Color.Red else Color.Yellow } else { if (i % 2 == 0) Color.Red else Color.Transparent } drawLine( color = color, start = Offset(size.width / 2, startY), end = Offset(size.width / 2, endY), strokeWidth = 4f ) } } // 播放时阻止滚动的遮罩 if (isPlaying) { Box(modifier = Modifier .fillMaxSize() .pointerInput(Unit) { detectTapGestures { isPlaying = false player.pause() } } ) } } } } } interface AudioRecorder { fun start(outputFile: File) fun pause() fun resume() fun stopAndSave() }
关键修复点
FFmpeg执行等待:原代码未等待FFmpeg完成就生成波形,可能导致PCM文件未就绪。现在通过
session.returnCode判断执行结果,确保PCM生成成功后再创建波形。播放滚动同步逻辑简化:不再依赖当前可见项索引计算,直接根据播放进度计算总滚动偏移量,映射到LazyRow的索引和偏移,确保波形始终对齐中央指示线。
滚动音频定位逻辑修正:计算总滚动偏移后,减去半屏宽度得到波形相对位置,再转换为音频进度,避免复杂百分比计算错误。
资源释放:添加
DisposableEffect在Composable销毁时释放MediaPlayer,避免内存泄漏。状态优化:移除冗余的
isPlaying22派生状态,直接使用isPlaying监听,减少状态层级。
内容的提问来源于stack exchange,提问作者Kizito Okparanwankwo
相关产品推荐
相关产品推荐

