NES APU

前言

NES (小霸王、FC、红白机)模拟器开发中,各部分其实都不简单。而 APU 和声音系统的难点在于,我们可能缺乏相关的知识。desdev 上虽然有硬件各部分的详细说明,却不成系统,很零散。本文将围绕 “声音的产生”、“NES APU 的组成”、“播放 APU 声音” 三个知识点并结合代码的方式介绍如何实现 NES APU,希望对你编写自己的模拟器有帮助。

声音的基本知识

我们知道声音是由振动产生的。物体的振动会激发波,这种波我们称之为声波。声波通过介质传播到达我们的耳朵,我们就听到了声音。

如同一切波,声波同样具有频率、振幅属性,声波的频率、振幅决定了声音的音高(Pitch)和音量(Volume)。频率为 261.63 Hz 的声音,就是中音“哆” (C4),而频率为 293.66 Hz 的声音就是中音 “来” (D4),频率为 329.63 Hz 的声音就是中音“咪”(E4)。

除了音高、音量外,我们听到的声音还有音色的差别,例如钢琴和吉他的音色就不同。实际上,音色是由声波的波形决定的。例如,正弦波跟方波听起来的感受就完全不同。正弦波听起来沉闷,而方波听起来更尖锐。

播放波形声音

既然声音是振动产生的波,那只要我们能够产生一个波形,并让扬声器按这个波形振动,我们就能听到声音了。可见,播放声音共两步:先产生波形,再送去扬声器。

如果让写一段产生正弦波的代码,相信我们都能不假思索地写出如下代码:

std::vector<float> sine_wave() {
	constexpr const float dx = 0.001;
	std::vector<float> v;
	for (float x = 0; x < 1; x += dx) {
		v.push_back(sin(x));
	}
	return v;
}

虽然这份代码能够产生正弦波,但想要把这份代码应用到声音系统中,还需要搞清楚如下几个问题,否则我们就不知道为何听不到正确的声音或干脆听不到声音:

  1. dx 的取值有什么讲究
  2. x < 1 的物理意义
  3. 声波的频率如何在代码中体现

要回答以上问题,我们不妨逆向思考,从声波的数字化说起。现假设有一段时长为 1 秒频率为 440 Hz 的正弦波声音,我们要把它存储到文件中,该如何做呢?我们很容易就能想到这样一种方案(如下图):按固定的时间间隔在波形上采样一个点,然后把这个点的高度用一个数字表示,这样就形成了一串数字,最后把这串数字保存到文件就行了。

PCM

这种数字化声波的方法就叫 PCM。由于采样间隔是固定的,所以 1 秒钟内的样本数也是固定的,这个数就叫采样率。常见的采样率有 44100 Hz(CD 音质)、48000 Hz(标准 DVD 音质)、96000 Hz(蓝光音质)。样本数字化时所用的二进制位数就叫位深度。显然,采样率越高、位深越大,就越接近原来的波形(也就是精度越高),但数据量也就越大。

波形的产生就是声波数字化的反向过程,只不过我们用程序产生波形。

回到上面的问题。dx 应该取采样率的倒数,表示采样间隔;如果 dx 为采样间隔,则 x < 1 表示时长 1 秒。至于声音的频率,我们对上面的代码稍加修改即可:

std::vector<float> sine_wave(float freq) {
	constexpr const float dt = 1 / 44100;
	constexpr const float PI = 3.1415926536;
	std::vector<float> v;
	for (float t = 0; t < 1; t += dt) {
		v.push_back(sin(2 * PI * t * freq));
	}
	return v;
}

好了,终于 sine_wave 产生的波形是一段正确的波形了,我们可以把它送到扬声器听听了。不过,先等等,为啥采样率取 44100 而不取 96000?播放数字化音频时,如果音频的采样率超过声卡的硬件能力,就需要降采样。由于 44100 Hz 音质已经足够,且现代 PC 基本支持,所以采用 44100 Hz 采样率。如果声卡支持更高的采样率,当然也可以用更大的值。

