首发公众号:Rand_cs
Music
本文继续讲述 NES 的基本原理——音乐部分,主要从两个方面讲述,一是与音乐有关的硬件,也就是 CPU 内部的 APU,二是简要说明如何对其编程。
感觉本文说音乐,就尝试在背景放了几首我比较熟悉的游戏、有些上头的 BGM,有猜出来是什么游戏吗?评论区见答案,另外也是第一次尝试在公众号里面放这玩意,希望没有吵到大家。
APU
APU,全名 Audio Processing Unit,它是 CPU 内部的一系列芯片,音乐(如循环播放的那音乐)和音效(跳跃挥刀发射子弹等音效)都是由 APU 产生的。CPU 通过一系列 I/O 端口(0x4000-0x4015, 0x4017)与 APU 交互,就与通过 0x2000-0x2007 与 PPU 交互,通过 0x4016-0x4017 与手柄交互一个道理。
Channels
APU 有 5 个通道,2 个方波,1 个三角波,1 个噪声,1 个 DMC,声音就是一系列波形,通常就是由前 4 个通道来合成想要的波形也就是演奏音乐。而 DMC 用来产生方波三角波噪声产生不了的声音,声音会提前录入 ROM 中,一般很少用到这个通道。
每个通道都有 4 个寄存器,来控制其内部的各个功能单元,总览如下:
下面就来简要说一说这些功能单元。每至于它们如何通过端口寄存器来控制我就不细说了,太多了,一个个说也没啥意思,有兴趣的点击下面链接:
https://wiki.nesdev.org/w/index.php?title=APU
Status Register
写 0x4015 这个寄存器可以用来使能通道:
Bit | Description |
---|---|
0 | 方波 1,0:屏蔽,1:使能 |
1 | 方波 2,0:屏蔽,1:使能 |
2 | 三角波,0:屏蔽,1:使能 |
3 | 噪声,0:屏蔽,1:使能 |
4 | DMC,0:屏蔽,1:使能 |
Frame Counter
APU 的基本时钟为 CPU 时钟 2 分频,除此之外,APU 内部还有一个可编程的时钟,即 Frame Counter/Timer,这个名字有点误人,虽有 "Frame" 这个词,但它与视频信号没什么关系。Frame Counter 的频率为 240Hz,它来驱动其他单元,比如说 Length Counter,Sweep,Envelope,Linear Counter 等等。
Frame Counter 有两种模式,我们可以通过 0x4017 来对 Frame Counter 进行配置,bit7 = 0 为 4 步模式,bit7 = 1 为 5 步模式,4 步模式下 Length Counter 与 Sweep 为 120Hz,Envelope 与 Linear Counter 为 240Hz。而 5 步模式下 Length Counter 与 Sweep 为 96 Hz,而 Envelope 与 Linear Counter 为 192Hz
Timer
Timer 这个单元控制着波形的周期,也就是频率,频率的高低可以反映声音的尖锐 程度,频率越高声音越尖锐,反之越低沉。
而不同的音符对应着不同的频率,对于 CPU 频率 C,音符频率 F,以及周期有一个神奇的公式,比如对于方波来说:
$$P=C/(F \times 16) - 1$$
所以对于第一个八度,NTSC 系统来说,它们的关系如下图所示:
因此我们写入合适的周期便可以得到相应的音符,完整的对应关系表格见下方链接:
https://nerdy-nights.nes.science/downloads/missing/NotesTableNTSC.txt
Length Counter,Linear Counter
这个单元可以控制音长,每个通道的第三个端口比如说 0x4003/0x4007 的高 5 位控制 Length Counter,写入一个 5 位数据,这 5 位是一个索引,根据索引查值然后填写到 Length Counter,然后 Length Couter 就会从这个数开始计时倒数,其计时频率又 Frame Counter 驱动,当计数到 0 时,该通道就安静下来了(如果后续没有音继续播放的话),这就是 Length Counter 控制音长的大致原理。
那张查找表如下所示:
举个例子,如果我写入 0xF(bit4-7为0xF, bit3为0),那么就会将 0x20 填进 Length Counter,然后 Length Counter 从这个数开始倒数。
Linear Counter 为三角波,特有,与 Length Counter 作用类似,只是控制它的端口有 7 位,而 Length Counter 的控制端口只有 5 位,所以 Linear Counter 控制音长更加精细,其他都类似,不再多说。
Envelope Generator
通过端口我们还可以设置音量,音量的大小在波形的体现上就是振幅大小,这个没啥说的。而 Envelope Generator 可以产生固定大小的音量,也可以产生随时间变化的音量。
通道的第一个端口的一部分用来控制音量,比如 0x4000:
---ld nnnn | loop,disable,n |
---|---|
如果 disable = 1,那么音量就是个常量为 n,用 4 位表示,所以大小范围为 [0, 15]
如果 loop = 1,那么每个周期 n 由 15 到 0 循环变化,而前面说过 Envelope 的频率由 Frame Counter 的模式驱动
看过一些关于 NES Audio 编程的一些教程,一般另 disable = 1,直接使用常量音量为了我们能够对音量绝对控制。也使用 Envelope,但这个 Envelope 是我们自己编写在内存的一组音量值,不像系统本身那样单调地循环往复,然后每个周期获取其值写入 nnnn 来控制音量的高低。
Sweep Unit
类似 Envelope 让音量随时间变化,对于方波来说还可以让频率随时间变化,这是方波特有的。方波第二个寄存器 0x4001/0x4005 来控制 Sweep Unit:
eppp nsss | enable,period,negate,shift |
---|---|
enable 使能不用多用,就是用不用这个单元,这个单元的工作过程大致如下:
- 通道的周期(前面 Timer 那儿说的,不是这里的 ppp 表示的周期)右移 sss 表示的数值,得到结果为变化量
- 如果 negate 为真,那么取负
- 目标周期数值等于通道当前的周期值加上变化量
- Sweep Unit 有个计数器,它的周期初始值设为 ppp 表示的数值 + 1,当这个计数器为 0 && enable == 1 && Sweep Unit 静音该通道,那么就调整通道的周期为计算出来的目标周期。
还有一些杂七杂八的情况我就不说了,详细情况见 wiki。
另外上面最后一点提到了一点,Sweep Unit 会静音通道,所谓静音就是说音量为 0,有两种情况 Sweep Unit 会静音通道:
- 当前周期小于 0x8
- 计算出来的目标周期值大于 0x7FF
有兴趣的朋友可以去查看一下前面我所说的不同八度的音符频率,周期值对照表,可以看出里面的周期值最低就是 0x8,最高为 0x7F1
另外可以简单计算一下,如果 negate = 0,shift = 0,当前周期为 p,那么目标周期就为 $p + p(不移动且为正值)=2p$,所以周期值最大不能超过 0x400,也是因此,一些 NES 的开发商从不使用低八度的音。
如何避免呢,目标周期是一直会进行计算的,光是让 enable = 0 是无法避免的,通常会将 negate 设置为 1,让变化量等于负数来避免,而一般就是直接像这个寄存器写入 0x08 来解决问题。
Duty Cycle
几乎都快忘了这东西什么意思了,看到 APU 才又慢慢回想起来,Duty Cycle 占空比,这里 Duty Cycle 只有 4 种用 2 位指示,00 表示 12.5%,01 表示 25 %,10 表示 50%,11 表示 75%,啥意思?直接看图:
这 4 个脉冲周期是一样的,但是高电平相对于总时间的比例是不一样的,也就是占空比 Duty Cycle 不一样。
Linear Feedback Shift Register
线性反馈移位寄存器,这玩意儿噪声通道独有,主要用来产生伪随机二进制序列,大致工作原理就是 bit 0 与 bit1/bit6 进行异或计算,然后寄存器里的值右移 1 位,最后将前面计算的值写进寄存器最高位。
关于伪随机二进制序列的生成可以看 wiki 上的资料,那上面还有 C 代码,有兴趣的可以看看:
https://en.wikipedia.org/wiki/Pseudorandom_binary_sequence
DAC
数模转换器,每个通道都有一个 DAC,它的作用就是将离散的数字量转化为模拟量(电压)的器件,音频信号其实就是模拟信号,其电压随着时间变化,因此通过 DAC 就可以将数字转化为音频信号
Mixer
混音器,混合 5 个通道的 DAC 信号总体输出一个信号,计算方式如下:
$$output = square\_out + tnd\_out$$
$$square\_out = \frac{95.88}{\frac{8128}{square1 + square2}+100}$$
$$tnd\_out = \frac{159.79}{\frac{1}{triangle/8277+noise/12241+dmc/22638}+100}$$
Program
上述主要就是硬件的一些部分,这部分从编程人员的角度来看看如何进行编程,了解一下其大致过程。下面的知识,例子等主要来源于 Nerdy Nights(书呆子的夜晚?),这个网站教程主要就是从编程人员的角度角度如何开发 NES 游戏,有兴趣的朋友强烈建议阅读,需要梯子,觉得麻烦的朋友可以在我后台回复 NES 获取 PDF 版本。
首先最基本的,向端口寄存器写入频率,音量,背后的硬件就会自动产生模拟信号发出声音。我们想要声音丰富多彩,就要用到一些高级的功能,比如说写入如不同的周期来表示不同的音符,使用 Envelope 让音量随着时间变化,使用 Length 让音符持续不同的时间等等。
在 Nerdy Nights 里面没有使用硬件自带的 Envelope,Length Counter,将其使能开关关闭,好让我们对音量对节拍有着绝对控制。下面就来简单其工作的大致过程,我分为两部分:一部分是音乐引擎,二是音乐数据格式
Data
通常来说音频文件有两种,一种是声音文件,声音文件记录了原始声音的二进制采样数据,现今的音乐大都是这种声音文件。而 NES 不同,它类似于 MIDI 文件,这类文件就好比乐谱,它记录了音乐怎么演奏而不是记录实际的音乐,更具体点它就是记录了一首曲子中每个音符的音阶,音量,音长等等。
而关于声音,主要由两种:
- 背景播放的音乐 Music,使用前 4 个通道,有节拍,通常是往复循环。如果某个地方过不去,很容易被洗脑,比如现下播放的,听出来是什么了吗?有被洗脑没?
- 另外一种是音效 sound effects / sfx,,它是由事件触发,而且通常不会循环,比如说魂斗罗里的子弹发射,赤影战士里的挥刀等等效果音
这两种声音的优先级是不同的,效果音是要与玩家进行实时交互的,所以通常优先级更高。优先级体现在什么地方呢?比如说背景音乐要使用方波 2 通道,音效也要使用方波 2 通道,那么方波 2 通道应让给音效使用。
声音数据通常分为 3 类:
- Note,音符,比如 A3,C2 等等
- Note Length,音长,这个音要持续好久,八分音符,四分音符,全音符等等
- Opcodes,操作码,指令那儿也有操作码的概念,其实 NES 的音乐格式就像指令,一条条指令告诉你一个个音符如何演奏。这里操作码就是告诉声音引擎如何运行,是否要循环演奏,是否要调整音量,是否要调整占空比等等。有些操作码还需要提供额外的参数,比如说循环的话循环多少次。说到这里有没有感觉操作码想什么东西,对了就是函数,其实在编程实现上就是一个个函数。
这三种数据都可以用 8bits 的数值来表示,只要在范围上区分开就行,比如说
if x < 0x80 then Note
else if x < 0xA0 then Length
else Opcode
举个例子:quarter, D6 这两个都是实际数值的别名,就像 C 里 #define N 5 一样的道理,这个例子表示的是 D6 这个音持续四分之一个拍子。
多个这样的数据组合起来就是一个数据流,这么一个数据流控制着一条通道的工作,而一首曲子可能用到多个通道,那么就有多个数据流,看个例子,来自于 Nerdy Nights 对勇者斗恶龙的改编:
song3_square1:
.byte eighth
.byte D4, A4, F4, A4, D4, B4, G4, B4
.byte D4, C5, A4, C5, D4, As4, F4, As4
.byte E4, A4, E4, A4, D4, A4, Fs4, A4
.byte D4, A4, Fs4, A4, G4, As4, A4, C5
.byte D4, C5, A4, C5, D4, B4, G4, B4
.byte D4, B4, G4, B4, D4, As4, Gs4, As4
.byte Cs4, A4, E4, A4, D4, A4, E4, A4
.byte Cs4, A4, E4, A4, B3, A4, Cs4, A4
.byte loop
.word song3_square1
song3_tri:
.byte quarter, D6, A6, d_half, G6
.byte eighth, F6, E6, quarter, D6
.byte eighth, C6, As5, C6, A5
.byte quarter, E6, d_whole, D6
.byte quarter, A6, C7, d_half, B6
.byte eighth, G6, F6, quarter, E6
.byte eighth, F6, G6, whole, A6, A6
.byte loop
.word song3_tri
这首曲子用到了方波和三角波的通道,这每一个数据流就相当于该通道的曲谱。
这是音乐本身的数据,一首音乐还应有它的描述信息(头部)以便于音乐引擎的加载,Nerdy Nights 进行了如下设计:
一首音乐的头部信息:
- 有多少个数据流,也就是用到了几个通道
- 每个数据流的描述
每个数据流的描述信息:
- 哪个数据流,哪个通道
- 该流的状态,比如说是否使用,是否暂停等等,前面所说的操作码可能会改变其值
- 对通道的一些初始值的设定
- 数据流指针,说明数据流在哪,好去取
上面只是描述了一部分,有兴趣的可以自行去阅读,还有配套代码
Engine
好了,现在有了音乐数据,怎么播放?这就是音乐引擎的事,来看 Nerdy Nights 如何设计的,由四个部分组成:
- sound_init,初始化操作,比如说使能通道静音之类的,播放音乐的过程中避免不了使用一些变量,初始化这些变量
- sound_load,加载音乐,就是读取音乐头信息,找到各个数据流
- sound_play_frame,播放音乐,播放音乐其实就两个操作,取数据,更新通道端口,对每个通道重复这个操作就是播放音乐了
- sound_disable,顾名思义,禁止播放
Nerdy Nights 使用的引擎由 NMI 驱动,每次 NMI 阶段最后调用 sound_play_frame,我们可以提前写好一串音量值,然后每个滴答时取值更新通道的音量,这就是 Envelope 的实现原理。也可以设置几个变量当作各个通道的 Length Counter,每次滴答时检查其值,只有数到 0 时才会去取新的音符数据然后更新通道,否则保持不变,这就是 Length Counter 的实现原理。
好了,音乐部分就到这里吧,本文主要就是对 APU 以及如何编程做了一个简单介绍,感觉灵魂还是在于音乐本身,NES 里面好多音乐现在听来都还是很不错,不说有多么好听动人,主要是有趣上头,有猜出背景音乐的哪几首曲子吗?答案见留言区。
首发公众号:Rand_cs