Android高性能音频:写一个云顶S10强音争霸混音器

背景

这个想法是7月底产生的。上半年云顶之弈返场 S10 强音争霸,很喜欢这个版本里面的每个羁绊自带 BGM 而且还可以组合混音的模式,但是在7月底就下线了,不知道再次返场会是什么时候。于是出于对这个模式的喜欢,就有了手搓一个混音器的想法。

这是我第一次接触原生 Android 开发。之前一直做的 Web 全栈,前端框架和后端 Java 已经写得很熟练了。得益于 Java 的经验,上手 kotlin 没有花太多时间,而且做前端的时候也经常用 React,所以很容易就习惯了 Compose 的写法。

git地址:

kurimuson/heartsteel: 云顶S10强音争霸混音器

项目结构

我是用的是 Kotlin Multiplatform 的模板,UI 部分和各个平台的部分是分开的。目前我只做了 Android 部分,后期有时间了会考虑编译为桌面端。

虽然 Kotlin Multiplatform 可以适配全平台,但是 IOS 端和 Web 我就不考虑了,因为一是我没有 Mac 和 iPhone,也不会 Swift,所以不考虑适配 IOS;二是音频资源非常多,单个文件也非常大,做成 Web 端不太合适,而且这个 APP 的核心功能多音轨同步音频播放可能在浏览器上实现会有困难。

如果未来 Kotlin Multiplatform 官方能直接适配鸿蒙端,我也许会考虑将它在华为手机上跑起来。但是现在我不会研究这个,因为我不太想将 Kuikly 这种框架引入到我这一个很简单纯粹的项目中。

核心功能

这个 APP 的核心功能就是多音轨同步音频播放。如何保证每一条音轨始终是同步播放的,这个要求看似简单,实际上实现起来有一定难度。

踩坑

在一开始,我查阅到 Android 音频播放可以使用 MediaPlayer 来实现。于是花了一天时间来做了下相关的功能。但是很快我发现,MediaPlayer 虽然能正确地将音频文件放出声,但是一旦我在拖动进度条之后,跳转到指定时间位置,就发现音频播放不同步了。因为云顶之弈的每个音频都是同样的一个节奏,如果某一条音轨有哪怕是 0.1s 的延迟,那么听起来就明显感觉节奏对不上。

经过查阅资料,我发现原来我使用的是可变比特率的 ogg 音频,核心原因在于 VBR 编码的时间定位不确定性 。VBR(可变比特率)的本质是"根据音频复杂度动态调整比特率"------复杂段落用高比特率,简单段落用低比特率。这种特性导致字节位置播放时间并非线性对应。简单来说,音频也是可以理解为"一帧一帧"的数据,可变比特率就是某一帧的时间长,某一帧的时间短。跳转到指定时间后,实际上不是到绝对的时间,而是到那个时间点所在的帧。所以多音轨下比特率不同,那么帧就不会在同一位置,延迟就产生了。所以在这一阶段,我将音频全部换成了固定比特率的 mp3,延迟在很大程度上缓解了。

接着踩坑

为什么说"在很大程度上缓解了",而不是说"解决了"呢?因为我发现在小概率下这个现象还是存在。经过我的一番检查,发现 MediaPlayer 是存在一定延迟的 ,这个延迟在执行 seekTo() 跳转方法的时候很容易出现,因为每一个音频文件它执行 seekTo() 所花的时间是不同的,这就导致了执行完后播放也就不完全同步了。虽然这个延迟很低,对于单音频文件播放不影响,但是我需要的是零延迟的多音轨同步音频播放,所以 MediaPlayer 这个方案被废弃了。

后来又试了试 ExoPlayer,发现与 MediaPlayer 存在同样的延迟问题。我对 Android 音频没啥经验,询问 AI 得知,MediaPlayer 这种方案的音频路径是经过系统混音器与媒体框架,缓冲区管理固定或自适应缓冲,策略偏稳定。我想应该就是这个原因吧。

解决办法

既然现成的 MediaPlayer 音频 API 不能满足我的需求,那么有没有其它的解决方案呢?于是我在 Google 的 Android 官方文档上找到了高性能音频方案。

高性能音频 | Android NDK | Android Developers