不同的系统播放声音的方式不同,但都支持 PCM。在 Windows 上,可以使用 waveOutXXX 函数簇播放 PCM 波形声音,它的播放流程如下:

  1. 先使用 waveOutOpen 打开声音设备
  2. 准备波形数据
    1. 分配缓冲区,填充波形数据
    2. 分配 WAVEHDR 结构体
    3. 将缓冲区信息填写到 WAVEHDR.lpDataWAVEHDR.dwBufferLength 字段中
    4. 使用 waveOutPrepareHeader 准备 WAVEHDR 结构体
  3. 使用 waveOutWrite 写入波形数据
  4. 如果还要继续播放,重复 2、3 步
  5. 如果播放完毕,使用 waveOutClose 关闭声音设备

音乐与音效

当我们按一定的节奏更改声音的频率,就形成了旋律。例如,按以下顺序更改频率就会听到“小星星”的旋律:

时间(毫秒) 目标频率
0 261.63
500 261.63
1000 392.00
1500 392.00
2000 440.00
2500 440.00
3000 392.00

游戏中除了背景音乐外,还有各种音效,它们往往在跳跃、攻击、爆炸时触发。音效的原理与旋律一样,也是随着时间修改声音的一个或者多个属性,只不过音效一般时长很短、旋律简单。例如,按以下时间函数修改音量,就得到了一个淡出音效:

音量淡出函数图像

示例波形:

音量淡出示例波形

而按以下时间函数修改频率,就得到一个类似滑音的音效:

滑音函数图像

示例波形:

APU 的组成

有了上面的基本知识,就很容易理解 NES APU 的组成了。NES APU 包含 5 个波形生成器,称为声音通道。它们分别是,2 个方波生成器、1 个三角波生成器、1 个噪声产生器和 1 个 DMC 通道。

每个生成器至少包含以下组件,用于生成指定频率的波形:

  1. 定时器(Timer)。如同上文 sine_wave 函数里面有循环,硬件上的循环用定时器实现。定时器用于驱动序列产生器(Sequencer),从而控制波形频率。
  2. 序列产生器(Sequencer)。用于产生波形,例如方波波形或者三角波波形。

它们之间的关系如下图:

Timer ---> Sequencer

用代码表示如下:

Timer timer;
Sequencer sequencer;
timer.ontick([&sequencer](){
    sequencer.tick();
});
int sample = sequencer.value();

dt 呢?因为定时器本身也需要时钟驱动,所以 dt 实际上是定时器时钟源的频率倒数。三角波生成器的定时器时钟源频率等于 CPU 时钟频率;方波与噪声生成器的定时器时钟源频率为 CPU 时钟频率的 1/2。由此可见,三角波的采样率约为 1.789773 MHz;方波和噪声的采样率约为 894.8865 KHz。

定时器通常由归零计数器实现,代码表述如下:

class Timer {
public:
    void set(unsigned val) { _reload = val; }
    void tick() {
        if (_counter) --_counter;
        else {
            /* onTick: ... */
            _counter = _reload;
        }
    }
private:
    unsigned _reload = 0;
    unsigned _counter = 0;
};

而序列产生器通常由循环数组实现,下文有详细介绍。

接下来,我们看看 APU 的各个生成器。

方波

方波的波形如下:

    +----+    +----+    +----+
    |    |    |    |    |    |
    |    |    |    |    |    |
    |    |    |    |    |    |
----+    +----+    +----+    +----

