You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android音频编辑器波形与播放/滚动同步问题求助

音频编辑器双向同步问题解决

我正在开发一款音频编辑器,实现流程如下:

  • 通过FFmpeg提取音频PCM数据
  • 调用generateWaveformBitmap生成波形Bitmap
  • 使用Jetpack Compose的LazyRow展示波形列表,屏幕中央设置固定指示线标记播放位置

期望实现双向同步:

  1. 音频播放时,LazyRow自动滚动,让当前播放位置对齐中央指示线
  2. 手动滚动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()
}

关键修复点

  1. FFmpeg执行等待:原代码未等待FFmpeg完成就生成波形,可能导致PCM文件未就绪。现在通过session.returnCode判断执行结果,确保PCM生成成功后再创建波形。

  2. 播放滚动同步逻辑简化:不再依赖当前可见项索引计算,直接根据播放进度计算总滚动偏移量,映射到LazyRow的索引和偏移,确保波形始终对齐中央指示线。

  3. 滚动音频定位逻辑修正:计算总滚动偏移后,减去半屏宽度得到波形相对位置,再转换为音频进度,避免复杂百分比计算错误。

  4. 资源释放:添加DisposableEffect在Composable销毁时释放MediaPlayer,避免内存泄漏。

  5. 状态优化:移除冗余的isPlaying22派生状态,直接使用isPlaying监听,减少状态层级。

内容的提问来源于stack exchange,提问作者Kizito Okparanwankwo

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.18 00:28:14