通常,高性能音频应用所需的不仅仅是简单的声音播放或录制功能。它们需要响应式实时系统行为。
本部分介绍了最大限度缩短音频延迟的一般原则。此外,本部分还提供了音频采样建议,以帮助您选择最佳采样率,以及考虑使用浮点数表示音频数据的优缺点。

这不就正好对上了我的需求吗,于是,按照文档的建议,我决定使用 Oboe

得益于我还有一点 C++ 的基础,所以做起来就不是那么费力了。

在 androidMain 中添加 C++ 相关支持

如下所示,在 composeApp 下的 build.gradle 中添加如下代码。由于我还是对 Java 更加熟悉,以及也知道 JNI 的写法,所以我一并在配置里面开启了 androidMain 模块的 Java 代码支持,也就是说针对 Android 部分可以实现 Java 与 Kotlin 混写。

kts 复制代码
android {
    ...

    defaultConfig {
        ...

        // C++
        externalNativeBuild {
            cmake {
                cppFlags.add("-std=c++17")
                arguments("-DANDROID_STL=c++_shared")
            }
        }
    }
    
    ...

    // Java适配补充
    sourceSets {
        getByName("main") {
            java.srcDirs("src/androidMain/java") // 确保Java源码目录被包含
        }
    }

    // C++
    buildFeatures {
        prefab = true
    }
    externalNativeBuild {
        cmake {
            path = file("src/androidMain/cpp/CMakeLists.txt")
            version = "4.1.0"
        }
    }
}

可以看到下图我的代码中 Android 部分:我使用 C++ 对播放器进行底层实现,使用 Java 对播放器进行封装,使用 Kotlin 作为 MainActivity 入口以及 commonApp 中接口的实现对接。

对于上图中的几个 Oboe 命名的文件,在此解释下:

OboeEngine.cppOboeEngine.h:音频播放器的底层类,Oboe 的具体实现;

jni-bridge.cppOboeEngine.cppOboeEngine.java 的 JNI 桥;

OboeEngine.java: JNI 方法的封装,将 OboeEngine.cpp 封装在 Java 对象内部,供外部调用;

OboePlayer.java:抽象类,播放器的逻辑封装,例如播放回调、跳转等相关方法,具有一个播放器最基础的功能;

OboeListPlayer.javaOboeMixerPlaye.javarOboePlayer.java的子类,不同类型的播放器的具体实现。

PCM 是音频硬件的"原生语言",Oboe 不支持压缩音频格式,并非技术限制,而是"为低延迟场景做减法"的设计选择------ 通过聚焦 PCM 这一硬件原生格式,规避了解码开销,简化了实时处理链路,从而实现其核心价值:"让应用以最低延迟控制音频硬件"。

在 Android 游戏开发的文档中可以找到 Oboe 低延迟音频的使用方法。

低延迟音频 | Android game development | Android Developers

因此我的项目中构建 Oboe 的代码如下:

cpp 复制代码
auto result = mStreamBuilder
        .setCallback(this)
        ->setDirection(oboe::Direction::Output)
        ->setPerformanceMode(oboe::PerformanceMode::LowLatency) // 请求低延迟模式
        ->setSharingMode(oboe::SharingMode::Exclusive) // 请求独占模式
        ->setUsage(oboe::Usage::Game) // 声明游戏用例
        ->setSampleRate(mSampleRate) // 使用48000Hz采样率(传入参数固定为48000)
        ->setChannelCount(mChannels)
        ->setFormat(audioFormat)
        ->openStream(&mStream);

具体代码可以在 git 上详细查看。

音频可视化

我觉得音频可视化可以算是这个项目的一大亮点。以前在前端使用 AudioContext 做过音频可视化,可以看我的个人网站:

日语笔记 - 音乐播放器 intelyes.club

因为 Compose 同样有 Canvas 组件,于是我将页面底部的可视化效果复刻到了 Compose 中,效果如下:

对比我网站上的音乐播放器,可以说是效果基本一致了。

获取频谱数据

在前端中,是使用 AudioContext 播放音频,在 AudioContext 中获取频谱信息,大致代码如下:

ts 复制代码
...
let actx = new AudioContext();
let decoded = await actx.decodeAudioData(buf)
let analyser = actx.createAnalyser();
analyser.connect(actx.destination);
// 创建Buffer节点
let sourceNode.buffer = this.decoded;
sourceNode.loop = false;
sourceNode.connect(this.analyser);
// 快速傅里叶变换, 必须为2的N次方
analyser.fftSize = 2048;
...