为了控制方波的时长、音量等属性,方波通道除了定时器和序列产生器,还包含以下组件:

  1. 包络生成器(Envelope Generator)。用于控制音量,支持固定音量和淡出音量(线性递减),音量最大值 15,最小值 0。它的时钟源为帧计数器(Frame Counter),但它内部包含分频器(Divider),可以进一步减小频率。
  2. 滑音单元(Sweep Unit)。用于实现滑音音效。它实现滑音的原理是,按一定算法定时修改通道的定时器。定时器时间间隔改变将导致波形频率发生变化,从而产生滑音效果。该单元也由帧计数器驱动,但输入频率被降低了一半。它内部也包含分频器,可以进一步减小频率。
  3. 长度计数器(Length Counter)。用于控制时长,它也由帧计数器驱动,但输入频率被降低了一半。

各组件的关系如下:

                 Sweep -----> Timer
                   |            |
                   |            |
                   |            v 
                   |        Sequencer   Length Counter
                   |            |             |
                   |            |             |
                   v            v             v
Envelope -------> Gate -----> Gate -------> Gate ---> (to mixer)

用代码表示如下:

FrameCounter fc;
Timer timer;
Sequencer sequencer;
Envelope env;
SweepUnit sweep;
LengthCounter length;

timer.ontick([&sequencer](){
    sequencer.tick();
});

fc.ontick([&length, &sweep, &env](bool half){
    if (half) {
        length.tick();
        sweep.tick();
    }
    env.tick();
});

sweep.ontick([&timer](){
    unsigned newval = /* ... */;
    timer.set(newval);
});

int output = env.value();
if (sweep.mute()) {
    output = 0;
} else if (!sequencer.value()) {
    output = 0;
} else if (!length.value()) {
    output = 0;
}

方波生成器的序列产生器实现方式与以下代码相似:

class PulseSequencer {
public:
    void tick() { _cursor = (_cursor + 1) % 8; }
    int value() { return _sequence[_cursor]; }
private:
    int _cursor = 0;
    int _sequence[8] = {0, 0, 0, 0, 1, 1, 1, 1};
};

方波除了频率、振幅等常规属性,还有一个独特属性叫占空比(Duty Cycle)。NES APU 支持 4 种占空比,分别是:12.5%、25%、50% 和 75%。占空比的实现方法是使用不同的序列:

int sequences[4][8] = {
    {0, 0, 0, 0, 0, 0, 0, 1},
    {0, 0, 0, 0, 0, 0, 1, 1},
    {0, 0, 0, 0, 1, 1, 1, 1},
    {1, 1, 1, 1, 1, 1, 0, 0}
};

以上各组件的搭配,可以生成各种方波:

方波示例

三角波

三角波,顾名思义,波形是三角形的:

  /\    /\    /\    /\
 /  \  /  \  /  \  /  \
/    \/    \/    \/    \

三角波通道与其它通道不同,它不支持音量控制,只支持频率与时长控制。因此,除了定时器和序列产生器,还包含以下组件:

  1. 线性计数器(Linear Counter)。用于精细的控制时长,它由帧计数器驱动。
  2. 长度计数器(Length Counter)。它的作用、功能、特性等与方波通道的完全一样。虽然,它也能控制三角波的时长,但由于它的时钟信号频率太小,所以控制精度不如线性计数器高

各组件的关系如下:

      Linear Counter   Length Counter
            |                |
            v                v
Timer ---> Gate ----------> Gate ---> Sequencer ---> (to mixer)

用代码表示如下:

FrameCounter fc;
Timer timer;
Sequencer sequencer;
LengthCounter length;
LinearCounter linear;

timer.ontick([&sequencer, &liear, &length](){
    if (linear.value() && length.value()) {
        sequencer.tick();
    }
});

fc.ontick([&length, &linear](bool half){
    if (half) {
        length.tick();
    }
    linear.tick();
});

int output = sequencer.value();

三角波生成器的序列产生器实现方式与以下代码相似:

class TriangleSequencer {
public:
    void tick() { _cursor = (_cursor + 1) % 32; }
    int value() { return _sequence[_cursor]; }
private:
    int _cursor = 0;
    int _sequence[32] = {
        15, 14, 13, 12, 11, 10,  9,  8,  7,  6,  5,  4,  3,  2,  1,  0
         0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12, 13, 14, 15 };
};

