引言:声音是如何被塞进芯片的?
在现代的音频开发或者数字信号处理(DSP)中,我们已经习惯了高码率的音频采样、傅里叶变换以及各种复杂的数字混音算法。但如果把时间拨回几十年前,在计算机算力和存储空间极度匮乏的年代,人类是如何把声音塞进微小的芯片里的?
回顾历史,电子音乐在早期曾经历过漫长的“噪音妖魔化”时期。20 世纪前半叶,古典学院派与大众普遍认为电子设备发出的纯粹正弦波或随机白噪声是“对艺术的亵渎”。直到 60-70 年代,诸如温蒂·卡洛斯的《Switched-On Bach》(用穆格合成器重奏巴赫)、德国发电厂乐队(Kraftwerk)的《Autobahn》(确立流行电音骨架)以及让-米歇尔·雅尔的《Oxygène》(用合成器白噪声模拟风声海浪)等一系列概念专辑的诞生,才彻底扭转了人类的审美,向世人证明了电信号同样能编织出极其宏大、浪漫的旋律。
这股电子浪潮在 80 年代初深深影响了第一代游戏音乐人。1983 年,任天堂推出了 FC 游戏机(红白机)。受限于极端的硬件成本,早期游戏卡带的容量通常只有几十 KB,根本无法容纳任何录制好的音频文件。
为了解决声音输出问题,设计师在中央处理器内部集成了一个音频处理单元(APU)。它不依赖音频流播放,而是通过代码直接控制纯电信号的波形、频率和音量,在游戏运行过程中实时合成声音。这就是现代数字音频技术的民用化起点。
一、红白机 APU 的硬件机制与通道设计
红白机的 APU(Audio Processing Unit)芯片内置了 5 个独立的音频通道: 脉冲方波(2路):主要用于表现主旋律与各类清脆的交互音效,支持 4 种占空比切换来改变音色。 三角波(1路):波形比方波更为浑厚,没有音量控制单元,在游戏中通常被开发者用作低音贝斯(Bass)或节奏底座。 白噪声(1路):通过伪随机数发生器产生不规则的频率杂音,常用于模拟爆炸、受击等特效音。 DPCM(1路):差分脉冲编码调制通道,用于播放极低保真的真人语音或特殊鼓点。 为了理清这些硬件单元在芯片内部的物理分工与声音生成流程,我们可以参考下面这张严谨的底层架构图:
如上图所示,运行在 CPU 核心里的游戏代码并不包含真实的音频流,而是以 60Hz 左右的频率,向内存映射地址 4017 的寄存器写入一字节一字节的控制命令。APU 内部各通道的硬件电路收到命令后,调制出对应的电信号,并在芯片末端通过 DAC1 和 DAC2 分组进行非线性混音。最终信号流经卡带插槽(允许第三方卡带的扩展音频在此处物理混入),输出给扬声器发声。
在音频处理中,波形决定音色。我们将从物理特性、数学本质以及它们在游戏中的实际用途三个维度来彻底梳理
1. 脉冲波 / 方波(Pulse / Square Wave) 方波是红白机最核心的旋律通道,声音听起来清脆、富有科技感。 物理与数学本质:方波是一种非正弦波。它在极短的时间内,电压在“最高点(高电平)”和“最低点(低电平)”之间进行瞬间跳变。 占空比(Duty Cycle):这是决定方波音色的关键。它指的是在一个周期内,高电平持续时间占整个周期时间的百分比。FC 硬件支持 4 种占空比,我们在 Qt 代码中就是通过改变 m_dutyCycle 来切换音色的: 12.5%:高电平极窄,声音听起来非常刺耳、单薄、带有强烈的金属感(常用于特殊受击音效或尖锐的配乐)。 25%:经典的红白机音色,清脆且明亮(《超级马里奥》主旋律的大部分音符都在使用它)。 50%:完美的对称方波,高低电平时间一样长。声音最浑厚、饱满,听起来有点像单簧管或早期电子琴。 75%:听觉效果与 25% 完全相同(因为人耳对相位的正负不敏感,只对宽窄敏感)。
2. 三角波(Triangle Wave) 三角波是红白机的低音支柱,声音听起来平滑、低沉。 物理与数学本质:电压不是瞬间跳变,而是像爬坡一样,匀速线性上升到最高点,再匀速线性下降到最低点。 FC 的硬件局限:在真实的红白机硬件中,APU 是用一个 5 位的数字计数器来模拟三角波的。它无法做到绝对平滑,而是由 32 个阶梯状的电压点拼接出来的(这也是为什么你在模拟器里把低音无限放大时,会听到一丝丝高频的沙沙声)。此外,FC 的三角波通道在硬件上没有音量控制(要么全响,要么静音),开发者只能通过频繁切断它来模拟音量的强弱变化。 游戏用途:由于方波太刺耳,不适合做低音,因此三角波在 FC 中几乎 90% 的时间都被用作 Bass(贝斯)。当它弹奏极短的音符时,配合噪声通道,就能完美模拟出架子鼓里大鼓(Kick Drum)的咚咚声。 DSP 前瞻(频谱本质):三角波同样只包含奇数倍泛音,但它的泛音能量衰减得极快(谐波振幅与频率的平方成反比)。因此它非常接近纯正弦波,听起来温和、没有攻击性。
3. 白噪声(Noise) 噪声通道没有固定的音高,听起来就是收音机没有信号时的“沙沙”声。 物理与数学本质:理论上的白噪声在所有频率上的能量是均匀分布的。但在红白机中,它是通过一个伪随机数发生器(PRNG)和移位寄存器来实现的。它通过极快的速度随机切换高低电平,从而产生大面积的无序频率。 FC 的两种噪声模式: 长周期噪声(长模式):随机数序列很长,听起来是非常均匀、宽广的“沙沙”声,常用于模拟爆炸、受击特效,或者架子鼓的沙锤(Hi-Hat)和军鼓(Snare)。 短周期噪声(短模式):随机数序列被强行截断并无限循环。此时杂音会产生一种奇特的固定周期,听起来不再是沙沙声,而是一种带金属机械感的怪异蜂鸣声(常用于模拟外星人飞船或激光枪音效)。 DSP 前瞻(频谱本质):它包含了全频段的随机频率。在现代 DSP 中,我们想要改变噪声的肤色(比如做成风声、雨声),就需要用到滤波器(Filter)——让噪声通过低通滤波器,滤掉高频,就会变成宏大的风声(正如 Jean-Michel Jarre 在专辑《Oxygène》中所做的那样) 📊 总结:波形特性对比表
理清了这三种波形,读者就能明白为什么我们在 Qt 的 readData 里用 if (currentPhase < m_dutyCycle) 就能完美模拟方波了。
二、硬核复刻:用 Qt C++ 模拟方波声道
既然知道了红白机实时计算波形的硬件原理,我们完全可以用现代代码在电脑里复刻这一行为。
理论与架构最终需要落到代码和可见的交互上。为了验证前文提到的 APU 寄存器映射机制,我用 Qt 5.4 搭建了一个极简的测试原型(如上图所示)。
这个界面虽然简单,但它完整模拟了红白机运行音频数据时的核心要素:
1. 组件构成与设计意图
Hex 文本输入框 (QTextEdit):这是我们与虚拟 APU 交互的“总线入口”。它允许我们以十六进制字符串(Hex)的形式直接写入一串自制的二进制音频流数据。
控制矩阵 (QPushButton):
PLAY / STOP 键:采用公用状态机设计。点击 PLAY 时,程序会激活异步音频流,开始将 Hex 数据按节拍转换为波形,同时按钮文本切换为 STOP;再次点击则强行释放硬件资源并关闭时钟。
PAUSE 终止键(图右侧按钮,在代码逻辑中已重映射为挂起控制):负责在不破坏当前波形相位和播放进度的前提下,临时切断硬件的音频流输出。
2. 文本框中的 Hex 数据流深度解构
我们可以直接拿截图中框内的这串十六进制数据来做一次现场拆解。这串数据在底层其实是一段马里奥跳跃音效的简易特征编码。
依照我们定制的 4 字节通信协议(【延迟帧】【寄存器类型】【高位(暂留)】【低位数据】),这段文本在被 QByteArray::fromHex 解析后,在时钟的滴答声中是这样驱动音频引擎的:
01 00 00 8A —— 第 1 帧:向 00 号(控制/占空比)寄存器写入 8A。位运算拆解后,最高两位为 10,代表将方波切换为 50% 占空比 的浑厚音色。
01 02 00 70 —— 第 2 帧:向 02 号(频率低8位)寄存器写入 70。
01 03 00 02 —— 第 3 帧:向 03 号(频率高3位)寄存器写入 02。
此时 11 位的 Timer 拼装完成,结合 FC 官方公式计算,方波开始以大约 189 Hz 的低音高频率发声。
02 02 00 50 —— 等待 2 帧:向 02 号寄存器写入 50。Timer 变小,公式分母变小,实际输出音高突变成大约 245 Hz(音高开始急剧上滑)。
02 02 00 30 —— 再等 2 帧:向 02 号寄存器写入 30。音高继续上滑至大约 355 Hz。
02 02 00 10 —— 最后 2 帧:向 02 号寄存器写入 10。音高滑到了大约 660 Hz 的高音区,随后数据流结束,声音干净地戛然而止。
通过这种设计,我们避开了复杂的汇编内核模拟,用最直观的字面量,在现代操作系统中体验到了几十年前游戏音乐人控制硬件寄存器时的真实体感。
音频发生器类 (FcApuDevice.h)
#pragma once
#include <QIODevice>
#include <QAudioFormat>
#include <cmath>
class FcApuDevice : public QIODevice {
Q_OBJECT
public:
FcApuDevice(QObject *parent = nullptr);
// 设置 FC 的音频参数
void setFrequency(double hz); // 设置音高频率
void setDutyCycle(double duty); // 设置占空比 (0.125, 0.25, 0.50)
void setVolume(double vol); // 设置音量 (0.0 到 1.0)
// 必须重写的 QIODevice 虚函数
bool open(OpenMode mode) override;
qint64 readData(char *data, qint64 maxlen) override;
qint64 writeData(const char *data, qint64 len) override { return 0; } // 纯输出,不需要写入
void writeFcApu(uint8_t regType, uint8_t value);
private:
double m_sampleRate; // 采样率(如 44100)
double m_frequency; // 目标频率
double m_dutyCycle; // 占空比
double m_volume; // 音量
double m_phase; // 当前波形相位追踪器
void updateFcApuFrequency();
uint8_t m_reg4000 = 0x00;
uint8_t m_reg4002 = 0x00;
uint8_t m_reg4003 = 0x00;
};
(上述代码为基于QT 5.4 环境的,由于篇幅有限暂时先不把cpp实现代码贴出来,需要的朋友可以在评论区发“我要代码",我就把完整代码的git仓库地址贴在评论区)
当你在程序的文本框中输入经典的马里奥跳跃 Hex 数据流:01 00 00 8A 01 02 00 70 01 03 00 02 并点击播放时,那声跨越时空的清脆电音,就是电信号在底层演变出的最初旋律。
三、结语与后续预告
通过对红白机 APU 寄存器的模拟和这几十行 Qt C++ 代码,我们实际上用数字逻辑还原了最早期的民用硬件合成器行为。当我们在文本框里敲下那串十六进制编码时,电信号便不再是冰冷的噪声,而是顺着固定的物理波形,变成了跨越时空的游戏旋律。
然而,红白机的方波和三角波只是“解构声音编码”这段历史的起点。固定的波形虽然经典,但它也限制了音色的丰富度。
在下一期节目中,我们将把目光投向任天堂的另一台传奇掌机——GameBoy (GB)。
虽然 GameBoy 同样属于 8-bit 时代,但它的音频架构中引入了一个极其迷人的特殊通道:CH3 自定义小波形通道(WAVE Channel)。与红白机只能死板地选择几种占空比不同,GameBoy 允许开发者在内存的一块特定区域中,直接写入 32 个 4 位(4-bit)的数值。这 32 个点就像画布上的像素,通过它们,你可以自由地“手绘”出任何你想要的奇特波形轮廓。
在下一期《解构声音的编码(2)》中,我们将深入 GameBoy 的声音模块,探讨这块独特的“波形内存(Wave RAM)”是如何工作的,并继续扩展我们的 Qt 播放器,用代码在现代屏幕上“画”出属于掌机时代的独特音色。
感谢你的阅读,我们下期见。