在获取到频谱信息数组后,就可以将频谱信息数组的值通过 Canvas 绘制出来,达到音频可视化的目的。本项目也是用的这个大致思路。

但是 Oboe 没有提供类似于 js 中 analyser.fftSize = 2048 快速傅里叶变换的方法,那么如何获取频谱信息呢?

在使用 Oboe 播放音频的时候,具体播放的代码是在 Oboe 回调到系统音频线程 OboeEngine::onAudioReady 方法中进行的。在这个方法中会拿到当前音频帧的 buffer 数据,所以根据这个数据就能得到当前音频帧的频谱信息。

当然直接将 buffer 当做频谱绘制出来是不行的,analyser.fftSize 这里,AudioContext 会做快速傅里叶变换处理,最终给出一组平滑的频谱数据。于是在本项目中,我使用 kissfit 插件进行相关的计算,最终得到了与 AudioContext 中基本一致的频谱数据,实现了这一效果。详细实现步骤就不在这里讲了,具体可以看 AudioSpectrumAnalyzer.cpp 中的代码实现。

mborgerding/kissfft: a Fast Fourier Transform (FFT) library that tries to Keep it Simple, Stupid

我在 OboePlayer.java 中已经封装了获取频谱数据的方法,这一块的完整 C++ 和 Java 代码是可以复制到你的项目中直接使用的

绘制到界面上

绘制到界面上也不难。大致方法为,声明一个 Compose,在其内部创建一个循环方法,每隔 16ms 获取一次频谱数据,更新到 mutable 变量,触发重组更新界面,实现近似于 60 帧的频谱可视化效果。

kt 复制代码
val scope = rememberCoroutineScope()
val rectList = remember { mutableStateListOf() }

LaunchedEffect(Unit) {
    scope.launch {
        while (true) {
            render() // 获取频谱数据,更新到rectList
            delay(16)
        }
    }
}

Canvas() {
    // rectList更新,触发重组,实现界面更新
    drawRect()
    drawRect()
}

基本思路就是这样,实际代码肯定与这有点不同,但是核心步骤都是一样的。

结尾

第一次做原生 Android,难免会对其中的一些概念理解得有所偏差。欢迎各位大佬指正,也欢迎喜欢云顶的玩家能够喜欢我做的混音器。

现在打开 APP,拖动进度条时所有音轨能精准同步,底部的频谱跟着 BGM 节奏跳动,那种 "亲手实现喜欢的功能" 的成就感,远超过技术本身的难度。这个项目里没有复杂的架构,更多是 "遇到问题就解决问题" 的直白。

如果有人也怀念云顶 S10 的羁绊 BGM,或者对 "低延迟多音轨播放" "Compose 音频可视化" 感兴趣,欢迎来 kurimuson/heartsteel: 云顶S10强音争霸混音器 看看源码,有疑问或优化建议也随时提 Issue ------ 毕竟技术的乐趣,除了自己实现需求,更在于和同好分享碰撞时的惊喜。

最后也算圆了自己一个小遗憾。就算游戏里的版本下线了,那些熟悉的 BGM,也能在自己写的 APP 里一直听下去。

相关推荐
灿烂阳光g9 小时前
domain_auto_trans,source_domain,untrusted_app
android·linux
低调小一11 小时前
Android传统开发 vs Android Compose vs HarmonyOS ArkUI 对照表
android·华为·harmonyos
雨白11 小时前
Java 多线程指南:从基础用法到线程安全
android·java
00后程序员张12 小时前
详细解析苹果iOS应用上架到App Store的完整步骤与指南
android·ios·小程序·https·uni-app·iphone·webview
程序员江同学13 小时前
ovCompose + AI 开发跨三端的 Now in Kotlin App
android·kotlin·harmonyos
2501_9151063214 小时前
Xcode 上传 ipa 全流程详解 App Store 上架流程、uni-app 生成 ipa 文件上传与审核指南
android·macos·ios·小程序·uni-app·iphone·xcode
消失的旧时光-194314 小时前
Kotlinx.serialization 使用讲解
android·数据结构·android jetpack
灿烂阳光g15 小时前
SELinux 策略文件编写
android·linux