Rust 中的 YM2149,第一部分:构建周期精确模拟器
Source: Dev.to
概览
有一种特定的声音定义了一代计算机。如果你在 Atari ST、ZX Spectrum、Amstrad CPC 或 MSX 上长大,你会立刻认出它:明亮、嘶嘶的方波叠加成的旋律,听起来比它本应拥有的更有活力。那种声音来自一颗单芯片——Yamaha YM2149,或它几乎相同的兄弟芯片 General Instrument AY‑3‑8910。
过去一年,我一直在构建 ym2149‑rs.org,一个完整的 Rust 生态系统,用于模拟这颗芯片并回放为其创作的音乐。最初只是一个周末实验,结果却变成了更大的项目:一个周期精确的模拟器、七种歌曲格式的播放器、一个 Bevy 游戏引擎插件、一个终端播放器以及一个浏览器演示。
这是一篇 四部分系列的第 1 部分,介绍该项目。我们将从芯片本身以及如何正确模拟它开始。
定义时代的芯片
YM2149 在纸面上看起来极其简单:
- 三个方波音调发生器
- 一个噪声发生器
- 一个拥有 10 种形状的硬件包络单元
- 一个将它们混合的混音器
仅此而已。
但简洁孕育了创造力。20 世纪 80‑90 年代的 Demo 场景音乐人发现,通过巧妙地操控这些受限资源,他们可以产生芯片本身从未设计过的声音。例如:
- 快速的包络操作产生了额外的波形(“SID voice” 技术,得名于 Commodore 64 那颗更强大的芯片)。
- 将包络重新启动与音调周期同步,产生了独特的 “Sync Buzzer” 效果。
- 通过滥用音量寄存器实现的采样播放让我们拥有了 “digi‑drums”。
这些并不是 bug——而是那些对硬件了如指掌的程序员发现的特性。
什么叫“周期精确”模拟?
大多数音频模拟器都会走捷径:读取寄存器值并近似输出。这对随意聆听来说足够,但无法再现使 YM2149 音乐独具特色的时序依赖效果。
真实的芯片运行在主时钟除以 8 的频率上——在大多数系统上约为 250 kHz。每个时钟周期会发生:
- 音调计数器递减,并在计数到零时翻转输出。
- 噪声 LFSR 移位,产生伪随机位。
- 包络生成器在其 128 条目形状表中前进一步。
- 所有输出送入对数 DAC。
要复现 SID voice 或 Sync Buzzer 等效果,必须模拟每一个这样的时钟周期:
pub fn clock(&mut self) {
self.subclock_counter += 1;
if self.subclock_counter >= self.subclock_divisor {
self.subclock_counter = 0;
self.tick(); // Full internal state update
}
}
fn tick(&mut self) {
// Tone generators
for ch in 0..3 {
self.tone_counters[ch] -= 1;
if self.tone_counters[ch] == 0 {
self.tone_counters[ch] = self.tone_periods[ch];
self.tone_outputs[ch] ^= 1;
}
}
// Noise generator (17‑bit LFSR)
self.noise_counter -= 1;
if self.noise_counter == 0 {
self.noise_counter = self.noise_period;
let bit = (self.noise_lfsr ^ (self.noise_lfsr >> 3)) & 1;
self.noise_lfsr = (self.noise_lfsr >> 1) | (bit , // 512 KB‑4 MB depending on model
psg: Ym2149, // Our sound chip
mfp: Mfp68901, // Timer chip (for interrupts)
ste_dac: Option, // STE‑only DMA audio
}
impl AtariMachine {
pub fn run_frame(&mut self) -> Vec {
// Execute 68000 code until next VBL
while !self.vbl_reached() {
self.cpu.execute_instruction(&mut self.bus);
self.mfp.tick();
// Collect PSG samples at audio rate
if self.sample_clock_elapsed() {
samples.push(self.psg.get_sample());
}
}
samples
}
}
MFP 68901 计时器芯片至关重要——许多 SNDH 文件使用计时器中断来触发包络重新启动,以实现 SID‑voice 效果。如果计时器模拟不够精确,这些曲目会听起来不对。
AY 文件(ZXAY/EMUL)
类似于 SNDH,但面向 ZX Spectrum:它们包含 Z80 机器码,需要 Z80 CPU 模拟器、不同的内存映射以及独立的 I/O 端口。
GIST 文件(.snd)
一种来自 Atari ST 时代的鲜为人知的格式。GIST(Graphics, Images, Sound, Text)是一个音效编辑器,允许开发者创建短小、冲击力十足的音频——激光射击、爆炸、菜单提示音、道具拾取声。与音乐格式不同,GIST 文件使用乐器定义和包络序列来描述单个音效。它们体积极小(往往只有几字节),却出奇地富有表现力。播放器支持多声部回放,因此使用多个通道的音效能够正确播放。
要支持这四大家族格式(再加上 YMT tracker 变体和 GIST 音效),需要一种分层架构:YM2149 模拟器位于底层,格式特定的播放器位于中层,应用程序位于顶层。
架构:不泄漏的层次
整个生态系统被组织成严格的层次,每一层只承担单一职责:
ym2149-common– 共享特性和类型。ym2149-core– 周期精确的芯片模拟。- 播放器 – YM、SNDH、AY、Arkos、GIST,基于核心实现。
- 应用程序 – CLI 播放器、Bevy 插件、WASM 演示,使用这些播放器。
