第 9 章 声音怎么变成数字:采样与量化
你点名想知道”音视频怎么保存到计算机、保存了哪些信息”。这一章先攻”声音”——因为它是视频的”另一半”,而且声音数字化的思路,是整个多媒体存储的祖宗。
先立一个根本事实,后面全从它长出来:
计算机只认数字(0 和 1)。而现实里的声音,是连续不断的波。
“连续”和”数字”是死对头。所以声音要进计算机,第一道坎就是:怎么把一条连续不断的波,变成一串计算机存得下的数字?
9.1 声音是波:空气中的”压强起伏”
你拍一下手,带动了空气。空气不是整体平移,而是一会儿被压紧、一会儿被放松,这种疏密变化像涟漪一样传出去,就是声波。
对计算机而言,声音在某一个时刻的”状态”,可以用一个数值表示:此刻空气压强相对平静时偏离了多少。偏离大 → 声音响;偏离为 0 → 寂静。
所以一段声音,在现实里是”压强随时间连续变化”的一条曲线。横轴是时间,纵轴是瞬时振幅(压强偏差)。
问题来了:这条曲线是光滑连续的——任意两个时刻之间还有无穷多个时刻,每个时刻都有一个振幅值。计算机存不下”无穷多个值”。
说到这里你或许会想:那条”压强随时间的曲线”到底长什么样?最纯粹的声音,比如一把调音叉发出的标准”哆”,曲线就是一个规整的正弦波(Sine Wave)——平滑地上下起伏,一个周期接一个周期,永远不歇。现实里人说话、乐器合奏,曲线则是无数个不同频率、不同响度的正弦波叠在一起,弯弯绕绕、没有重复。但无论多复杂,计算机对付它们的办法都一样:先拍照(采样),再分等级(量化)。你后面 9.10 节用 Python 推演时,我就让你从最简单的一个正弦波算起——那最能看清”采样”这件事的骨架,不会被现实的杂乱干扰。
9.2 采样:把连续的时间”拍照”
第一个办法叫采样(Sampling)。
想象你拿一台高速照相机,对着那条振幅曲线,每隔固定一小段时间”拍一张照”,只记录”这一刻的振幅是多少”。拍出来的不是连续曲线,而是一串离散的点。
- 两次拍照之间的时间间隔,叫采样周期。
- 每秒拍多少次,叫采样率(Sample Rate),单位是 Hz(次/秒)。
常见采样率:
44100 Hz(44.1kHz):音乐 CD 标准。48000 Hz(48kHz):影视、游戏常用。22050 Hz、8000 Hz:语音、电话那种对音质要求低的场景。
直觉:采样率越高,点越密,连起来越接近原始曲线,声音越保真;采样率越低,点越稀,曲线被”拉直线段”,高频细节丢失,声音发闷、发劈。
你手机录歌和录音棚录歌的差别,很大程度上就在这个采样率(以及下面要讲的位深)。
9.2.1 车轮倒转与摩尔纹:采样不足的乱象(直观理解为什么不够)
采样率不够会出什么岔子?我先不讲公式,给你两个生活画面,你一下就懂。
第一个画面是车轮倒转效应(wagon-wheel effect)。你小时候看汽车广告或电影,车轮明明在往前转,屏幕里却像在倒着转、或者静止不动。原因是什么?摄影机每秒只拍 24 张(采样 24 次/秒),而车轮转得足够快时,两次拍照之间轮子刚好转过”差一点点就一整圈”的角度——于是第二张照片里的辐条,看起来像第一张”往回挪了一点点”。你的眼睛被这些离散的快照骗了,以为轮子在倒退。这和我们采样声波一模一样:采样率跟不上信号变化的速度,高频就被”错看成”低频。
第二个画面是摩尔纹(Moiré)。你拿手机拍电脑屏幕,常会看到一层奇怪的水波纹。屏幕本身是一排排规整的像素点(高频栅格),手机镜头也在按自己的栅格采样,两个栅格一错开,就冒出本来不存在的粗大条纹。这也是”采样跟不上细节”制造出的假象。
把这两个画面翻译回声音:当声音里某个频率,比”每秒采样次数的一半”还要高,它就会被混叠(Aliasing)成另一个低频,听起来像凭空多出来的杂音、或者高频”折叠”回来变成刺耳的怪声。所以工程师必须让采样率明显高于信号里最高频率的 2 倍——这就是下一节奈奎斯特定理的来由。你先记住这个直觉:采样就是”按节拍拍照”,节拍太慢,快动作就会被看成慢动作甚至反动作。
9.3 量化:把振幅的”高度”分级
采样解决了”时间”的离散化。但还有一关:每个采样点上的振幅值,本身是个连续的实数(比如 0.732814…),计算机存的位数有限,不能存无穷位小数。
第二步叫量化(Quantization):把振幅值四舍五入到有限的几个等级上。
- 每个采样值用多少位(bit)来表示,叫位深(Bit Depth)。
16 bit:常见,每个采样有 2^16 = 65536 个可能等级。24 bit:专业录音,2^24 ≈ 1677 万等级,更细腻。8 bit:很糙,能听但满是”沙沙”的量化噪声。
量化必然有误差:真实振幅是 0.7328,你只能存到最近的等级 0.7329。这个”够不到”的差,叫量化误差,听起来就是一层底噪。
直觉:位深越大,等级越密,误差越小,声音越干净;位深越小,等级越糙,底噪越明显。
9.3.1 量化噪声与信噪比:为什么 16bit 比 8bit 干净(直觉)
我再用你能懂的”数字直觉”解释量化噪声。量化误差有多大?它最多就是”半个等级”。假如你把振幅分成 N 个等级(N = 2^位深),那每个采样点上的误差,大约在 1/N 这个量级上下乱跳。
- 8bit:N = 256,误差量级约 1/256 ≈ 0.004,噪声相对明显。
- 16bit:N = 65536,误差量级约 1/65536 ≈ 0.000015,噪声小了 256 倍。
- 24bit:N ≈ 1677 万,误差更小。
怎么衡量”干净程度”?业界用信噪比(SNR, Signal-to-Noise Ratio):有用信号和噪声的比值,取对数后单位是分贝(dB)。有个很省事的直觉结论:每多 1 个 bit(等级翻倍),信噪比大约多 6 dB。于是:
- 8bit ≈ 8 × 6 = 48 dB。什么概念?大概像在安静房间里听收音机底噪,细弱的声音会被盖住。
- 16bit ≈ 16 × 6 = 96 dB。这已接近人耳能分辨的极限动态范围,所以 CD 用 16bit 够”透明”了。
- 24bit ≈ 144 dB。专业录音棚才用,给后期混音留足余量。
你现在懂了:所谓”位深越大越干净”,本质是”等级越密 → 量化误差越小 → 信噪比越高”。你作为写过后端的人,可以把它类比成”浮点数精度”——float32 比 int8 表示得精细,但占更多空间、也更需要算力。声音的位深,就是它每个采样点的”数值精度”。
9.4 声道:几个”麦克风”在录
前面说的都是”一个位置录到的声音”。但实际声音有空间——左边喇叭响和右边响,听感完全不同。
**声道(Channel)**就是”同时录 / 播几路独立声音”:
- 单声道(Mono, 1 声道):一路。没有左右之分,像老广播。
- 立体声(Stereo, 2 声道):左(L)、右(R)两路。能表现左右方位,是音乐、游戏默认。
- 5.1 / 7.1 环绕声:前方左中右 + 后方左右 + 低音炮(.1 是独立低音道),营造被声音包围的感觉,家庭影院、3A 大作常用。
多声道意味着:每秒要存的采样数 = 采样率 × 声道数。立体声的数据量是单声道的两倍。
9.4.1 多声道在内存里怎么摆:交错还是分块
既然数据量 = 采样率 × 声道数 × 位深,那这么多数字在内存/文件里到底怎么排?最常见的方式叫交错(Interleaved):把同一时刻各个声道的采样紧挨着放,再排下一个时刻。以立体声 16bit 为例,字节顺序是:
[L0低][L0高][R0低][R0高][L1低][L1高][R1低][R1高][L2低]...
这里 L0 是”第 0 个采样时刻的左声道”,R0 是同刻右声道,如此往复。为什么这么排?因为播放时声卡要”同时”把左右声道送给两个喇叭,交错存放让它一次顺序读过去就能凑齐一帧,缓存友好、解码简单。
另一种叫分块(Non-interleaved / Planar):先把所有左声道采样连续放完,再放所有右声道:[L0][L1][L2]...[R0][R1][R2]...。某些音频处理库(比如做 FFT 频谱分析)喜欢这种,因为它对”单一路”做运算时内存连续。但游戏实时播放里,交错是绝对主流。你以后看音频 API 文档看到 interleaved 这个单词,就知道它在说”左右有没有交错着放”。
9.5 奈奎斯特采样定理:为什么 44.1kHz 能覆盖人耳
这是整个音频数字化里最该懂的一条定理,名字吓人,道理朴素。
奈奎斯特采样定理:要能无失真地还原一个最高频率为 f 的信号,采样率必须大于 2f(即 > 2×最高频率)。
人耳能听的最高频率大约 20 kHz。按定理,采样率得 > 40 kHz。那为什么用 44.1 kHz 而不是刚好 40 kHz?
两个原因:
- 留余量:采样率卡在理论极限,任何一点误差都会让高频”混叠(Aliasing)“——高频被错误折返成低频杂音。留点余量才稳。
- 历史技术约束:44.1kHz 的来历和早期数字录音用录像带存音频、以及抗混叠滤波器的可实现性有关(这是个有趣的技术史八卦,不影响你理解原理,记住”留余量”就够了)。
所以 44.1kHz 不是玄学,是”奈奎斯特 + 工程余量”算出来的最低够用线。游戏用 48kHz,也是同一思路,且和影视设备对齐。
反过来想:如果你用 8000 Hz 采样,按奈奎斯特,你最多只能还原到 4 kHz 的声音——这正是电话音质”又尖又扁”的根源。你一下就懂了”为什么电话里听不出音乐好坏”。
9.5.1 奈奎斯特的图形化证明:为什么恰好 2f 还不够
定理说”必须大于 2f”,不是”等于”。为什么?我给你一个不用数学公式的几何直觉。
想象一个高频正弦波,它每周期有两个关键点:波峰、波谷。要”认出”这个波,你至少得在每个周期里采到两个点——一个在波峰附近、一个在波谷附近——才能知道”哦,这是个上下摆动的波,而不是一条平线”。如果采样率恰好等于 2f,那就是每个周期刚好 2 个采样点。问题来了:万一你的采样点每次都”恰好”落在波峰和波谷之间的同一个相位上呢?比如你每次都在”过零点”拍照——那拍出来的所有点都是 0,你以为这是静音!
更糟的是,如果采样点稍微偏一点,你采到的不是原波,而是一个频率更低的波(混叠)。所以”等于 2f”是刀尖上的平衡,现实中抗混叠滤波器也不可能完美,于是工程师必须留余量——让采样率明显超过 2f,把那些危险的高频彻底挡在采样能力之外。这就是为什么 44.1kHz 比理论 40kHz 多了 10% 以上的缓冲。
一句话收口:2f 是”理论能分辨”的底线,不是”工程能用好”的线。 这和写代码时给数组留一点余量、给网络超时留一点 buffer,是同一个工程脑子。
9.6 串起来:一段声音”存进计算机”到底存了哪些信息
现在你可以用程序员的眼睛,把一段数字音频看成一份带元信息的数据包:
音频文件 = 文件头(元数据) + 采样数据(PCM)
文件头里存的是”怎么解读这些数据”的说明,通常包括:
- 采样率(每秒多少点):如 44100
- 位深(每个点几位):如 16
- 声道数(几路):如 2
- 编码格式标识(后面第 10 章讲)
采样数据(PCM,脉冲编码调制) 是主体,本质是一个很长的数组:
- 若是单声道 16bit:就是一串
int16数字,每个数字代表”某一时刻的振幅等级”。 - 若是立体声:左右声道交替排列(L, R, L, R, …)。
你用程序读一个 WAV 文件,拿到的就是这么一组数字。播放时,声卡按采样率把这些数字还原成电压起伏,推动喇叭,空气振动,你听到声音。
这一刻你应该有点”啊哈”:所谓”播放声音”,就是把那串数字按固定节奏翻译成电压。所谓”录音”,就是用 ADC(模数转换)把电压拍成那串数字。声音文件里没有”声音”,只有”对声音的数字化描述”。
9.6.1 PCM 在内存里:就是一串你会读的数组
我把它画得更像你熟悉的程序。假设单声道、16bit、采样率 8kHz,录了 1 秒,那你拿到手的就是一个长度为 8000 的 int16 数组(C 里是 short samples[8000],Go 里是 []int16,Python 里是 array('h') 或 numpy.int16)。数组下标就是”第几个采样时刻”,数组元素值就是”那一刻的振幅等级”:
samples[0] = 0 // 第 0 毫秒,寂静
samples[1] = 128 // 第 0.125 毫秒,轻微正压
samples[2] = 256
...
samples[4000]= 32000 // 第 0.5 秒,接近最大振幅(接近波峰)
...
samples[7999]= -100 // 接近 1 秒末,回落
你看,这和你写后端处理一个”时间序列”没有任何本质区别:下标是时间轴,值是观测值。所谓”播放”,不过是按顺序、按固定间隔(1/采样率 秒)把这个数组的值交给声卡;所谓”音频处理”(比如调音量),就是 for i := range samples { samples[i] = samples[i] * 0.8 }——一个朴素的数组遍历。
立体声时,数组长度变成 8000 × 2 = 16000,但语义是”每两个元素是一帧(左、右)“。你之前在 9.4.1 看到的交错布局,在这里就是数组的物理样子。所以 PCM 没有任何神秘:它就是一个(或一组)以时间为下标的整数数组,头文件告诉你”这个数组多长、每元素几位、每秒迈几步”。理解到这一层,你再看音频 API 就只是”读写数组”而已。
9.7 和第 8 章的呼应
你发现了没有,声音数字化和雾,走的是同一套路:
- 雾:先有现象(空气透视)→ 有问题(实时下画不清/省钱/遮丑)→ 有方案(按距离混合颜色)→ 落进参数(Fog Color/Density)。
- 声音:先有现象(连续声波)→ 有问题(连续的东西计算机存不下)→ 有方案(采样+量化把它变数字)→ 落进参数(采样率/位深/声道)。
“连续 → 离散” 这个动作,是数字世界对付一切现实生活的基本招式。下一章你会看到,视频也是同一招,只不过把”振幅”换成了”像素颜色”。
9.8 动手算:一段语音占多大
假设游戏里 NPC 对话,用单声道、16kHz、16bit 录一句 3 秒的话:
- 每秒采样 = 16000 点;每点 2 字节(16bit);单声道不翻倍。
- 原始 PCM = 16000 × 2 × 3 = 96000 字节 ≈ 94 KB。
对比同样的 3 秒,若用音乐标准 44.1kHz/16bit/立体声(第 10 章讲为何不同),则是 44100 × 2 × 2 × 3 ≈ 529 KB,差 5 倍多。所以语音用低采样率完全够(人声主要能量在 3–4kHz 以下,奈奎斯特只需 8kHz 采样),能省大笔存储和内存。这也是为什么游戏里”对话”和”背景音乐”从来不是一套参数。
9.9 用代码读 WAV 头(思想层面)
你不必现在写代码,但知道”文件头长什么样”能破除神秘感。一个 WAV 文件头大致是一组固定顺序的字段(用 C 结构体想就是几个 int/short):
RIFF标记、文件总字节数、WAVE标记;fmt块:编码格式(PCM=1)、声道数、采样率、每秒字节数、块对齐、位深;data块:后面跟着的就是那串 PCM 采样数字。
播放器读头,才知道”每秒读几个点、每个点几位、几路”,才能正确解释 data 里的数字。少了头,那串数字就是天书——这和 9.6 说的”元数据是说明书”完全对应。
9.10 用 Python 亲手算一段正弦波的采样值
光看不如动手。下面这段 Python 你直接粘进解释器就能跑,它把你刚学的”采样 + 量化”两个动作从头演一遍。我们不依赖任何音频库,只用最朴素的数学:
import math
sample_rate = 8000 # 每秒采样 8000 次(电话级,够演示)
duration = 0.01 # 录 0.01 秒,即 10 毫秒
freq = 440 # 声音频率 440Hz(标准音 La)
bit_depth = 16 # 16bit 量化
n = int(sample_rate * duration) # 总共的采样点数 = 80 个
samples = []
for i in range(n):
t = i / sample_rate # 第 i 个采样点的时间(秒)
# 1) 采样:在 t 时刻"拍一张照",读出正弦波的连续振幅(-1.0 ~ +1.0)
value = math.sin(2 * math.pi * freq * t)
# 2) 量化:把 [-1,1] 的连续值,映射到 16bit 整数等级 [-32768, 32767]
level = int(round(value * 32767))
samples.append(level)
print("采样点数:", len(samples))
print("前 8 个量化等级:", samples[:8])你跑一下会看到:samples 是一个长度 80 的普通整数列表,前几个值从 0 附近开始往上爬——因为 sin(0)=0,随后逐渐增大,正是正弦波从过零点出发的样子。这 80 个数字,就是这段 10 毫秒、440Hz 声音”数字化之后”的全部信息。
我让你注意三件事,它们串起了整章:
- 采样体现在
t = i / sample_rate——我们只在这些离散时刻去读振幅,中间的连续变化全被丢弃。 - 量化体现在
int(round(value * 32767))——连续的浮点振幅被强行塞进 65536 个整数等级里,丢弃了小数部分(这就是 9.3 说的量化误差来源)。 samples这个列表本身,就是 9.6.1 讲的那个 PCM 数组——你亲手造出了”声音的数字描述”。
如果你把 sample_rate 从 8000 改成 2000(低于 2×440=880 吗?不,2000>880 仍够,但试试改成 800 看看),或者把 freq 调到 2000 而采样率还是 8000,你会发现 samples 里凭空多出奇怪的低频摆动——那就是 9.2.1 说的混叠活生生发生在你眼前。改几个数、跑两段,比读十段文字都管用。
9.11 Unity 里的 AudioClip 与 PCMReaderCallback(思想层面)
最后落进引擎。你在 Unity 里用 AudioClip 表示一个声音片段,它的底层其实就是”一段 PCM 数据 + 一组说明(采样率/声道/位深)“——和你 9.6.1 看到的数组、9.9 看到的文件头,是同一份东西的不同包装。
Unity 提供一个很有意思的接口思想:PCMReaderCallback。它的意思是:你不必把整段声音预先存好,而是注册一个函数,Unity 在需要播放、且缓冲区快”饿”了的时候,就回调你的函数,把”接下来一小段该播什么”的 PCM 数组交给你来填。
这背后的思想,和你写后端时”流式处理""按需生成”完全一致:
- 你这个函数拿到的,就是一个
float[](Unity 内部常用归一化到 [-1,1] 的浮点,而非 int16,但本质一样:每个元素是一个采样点的振幅)。 - 你往里写的正弦波、噪声、或者实时合成的音效,会被声卡按采样率播出来。
- 它常用于程序化音效(比如引擎噪声、激光音随速度变化),也让你看清”播放”的真相——就是不停地、按节奏地把数组元素喂给声卡。
你不必现在调用它,但请记住这个画面:所谓”播放一段声音”,在引擎眼里不过是一条按固定节拍被消费的采样数组。你 9.10 用 Python 造出的那个 samples 列表,和 Unity 喂给声卡的 float[],是同一个祖先——只不过一个在脚本里、一个在实时音频线程里。理解到这一步,“声音”这件事对你已经没有秘密了。
【思考题】
- 计算机为什么不能直接存”一段连续的声音”?根本矛盾是什么?
- 采样率决定了声音的什么?位深又决定了什么?两者弄反了会怎样理解?
- 用奈奎斯特定理解释:为什么电话音质(8kHz 采样)听不出音乐细节?
- 一段立体声、44.1kHz、16bit 的音频,1 秒钟大约产生多少字节的原始数据?(提示:先算每秒采样点数,再乘声道数和每样本位数字节数)
- 一个 WAV 文件里,除了那串采样数字,还必须存哪些”说明性”信息?为什么少了他播放端就懵了?
【参考答案】
- 根本矛盾是连续 vs 离散:现实声音是时间上连续、振幅上连续的波,任意两时刻间有无穷多个值;计算机只能存有限个离散数字。所以必须把”连续”切断、分级,才能落地成 0/1。
- 采样率决定时间上的精细度(点够不够密,影响高频/保真度);位深决定每个采样点的精度(等级够不够细,影响动态范围和底噪)。弄反的理解:以为”采样率高=音量大”或”位深高=更尖”都是错的——一个管”密不密”,一个管”准不准”。
- 奈奎斯特:能还原的最高频率 = 采样率/2。8kHz 采样最多还原 4kHz,而音乐大量信息在 4kHz 以上(人声齿音、镲片、泛音),这些全被砍掉,所以电话里音乐”扁平、发闷、听不出好坏”。
- 每秒采样点 = 44100;立体声 ×2 声道 = 88200 点/秒;16bit = 2 字节/点;合计 88200 × 2 = 176400 字节/秒 ≈ 172 KB/s。这就是为什么无损音频文件很大(1 分钟 ≈ 10 MB)。
- 必须存:采样率、位深、声道数、编码格式。播放端要靠采样率知道”每秒放几个点”,靠位深知道”每个点几个字节怎么解释”,靠声道数知道”左右怎么分”。少了这些元数据,那串数字就是无意义的乱码——就像寄了一箱零件却不给组装说明书。