可见,上面的各种组件的搭配,只能让方波通道生成频率和时长不同的方波。

噪声

噪声通道通常用来模拟枪炮、爆炸等音效,比如“魂斗罗”(Contra)游戏开场音乐最后的一声爆炸。这个通道除了定时器和序列产生器,还包含以下组件:

  1. 包络生成器(Envelope Generator)。它的作用、功能、特性等与方波通道的包络生成器完全一样。
  2. 长度计数器(Length Counter)。它的作用、功能、特性等与方波通道的完全一样。

各组件的关系如下:

   Timer --> Shift Register   Length Counter
                   |                |
                   v                v
Envelope -------> Gate ----------> Gate --> (to mixer)

用代码表述如下:

FrameCounter fc;
Timer timer;
Sequencer sequencer;
LengthCounter length;
Envelope env;

timer.ontick([&sequencer](){
    sequencer.tick();
});

fc.ontick([&length, &env](bool half){
    if (half) {
        length.tick();
    }
    env.tick();
});

int output = env.value();
if (!sequencer.value()) {
    output = 0;
} else if (!length.value()) {
    output = 0;
}

噪声通道的序列产生器比较有意思,它是由线性反馈移位寄存器(LFSR)实现的,此寄存器的运作方式如下:

bit:  14 13 12 11 10 9 8 7 6 4 3 2 1     0
       ^                   |       |     |
       |                   v       v     |
       |                 \"1"""""""0"/   |
       |     $400E.7 ---->\   Mux   /    |
       |                   \_______/     |
       |                       |         |
       |       /"""""//<-------'         |
       `------( XOR ((                   |
               \_____\\<-----------------'

我做了一个线路演示,点击这里体验。

线性反馈移位寄存器在此处的作用是产生随机的 0 和 1,所以噪声通道的序列产生器能够产生 0、1 随机序列。噪声通道的序列产生器产生用代码表述如下:

class NoiseSequencer {
public:
    void tick() { _lfsr.tick(); }
    int value() { return _lfsr.bit0(); }
private:
    LFSR _lfsr{1};
};

帧计数器(Frame Counter)

帧计数器是非常重要的组件,它的作用是给包络生成器、线性计数器、长度计数器、滑音单元提供时钟信号以及产生软中断(IRQ)。
可以粗略地把帧计数器也理解成一个分频器,但是它不按固定倍率分频。它有两个工作模式:4 步模式和 5 步模式,每个模式分频的倍率略不同。

4 步模式下,每 14915 个 APU 时钟周期经历 4 个步骤,每个步骤经历的时钟周期数不尽相同,平均约每 3728.75 个 APU 周期一个步骤。每个步骤触发一次时钟信号,换算下来时钟信号频率约为 239.996 Hz。因帧率为 60 Hz,所以一个时钟信号也叫一个“四分帧”(Quarter Frame),而每两个时钟信号也叫一个“二分帧”(Half Frame)。

5 步模式下,每 18641 个 APU 时钟周期内触发 4 次时钟信号,时钟信号频率约为 192.025 Hz。

帧计数器触发时钟信号的具体时机可以参考 nesdev

实现上,帧计数器也可以看作是计时器和序列产生器产生的组合,代码表述如下:

class FrameCounter {
public:
    FrameCounter() {
        _timer.ontick([this](){
            /* onTick: ... */
            _sequencer.tick();
            _timer.set(_sequencer.value());
        });
        _timer.set(_sequencer.value());
    }
    // 按 CPU 频率调用即可
    void tick() {
        _timer.tick();
    }
private:
    Timer _timer;
    Sequencer _sequencer;
};

class FourStepSequencer {
public:
    void tick() { _cursor = (_cursor + 1) % 4; }
    unsigned value() { return _sequence[_cursor]; }
private:
    int _cursor = 0;
    unsigned _sequence[] = {7457, 7456, 7458, 7458};
};

class FiveStepSequencer {
    /* ... */
    unsigned _sequence[] = {7457, 7456, 7458, 14910};
};

滑音单元

滑音单元常用于实现滑音音效,比如马里奥的跳跃音效。它实现滑音的原理是,按一定算法定时修改通道的定时器。定时器时间间隔改变将导致波形频率发生变化,从而产生滑音效果。该单元也由帧计数器驱动,但输入频率被降低了一半。它内部也包含分频器,可以进一步减小频率。

参考 nesdev 可知,它更新目标通道定时器的算法如下:

  1. 获取目标通道计时器的值,并将值右移 S 位。这里计时器的值是指 reload 值,而不是当下的计数值
  2. 将第 1 步得到结果与目标通道计时的值相加(或者相减,取决于 $4001/5.3
  3. 将第 2 步得到的结果设置为目标通道计时器的新值

用代码表述如下:

sweep.ontick([&timer](){
    unsigned amount = timer.period() >> _shift;
    unsigned newval = _negative ? timer.period() + amount : timer.period() - amount;
    timer.set(newval);
});

包络生成器

包络生成器用于控制音量,支持固定音量和淡出音量(线性递减),音量最大值 15,最小值 0。它的时钟源为帧计数器,但它内部也包含分频器(Divider),可以进一步减小频率。淡出音量的控制,代码表述如下:

class Envelope {
public:
    Envelope() {
        _timer.ontick([this](){
            if (_decay) --_decay;
            else (_loop) _decay = 15;
        });
    }

    void tick() {
        _timer.tick();
    }

    int output() const { return _decay; }
private:
    int _decay = 15;
    Timer _timer;
    bool _loop = false;
};

当生成线性递减音量时,如果 _looptrue,则生成的音量波形是一个锯齿波(如下图)。

包络线锯齿波

播放 APU 生成的声音

要播放 APU 产生的样本,至少有两种方案,我称为“直接法”和“间接法”。

直接法

直接法顾名思义就是直接播放 APU 产生的样本。代码表述如下:

APU apu;
apu.tick();
int pulse1 = apu.pulse[0].output();
int pulse2 = apu.pulse[1].output();
int triangle = apu.triangle.output();
int noise = apu.noise.output();
int sample = mix(pulse1, pulse2, triangle, noise);
write_sample(sample); // 将 sample 送往声卡

前面提到,APU 声音通道的采样率非常高(三角波为 1.789773 MHz,方波和噪声为 894.8865 KHz),所以这种方法需要对产生的样本做降采样,否则可能无法播放。

可见,使用直接法需要实现完整的 APU。完整的 APU 参考实现可以到这里获取。

间接法

间接法不做降采样,而是做 API 映射,不需要实现完整的 APU,播放的时候可以单独开一个线程播放声音。

怎么理解“API 映射”呢?从 NES 编程的角度,我们可以把 APU 提供的各种寄存器视为 API 接口,这些接口的功能无非就是控制波形的振幅、频率、时长。因此,我们可以实现另外一套产生和控制波形的 API,直接按硬件能播放的采样率产生样本,然后将 APU 寄存器的读写映射到这套新系统上。这套新 API 不使用计数器、序列产生器产生之类偏硬件的概念,而使用时长、频率等直观的概念。比如,方波通道可能有如下 API:

class PulseChannel {
public:
    void set_freq(double freq);
    void set_duty(double duty);
    void set_duration(double ms);
};

翻译 API 用到的换算公式如下(以下公式中,fCPU 表示 CPU 频率,1.789773 MHz;fFrameCounter 表示帧计数器频率,240 或者 192 Hz):

公式 说明
fPulse = fCPU/(16*(t+1)) t 表示计时器的值,共 11 比特,由 $4002/6$4003/7 两个寄存器共同设置。参考 nesdev
fTriangle = fCPU/(32*(t+1)) t 表示计时器的值,共 11 比特,由 $400A$400B 两个寄存器共同设置。参考 nesdev
fNoise = fCPU/(16*(p+1)) p 表示计时器的值。由 $400E 寄存器设置,写入寄存器的值只是 p 值的索引,真实值由查表得出。参考 nesdev
duration = 2*l/fFrameCounter l 表示长度计数器值,共 5 比特。四个通道都支持长度控制,分别由 $4003$4007$400B$400F 设置。写入寄存器的值只是 l 值的索引,真实值由查表得出。参考 nesdev
fEnvelope = (v+1)/fFrameCounter v 表示包络生成器分频器值,共 4 比特。方波和噪声支持包络生成器,分别由 $4000/4$400C 寄存器设置。参考 nesdev

生成波形

要播放声音就得产生波形,新 API 依然得产生波形。根据上面 APU 的知识,总共有 3 种波要产生:方波、三角波和锯齿波(包络生成器使用)。各波形生成函数如下:

波形 函数
方波 floor(x * f) - floor(x * f + d)
三角波 2 * abs(x * f - floor(x * f + 0.5))
锯齿波 1 + floor(x * f) - x * f

以上函数中,x 表示时间;f 表示频率;d 表示占空比,取值范围为 0 到 1。显然,当采样率固定时,x 可由公式 \(x_n = x_{n-1} + \Delta_x\) 计算得到,其中 \(\Delta_x\) 为采样率倒数。

在使用上述函数生成波形时需要注意,频率、占空比属性可能随时变化,这些属性变化时波形应该平滑切换,不能出现以下现象:

方波毛刺示例
三角波毛刺示例 1
三角波毛刺示例 2

当波形出现上述毛刺,播放的时候就会听到刺耳的噪声。例如,若三角波有毛刺,可以在游戏“荒野大镖客”(Gun Somke)的开场音乐中很明显地听到。因为游戏“荒野大镖客”使用了相当长段的三角波作背景音,且为实现颤音效果,频率有节奏的变高变低。

噪声波产生与 APU 本身的产生方式相差无几,只是把计数器控制变成了时间控制,也不需要实现 LFSR 生成随机数:

double x;
constexpr double dx = 1 / 44100;
const double period = 1 / freq;
int output = 0;
if (x > period) {
    x -= period;
    output = rand() % 2;
}
x += dx;

滑音

前面提到,滑音实际是不断修改频率实现的,要实现滑音,也有两种办法。一种是把滑音单元完整实现,修改频率时,调用 set_freq 即可。另外一种当然是把滑音实现在新 API 里面,下面我们探讨一下如何实现。

参考 nesdev 可知,滑音单元有个工作频率(即按怎样的时间周期去修改目标波形的频率)。它的工作频率由 $4005 设置,频率换算公式如下:

fSweep = (p + 1) * 2 / fFrameCounter

p 就是由 $4005 寄存器设置的值。

而频率更新的算法中,右移 S 位实际上是除以 2 的 S 次方,所以控制公式可以改写为:

\[t_n = \left(1 + \frac{1}{2^S}\right)t_{n-1} \]

可以看出它是一个等比数列,而等比数列是指数函数。所以,计时器的值 \(v\) 随时间 \(t\) 变化的公式为:

\[v = \left(\frac{2^S + 1}{2^S}\right)^t \cdot v_0 \]

其中,\(v_0\) 表示目标通道计时器初始值;\(t\) 表示时间。

由于频率为时间间隔的倒数,所以频率 \(f\) 随时间 \(t\) 的变化公式为:

\[f = \left(\frac{2^S}{2^S + 1}\right)^t \cdot f_0 \]

所以只需按固定频率,对上述公式求值,然后更改目标波形的频率即可实现滑音音效。上述公式中,令 \(r = \frac{1}{2^S}\),则有:

\[f = (1 + r)^{-t} \cdot f_0 \]

可见滑音的控制参数有:工作频率 f, 比率 r, 初始值 f0。API 如下:

class Sweep {
public:
    void set_base_val(double v);
    void set_freq(double f);
    void set_ratio(double r);
};

生成与播放声音样本

有了波形生成器,就可以在另外一个线程里面一直生成样本,然后播放样本:

int next_sample() {
    int pulse1 = pulse0.next();
    int pulse2 = pulse1.next();
    int triangle = triangle.next();
    int noise = noise.next();
    int sample = mix(pulse1, pulse2, triangle, noise);
    return sample;
}

void audio_thread() {
    while (1) {
        int sample = next_sample();
        write_sample(sample);
    }
}

API 映射

前面讲过,API 映射就是把寄存器写入变成对新 API 的调用。代码表述如下:

class APU {
    /* ... */
    void write_register(uint16_t addr, uint8_t data) {
        switch (addr) {
        case 0x4002:
            _pulse1.set_freq(/* ... */); // 应用上面的公式,根据计时器值 data 计算出频率
            break;
        /* ... */
        }
    }
private:
    PulseChannel _pulse1, _pulse2;
};

同步

如果使用另外一个线程播放声音样本,则存在 APU 与播放线程同步的问题,不过此同步问题不是传统的线程同步问题,而是时间同步问题。通常,模拟器会一次性执行数个时钟周期(一帧内的时钟周期数),然后休息一阵(这是因为模拟器执行时钟周期的效率要高于原本硬件本身的效率)。而播放线程,则是疯狂的产生样本并播放。这样,模拟器中的 APU 送出样本的时间相比真正的 APU 送出样本的时间就早了,而样本几乎送出就播放,就导致实际听到的声音的频率、时间和原本的对不上。同时,由于模拟器执行单个时钟周期的时间受各种因素影响会变化,也导致音乐的节奏也变得紊乱。

知道了原因,问题就好解决。虽然送出样本的时间变早了,但样本应该在什么时候播放却是知道的。硬件做任何事情都是靠时钟驱动的,所以样本的真实时间与时钟有关系。我们知道 CPU 的时钟频率是 1.789773 MHz,而 APU 的时钟频率是 CPU 的一半,约 894.8865 KHz,可以算出一个 APU 时钟周期是 1117.5 ns。如果 CPU 在 APU 时钟 c 的时候写入了 $4002 寄存器,更改了方波通道 1 的频率,那这个更改就应该在 t0 + c * 1117.5ns 时刻生效(其中,t0 表示 APU 时钟周期 0 的真实时间)。

现在,要么主线程(模拟 CPU、PPU、APU 的那个线程)在指定的时间调用 API;要么播放线程在指定的时间才执行 API 对应的动作。主线程因为要模拟各硬件还要做休眠稳定帧率,挺忙的,所以可以把定时执行的实现放到播放线程。APU 会不断的要求播放线程延迟执行 API,这些请求就会构成一个队列,播放线程是一个死循环处理这个队列,这实际构成了一个消息循环:

void audio_thread() {
    while (1) {
        Task task;
        while (!queue.peek(&task)) {
            int sample = gnext_sample();
            write_sample(sample);
        }
        task.exec();
    }
}

映射 API 时也需要相应调整:

class APU {
    /* ... */
    void write_register(uint16_t addr, uint8_t data) {
        switch (addr) {
        case 0x4002:
            queue.post_timed([](){
                _pulse1.set_freq(/* ... */); // 应用上面的公式,根据计时器值 data 计算出频率
            }, now());
            break;
        /* ... */
        }
    }
private:
    unsigned long long now() const {
        return _t0 + (unsigned long long)(_elpased_cycle * 1117.5);
    }

    PulseChannel _pulse1, _pulse2;
    unsigned long long _t0, _elapsed_cycle;
};

有关消息循环的实现,可以参考 消息循环 这篇文章。

总结

本文围绕 “声音的产生”、“NES APU 的组成”、“播放 APU 声音” 三个知识点,并通过与代码实现相结合的方式介绍了 NES APU 的知识。虽然代码只有一部分,但剩下的部分对照 nesdev 上的文档完成并不是困难的事情。

通道-组件矩阵

通道 长度计数器 包络生成器 滑音单元 线性计数器
方波通道#1
方波通道#2
三角波通道
噪声通道

组件关系

以下关系图中,帧计数器只输出 “四分帧” 信号,“二分帧” 信号通过分频器实现

方波通道

                           ┌─────────┐                                                              ┌───────┐        ┌───────────┐
                  ┌───────►│ Divider ├─────────────────────────────────────────────────────────────►│ Timer ├───────►│ Sequencer │
  ┌──────────┐    │        └─────────┘                                                              └───────┘        └─────┬─────┘
  │  Clock   ├────┤                                                                                     ▲                  │
  └──────────┘    │                                                        ┌────────┐                   │                  │
                  │                                                   ┌───►│ Sweep  ├───────────────────┤                  │
                  │                                    ┌─────────┐    │    └────────┘                   │                  │
                  │                             ┌─────►│ Divider ├────┤                                 │                  │
                  │                             │      └─────────┘    │    ┌────────────────┐           │                  │
                  │        ┌───────────────┐    │                     └───►│ Length Counter │           │                  │
                  └───────►│ Frame Counter ├────┤                          └────────┬───────┘           │                  │
                           └───────────────┘    │                                   │                   │                  │
                                                │                                   │                   │                  │
                                                │                                   │                   │                  │
                                                │      ┌──────────┐                 ▼                   ▼                  ▼           ┌───────┐
                                                └─────►│ Envelope ├──────────────►(Gate)─────────────►(Gate)────────────►(Gate)───────►│ Mixer │
                                                       └──────────┘                                                                    └───────┘

三角波通道

                                                     ┌─────────┐         ┌────────────────┐
                                              ┌─────►│ Divider ├────────►│ Length Counter │
                         ┌───────────────┐    │      └─────────┘         └────────┬───────┘
                   ┌────►│ Frame Counter ├────┤                                   │
                   │     └───────────────┘    │      ┌────────────────┐           │
                   │                          └─────►│ Linear Counter │           │
  ┌──────────┐     │                                 └───────┬────────┘           │
  │  Clock   ├─────┤                                         │                    │
  └──────────┘     │                                         │                    │
                   │                                         │                    │
                   │     ┌───────┐                           ▼                    ▼            ┌───────────┐      ┌───────┐
                   └────►│ Timer ├────────────────────────►(Gate)──────────────►(Gate)────────►│ Sequencer ├─────►│ Mixer │
                         └───────┘                                                             └───────────┘      └───────┘

噪声通道

                           ┌─────────┐               ┌───────┐                              ┌──────┐
                  ┌───────►│ Divider ├──────────────►│ Timer ├─────────────────────────────►│ LFSR │
                  │        └─────────┘               └───────┘                              └──┬───┘
  ┌──────────┐    │                                                                            │
  │  Clock   ├────┤                                  ┌─────────┐       ┌────────────────┐      │
  └──────────┘    │                             ┌───►│ Divider ├──────►│ Length Counter │      │
                  │                             │    └─────────┘       └───────┬────────┘      │
                  │        ┌───────────────┐    │                              │               │
                  └───────►│ Frame Counter ├────┤                              │               │
                           └───────────────┘    │                              │               │
                                                │    ┌──────────┐              ▼               ▼           ┌───────┐
                                                └───►│ Envelope ├───────────►(Gate)─────────►(Gate)───────►│ Mixer │
                                                     └──────────┘                                          └───────┘

参考

  1. 数字化音频
  2. APU Frame Counter
  3. APU Evelope
  4. APU Length Counter
  5. APU Seep
  6. APU Pulse
  7. APU Triangle
  8. APU Noise
  9. NES APU Replay
posted @ 2023-03-21 10:42  1bite  阅读(251)  评论(0编辑  收藏  举报