本文章已授权微信公众号郭霖(guolin_blog)转载。
本文主要讲解的是音频基础概念、交叉编译原理和实践(LAME的交叉编译),是基于Android平台,示例代码如下所示:
AndroidAudioDemo
另外,iOS平台也有相关的文章,如下所示:
音视频开发之旅——音频基础概念、交叉编译原理和实践(LAME的交叉编译)(iOS)
音频基础概念
在进行音频开发的之前,了解声学的基础还是很有必要的。
声音的物理性质
在初中物理的时候学过,声音是由三要素组成:音调、响度和音色。
音调
声音的高低叫做音调。物体振动得越快,发出声音的音调就越高;物体振动得越慢,发出的音调越低。频率(过零率,指信号的符号变化的比率)决定了音调,频率越高,波长越短,声音更容易绕过障碍物,也就是能量衰减越小,反之得到相反的结论。
响度
声音的强弱叫做响度。我们可以一般用分贝(dB)来描述响度,分贝越大,声音响度越大,反之得到相反的结论。
音色
声音的品质叫做音色,它反映了每个物体发出的声音特有的品质。例如在同样的音调和响度下,吉他和钢琴的声音听起来是不同的,也就是音色是不同的。波的形状决定声音的音色,吉他和钢琴音色不同就是因为它们介质产生的波形不同。
业界来说,人耳能够听到频率范围大约为20Hz~20kHz,对3kHz~4kHz频率范围内的声音比较敏感,对于较低或者较高频率的声音,人耳的敏感度会减弱;在分贝较低时,听觉的频率特性会很不均匀,反之就会较为均匀。一个频率范围较宽的音乐,最佳的分贝范围为80dB~90dB,超过90dB就会损害人耳,105dB是人耳的极限。
声音在不同的介质传播的速度也会不一样,在空气中的传播速度为340m/s,不过在真空是无法传播的。
有时候我们在空旷的地方或者高山大喊的时候,会听到回声(echo),产生回声的原因是声音在传播的过程中遇到障碍物后反弹回来后再次让我们听到,但是如果这两种声音传回到我们耳朵的时差小于80毫秒的话,我们就无法分辨这两种声音。
音频数字化
将声音模拟信号转换为数字信号的过程称之为音频数字化,这里需要经过三个步骤:采样、量化和编码。
采样
首先对模拟信号进行采样,采样是指在时间轴(横轴)对信号进行数字化,根据奎斯特定理(采样定理,我们要按比声音最高音频高两倍以上的频率对声音进行采样,这个过程也称为AD转换。上面提过的人耳能够听到的频率为20Hz~20kHz,所以一般采样频率为44.1kHz,也就是说1秒会采样44100次。
量化
上面提到的,具体每个采样需要怎样处理呢?这就需要量化,量化是指在幅度轴(纵轴)上对信号进行数字化,要注意的是,和上面提到的采样形成平面直角坐标系,举个例子:用16bit的二进制信号表示这个声音的一个采样,16bit等于一个short,表示范围为[-32768, 32767],也就是说有65536个可能取值,所以在幅度上分为65536层。
编码
最后一步就是要将采样的数据进行存储,也就是是需要进行编码,编码就是按照一定的格式记录采样和量化后的数据数据,例如:顺序存储和压缩存储等等。常用的格式为音频的裸数据格式,也就是脉冲编码调制(Pulse Code Modulation,简称PCM)。描述一段PCM的数据需要这几个概念:采样率(sampleRate)、量化格式(sampleFormat,也称为位深度)和声道数(channel)。比特率用于衡量音频数据单位时间内的容量大小,也就是一秒时间内的比特数目,我们以常见的CD格式和DVD-Audio格式为例子:
CD格式的采样率为44100Hz,量化格式为16bit(2byte),声道数为2,那么它的比特率为:
44100 * 16 * 2 = 1411200bps
转换可得1411200bps / 1024 = 1378.125Kibps
DVD-Audio格式的采样率为96000Hz,量化格式为24bit(3byte),声道数为6.那么它的比特率为:
96000 * 24 * 6 = 13824000bps
转换可得13824000bps / 1024 = 13500Kibps,再转换可得13500Kibps / 1024 ≈ 13.18Mibps
一般来说一首歌曲的时间大概在4分钟左右,那我们算下CD格式和DVD-Audio格式会占用多大的存储空间,如下所示:
CD格式:1411200bps * 4 * 60 = 338688000b,转换可得338688000b / 8 / 1024 / 1024 ≈ 40.37MiB
DVD-Audio格式:13824000bps * 4 * 60 = 3317760000b,转换可得3317760000b / 8 / 1024 / 1024 = 395.51MiB
由数据可得,DVD-Audio格式一秒时间内的比特数目大于CD格式,因此它的音质会更好,当然所占的储存空间也会相应得大。
压缩编码
由上面可以看到一首歌如果仅仅是已CD格式去存储的已经占用了40.37MiB,如果只是存储在存储设备上(例如:硬盘或者光盘)那还可以接受,但是如果在网络上实时在线传输的话,这样的大小实在是太大了,所以我们需要对其进行压缩编码,压缩编码里有个指标叫做压缩比,压缩比是小于1,压缩比越小(越接近0),丢失的信息就越多,反之得出相反的结论。压缩算法有两种:无损压缩和有损压缩。无损压缩是指解压后的数据能够复原;有损压缩是指解压后的数据不能够复原,压缩导致的丢失得越多,还原的失真就越大。
有如下常用的压缩编码格式:
WAV编码
WAV(Waveform Audio File Format)是微软专门为Windows开发的一种编码格式,它会在PCM数据格式的前面加上44字节,分别用来描述该PCM数据的采样率、声道数、量化格式。
优点:音质非常好,有大量软件支持。
缺点:占用的存储空间较大。
适用场合:多媒体开发的中间文件、音乐和音效素材。
MP3编码
MP3(MPEG-1或者MPEG-2 Audio Layer III)是一种有损压缩的编码格式,它通过舍弃PCM数据人类听觉不重要的部分,已达到压缩成较小文件的目的,对于大多数用户来说,它的音质和不压缩的音频没有明显的下降。我们常用LAME编码MP3文件,下面会讲解到。
优点:音质在高码率(≥128Kbit/s)表现不错,同时压缩比也比较高;有大量硬件和软件支持,兼容性不错。
适用场合:高码率(≥128Kbit/s)的音频。并且需要比较好的兼容性。
AAC编码
AAC(Advanced Audio Coding,高级音频编码)是一种高压缩比的编码格式,由于采用多声道和使用低复杂性的描述方式,使其比几乎所有的传统编码方式在同规格的情况下更胜一筹。目前衍生出LC-AAC、HE-AAC v1、HE-AAC v2三种主要的编码格式。LC-AAC是比较传统的AAC,主要编码中高码率(≥80Kbit/s)的音频;HE-AAC v1是高效AAC,是对AAC的扩展,它使用频段复制(SBR)提高频域的压缩效率,适用于中低码率(≤80Kbit/s);HE-AAC v2结合使用了频段复制(SBR)和参数立体声(PS)提高立体声信号的压缩效率,进一步降低了对码率的需要(接近于50%),主要编码低码率(≤48Kbit/s)的音质。大部分编码器都设置为≤48Kbit/s自动启用PS,>48Kbit/s就关闭PS,箱单与HE-AAC v1。
优点:音质在中低码率(<128Kbit/s)表现优异,多用于视频中音频轨的编码。
适用场合:中低码率(<128Kbit/s)的音频,多用于视频中音频轨的编码。
Ogg编码
Ogg在各种码率下都有优秀的表现,尤其在中低码率的场景表现不错,同时它不收到软件专利的限制,完全免费。Ogg有着非常出的的算法,可以用更小的码率编码出更好的音质,举个例子:128Kbit/s的Ogg音质甚至比192Kbit甚至更高的MP3还要好。
优点:可以用更小的码率编码出更好的音质,在各种码率下都变现优异。
缺点:目前兼容性不够好,流媒体特性不支持。
适用场合:语音聊天的音频消息。
Android平台增加C和C++支持
Android提供了一种编译框架,叫做JNI(Java Native Interface),用于允许运行于JVM的Java或者Kotlin代码去调用本地代码(C、C++、汇编语言)。大概步骤为将相关的C/C++代码放在项目模块的cpp目录下,在构建项目的时候,Gradle会将这些代码和应用的代码一起打包到原生库,然后Java或者Kotlin代码就可以通过JNI去调用原生库中的函数。什么时候需要用到JNI呢?有以下几种情况:
应用程序需要一些平台的特性支持,但是Java层没有提供相应的API支持,例如:OpenSL ES的使用)
调用一些已经存在并且已是成熟方案的C/C++库,例如:使用LAME编码MP3文件、使用FFmpeg处理音频或者视频、使用OpenGL ES处理视频特效。
应用程序对部分逻辑的运行速度有较高的要求,那么这部分就可以用C/C++实现,再通过JNI向Java层提供访问接口。
我们需要以下组件:
Android原生开发套件(NDK):这是一套可以让开发者使用C/C++的工具。
ndk-build脚本或者CMake:用于构建原生库。
LLDB:Android Studio用于调试原生代码的程序,默认情况下,它会随同Android Studio的安装而安装。
这里讲解下ndk-build脚本和CMake的区别,在讲解之前,我们要了解下下面的内容:
GNU、GCC、gcc、g++
GNU:它是一个完全自由的操作系统,起源于GNU计划。
GCC:GNU Compiler Collection(GNU编译器套件)的缩写,它是一组GNU操作系统中的编译器集合,可以用于编译C、C++、Java、Go等语言。
gcc:GCC中的GNU C Compiler(C编译器)。
g++:GCC中的GNU C++ Compiler(C++编译器)。
对于.c文件和.cpp文件,gcc会分别当作c文件和cpp文件编译,而g++会统一当作cpp文件编译。
编译C/C++的四个步骤
接下来我们要了解一下使用gcc(GNU Compiler Collection,GNU编译器套件)生成可执行二进制文件的大概过程:
预处理(Preprocess)
预处理(Preprocess):预处理会处理一些编译前的准备工作,把一些#define的宏定义完成文本替换,然后将#include里的文件复制到.cpp文件,如果.h文件里还有.h文件,那么就会递归展开,要注意的是,在这一步中,代码注释会被忽略。通过g++ -E命令将.c文件预处理为.i文件,它是文本文件。
编译(Compile)
编译(Compile):编译是把代码转换成汇编代码,同时检查词法规则和语法规则,如果没有出现语法错误,那么不管逻辑是否错误都不会报错。通过g++ -S命令将.i文件转换为.s文件,它是文本文件。
汇编(Assemble)
汇编(Assemble):汇编是把汇编代码(.s文件)转换为机器码。通过g++ -c命令将.s文件转换为.o文件(目标文件),它是二进制格式。
链接(Link)
C/C++代码经过汇编后生成的.o文件(目标文件),它是二进制文件,但是它不是最终可执行的,需要和系统组件(例如:标准库、动态链接库)链接起来才能得到可执行的二进制文件(Executable File),完成这个过程的组件叫做链接器(Linker)。链接分为静态链接和动态链接,生成的文件叫做静态库和动态库。
静态库
静态库在Linux下为.a文件,在Windows下为.lib文件。之所以称之为静态库,是因为在链接阶段会将.o文件和引用到的库一起链接打包到可执行文件中,它有如下特点:
会在编译时期完成静态库对函数库的链接。
程序在运行的时候与函数无关,方便移植。
会浪费一定的空间和资源,因为所有目标文件和涉及到的函数库被链接合成一个可执行文件。
动态库
动态库在Linux下为.so文件,在Windows下为.dll文件。动态库在程序编译时不会被链接到目标文件,而是在程序运行时才被载入,它有如下特点:
把对一些库函数的链接载入推迟到程序运行的时期。
不同的应用程序如果调用相同的库,那么在内存中只需要有一份该共享库的实例,可以实现进程之间的资源共享,节省了空间。
由于动态库是在程序运行的时候才载入,因此解决了静态库对程序的更新、部署和发布带来的麻烦,只需要更新动态库就可以了,即增量更新。
开发者可以在程序代码中控制链接载入,即显示调用。
Make
Make其实是一个批量处理的工具,它是通过调用Makefile文件中开发者指定的命令来进行编译链接,例如调用gcc或者其他编译器的命令。上面提的ndk-build脚本就是使用NDK基于Make来构建项目。
CMake
如果是简单的工程Makefile文件手写起来还是比较轻松的,但是如果复杂的工程手写起来就比较麻烦了,换平台还要重新修改,所以就有上面提到的CMake。CMake是一个开源的跨平台自动化建构系统,它可以根据CMakeList.txt文件自动生成Makefile文件来给上面的Make工具使用。它也是目前Android Studio编译NDK默认构建工具,当然也可以使用上面提到的ndk-build脚本,官方也是支持的。
本机编译
我们要在PC上运行一个二进制的程序(要注意的是,是以源码的方式进行编译,而不是以包管理器的方式去安装)会经过如下步骤:
得到这段程序的源代码,它可以是自己编写的源代码,也可以是从第三方开源网站上下载的源代码。
在PC上编译链接这些源代码生成可执行文件。
在终端(Terminal)下执行该可执行文件。
总结就是使用本机器的编译器和链接器,将源代码编译链接成一个可以在本机器运行的程序,这个编译过程叫做本机编译,它是正常的编译过程。
交叉编译
了解完本机编译后,交叉编译就好理解了,它就是一个平台(例如:PC)上生成另外一个平台(例如:Android、iOS、其他嵌入式设备)可执行的程序。这里的编译机器是PC,所以编译器是安装在PC上,并且运行在PC上的,而这个编译器叫做交叉工具编译链。那其实为啥需要交叉编译呢?因为运行程序的目标平台运算能力和存储能力都是有限的,尽管现在iOS和Android设备的性能越来越强劲,但是和PC还是有一定的距离,而且ARM平台下的编译工具和整个编译过程异常繁琐,所以PC是最佳选择。目前大部分的嵌入式开发平台都提供本身平台交叉编译所需要运行在PC上的交叉工具编译链。
在所有的编译器中,包括自行安装在PC上的编译器和嵌入式平台的交叉工具编译链,都包含以下这几个工具:
CC:编译器,作用是对C或者C++源文件编译成汇编文件。
AS:将汇编文件翻译成机器码,生成目标文件,汇编文件使用的是指令助记符。
AR:打包器,它可以从一个库增加或者删除目标代码模块。
LD:链接器,作用是为前面生成的目标代码分配地址空间,将多个目标文件链接成一个库或者可执行文件。
GDB:调试工具,它可以对正在运行的程序进行代码调试。
STRIP:消除最终生成的库文件或者可执行文件其中的源码。
NM:查看静态库文件中符号表。
Objdump:查看静态库或者动态库的方法签名。
在Android的NDK提供的交叉工具编译链就在开发者用到的ndk下的prebuilt/darwin-x86_64/bin路径中,它提供了上面这些工具。我的路径如下所示:
/Users/tanjiajun/Library/Android/sdk/ndk/26.1.10909125/prebuilt/darwin-x86_64/bin
LAME的交叉编译
我们了解完交叉编译后,以LAME库为例进行实践。
先介绍一下LAME库,它是目前最优秀也是最常用的MP3编码引擎。当码率达到320Kbit/s以上的时候,LAME编码出来的音频质量几乎可以和CD音质媲美,并且还能保证其文件体积非常小,因此如果要在移动端编码MP3文件,使用LAME是唯一选择。
下面来讲解下,在Android平台下如何交叉编译LAME库,并且打印LAME版本。
Android Studio准备工作
首先我们的Android Studio要下载好NDK,然后新建一个Android项目,右键模块点击“Add C++ to Module”,上面也提及过,Android Studio编译NDK的默认构建工具是CMake,所以我们会看到添加完毕后会有相关的代码和文件生成,例如生成了cpp文件夹和里面相关的cpp文件和CMakeList.txt文件。
下载LAME库并解压,复制到项目中
然后在SourceForge下载最新版本的LAME库,目前为3.100,点击下面文本即可下载:
lame-3.100.tar.gz
下载完成后,解压文件得到lame-3.100文件夹,然后找到libmp3lame文件夹,把里面的.c和.h文件全部复制到Android Studio项目生成的cpp文件夹下,我这边会新建一个lame的文件夹来存放这些文件,这样看起来目录会整洁点,我这边写了个的Python脚本用于把文件夹里的.c和.h文件复制到指定的目录,同时还会打印复制后的绝对路径。我已经把它push到示例代码中,文件名为CopySpecifiedFiles.py,代码如下所示:
import os
import shutil
oldDir = '/Users/tanjiajun/lame-3.100/libmp3lame'
newDir = '/Users/tanjiajun/StudioProjects/AndroidAudioDemo/app/src/main/cpp/lame'
cExtension = '.c'
hExtension = '.h'
if not os.path.exists(newDir):
os.makedirs(newDir)
for file in os.listdir(oldDir):
extension = os.path.splitext(file)[-1]
if extension == cExtension or extension == hExtension:
shutil.copy(os.path.join(oldDir, file), newDir)
print(os.path.join(newDir, file))
然后再找到上面提到的lame-3.100文件夹下的include文件夹,把里面的lame.h文件同样复制到Android Studio的lame文件夹。
编译项目
接下来,我们需要修改CMakeList.txt文件,利用上面脚本打印的绝对路径,修改后代码如下所示:
cmake_minimum_required(VERSION 3.22.1)
project("audiodemo")
add_library(
${CMAKE_PROJECT_NAME}
SHARED
audiodemo.cpp
lame/reservoir.c
lame/mpglib_interface.c
lame/machine.h
lame/fft.h
lame/set_get.c
lame/quantize_pvt.h
lame/psymodel.h
lame/newmdct.c
lame/id3tag.h
lame/lame-analysis.h
lame/id3tag.c
lame/reservoir.h
lame/lameerror.h
lame/set_get.h
lame/quantize.c
lame/fft.c
lame/l3side.h
lame/newmdct.h
lame/quantize.h
lame/gain_analysis.c
lame/encoder.c
lame/lame.c
lame/bitstream.c
lame/quantize_pvt.c
lame/presets.c
lame/bitstream.h
lame/encoder.h
lame/gain_analysis.h
lame/lame_global_flags.h
lame/psymodel.c
lame/lame.h
lame/tables.c
lame/tables.h
lame/takehiro.c
lame/util.c
lame/util.h
lame/vbrquantize.c
lame/vbrquantize.h
lame/VbrTag.c
lame/VbrTag.h
lame/version.c
lame/version.h
)
target_link_libraries(
${CMAKE_PROJECT_NAME}
android
log
)
然后我们make project,build完后以下文件会报错,这里列出解决的办法:
util.h
在570行,如图所示:
解决办法:
extern float fast_log2(float x);
fft.c
在47行,如图所示:
解决办法:删除该行代码。
set_get.h
在24行,如图所示:
解决办法:
#include "lame.h"
其他错误
如图所示:
解决办法:修改app模块中的build.gradle.kts文件,在android函数中增加如下代码:
android {
...
defaultConfig {
...
externalNativeBuild {
cmake {
cFlags("-DSTDC_HEADERS")
}
}
}
...
}
修改后,再次make project后编译成功,CMake会帮我们自动生成libaudiodemo.so文件,过程上面也提过了,这里就不再赘述。
打印LAME库的版本
新建MainActivity,这里我使用Compose写界面,并且在AndroidManifest.xml添加相关的代码,AndroidManifest.xml的代码我就不贴了,详细可查看该demo。调用getLameVersion函数就能获取当前LAME版本,要注意的是,需要调用System.loadLibrary("audiodemo")把生成的动态库加载进来。代码如下所示:
/**
* Created by TanJiaJun on 2023/12/13.
*/
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ContentView()
}
}
@Composable
private fun ContentView() {
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
val (topBox, lameVersionText) = createRefs()
Box(
modifier = Modifier
.fillMaxWidth()
.height(45.dp)
.background(Purple80)
.constrainAs(topBox) {
start.linkTo(parent.start)
top.linkTo(parent.top)
end.linkTo(parent.end)
}
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = packageManager.getApplicationLabel(applicationInfo).toString(),
fontSize = 18.sp,
color = White
)
}
Text(
modifier = Modifier.constrainAs(lameVersionText) {
start.linkTo(parent.start)
top.linkTo(topBox.bottom)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
text = getLameVersion(),
fontSize = 16.sp,
color = Black
)
}
}
/**
* 获取当前LAME版本
*
* @return 当前LAME版本
*/
private external fun getLameVersion(): String
private companion object {
init {
System.loadLibrary("androidaudiodemo")
}
}
}
运行后,我们就可以看到界面有个居中的3.100文本,这就是目前编译的LAME版本,代表我们编译LAME库成功。
我的GitHub:TanJiaJunBeyond
Android通用框架:Android通用框架
我的掘金:谭嘉俊
我的简书:谭嘉俊
我的CSDN:谭嘉俊