本文由Motto撰稿于2025年5月31日。Small-U代为投稿
音源效果
如下是音源通过AG03声卡内录的效果(点击下载mp3文件)
FM音乐背景
曾经有人找到笔者想要利用YM2151芯片制作类似Yamaha DX7一样的电子琴,那时我还对调频(FM)电子音乐没有什么概念。但是其实这种风格的音乐已经陪伴了80后90后的前20年时光,一些经典音乐比如《云宫讯音》、《爱如潮水》都以DX7的音色为特征。
有关FM音乐和DX7的描述,可以参考如下视频:
FM音乐的原理
FM音乐作为上世纪80年代介于PWM蜂鸣器和全采样音频之间的技术,它是如何产生声音的呢?
众所周知,自然界最本征的声音,便是正弦波单频信号。于是,FM频率调制的基础信号,便是来自正弦波表(当然也可以用其他的波表,但是这样子谐波就不太好控制了)。除了频率之外,我们对声音的感受还包括了包络信息,那么我们可以生成这样的一个基础信号:
y(t) = E_y(t) \cdot \sin(2\pi f_y t)
其中,E_y(t)
是y(t)
的包络信号,可以由Attack、Decay、Sustain和Release四个阶段描述;f_y
是y(t)
的频率。我们将这个信号再次进行一次调制,就可以得到一种FM音频信号:
z(t) = E_z(t) \cdot \sin[2\pi f_z t + y(t)]
我们可以把f_z
叫做载波频率,把f_y
叫做调制频率。载波频率决定了声音的高低,而调制频率和载波、调制的包络决定了音色。可以看到,生成上述的一种FM信号需要查询2次正弦表,乘2次包络信号;于是正弦波表、包络发生器、乘法器,以及时分复用的电路就构成了FM合成器的核心组件。而在FM合成术语中,我们把一次正弦查表乘以一次包络叫做一个算子;显然上述FM合成的例子包括了两个算子,由调相级联起来。
YM2151
上述的音乐合成概念,最早由John Chowning于20世纪60年代提出,那时候计算机性能受限,无法实时生成音色丰富的音乐。于是这个概念在AC’97规范提出之前很受欢迎,一些经典的街机、电子琴和电脑都采用了FM合成技术,而雅马哈则在80、90年代垄断了FM合成器的市场。
YM2151作为雅马哈FM合成器系列的一种,代号为OPM,于1984年推出。它包含了4个算子,并支持8种算子级联方式(又叫做算法),如下图所示。它除了FM算子之外,还包含了一个低频振荡器。它的四个算子(事实上为一个正弦表复用4次)被时分复用为8个声道,而8号声道的第4个算子又可以被配置为噪声源。
YM2151的数据手册可以参考这里。我将常用的寄存器配置整理成了如下的图片。
YM2151音符发生器:
YM2151算子:
YM2151包络发生器:
YM2151低频振荡器:
音源制作过程
之前已经有很多人使用YM2151制作音源了,这个项目很多都是参考Gasshi的SPFM Light硬件和SCCI 1.0上位机。
硬件
SPFM Light的硬件包括两大部分:主板和音源扩展板。本项目将两部分合并,使用RP2040作为YM2151的主控,通过解析SCCI 1.0协议,将上位机的寄存器dump的VGM音乐发送到YM2151。
PCB原理图
原理图如下所示,包括主控引脚的引出、YM2151和DAC的连接。我直接采用RP2040的4MHz PWM输出作为YM2151的时钟,节省了一个晶振。
RP2040 Pico板子的引脚引出:
YM2151和DAC的连接:
USB供电滤波器:
PCB电路图
原理图做好之后,我先是用洞洞板验证了一下(程序附在文章末尾),然后再用KiCad绘制PCB,引脚顺序经过调整来方便布局。主要走线在板子底面,正面只有几个电源跳线,这样子方便做洞洞板。

制作过程中DAC封装画错了,掰引脚修改的途中断了两个脚,只好飞线解决。
外壳
外壳使用FreeCad制作,通过对PCB的Step文件创建Datum对象来获得需要参考的板子特征。这样子就可以处理接口的预留空间、板子的对齐孔和安装卡扣。
成品
外壳使用3D打印制作;亚克力顶盖最后加上了激光雕刻的文字,并用丙烯颜料涂色。最终使用AB胶对音频座和飞线部位加固。
上位机
到此硬件的部分描述完毕,软件也有很多前辈做了不少工作,比如本项目使用到的MDPlayer和SCCI。
MDPlayer和SCCI
MDPlayer是一款VGM播放器,可以读取VGM音乐,并且内置了大量的软音源用来播放。SCCI是SPFM Light硬件的上位机,通过DLL挂载到MDPlayer。这样子硬音源就可以播放YM2151的VGM音乐了。
SCCI 1.0 协议
关于SCCI协议的描述,可以参考这个文档,它的大致原理如下:
- 初始化
- 串口波特率1500000,8位,1停止位,无校验,无流控制。
- 上位机发送0xFF,SPFM Light收到0xFF之后,返回’L”T’2个字节,告诉上位机连接的硬件是SPFM Light。
- 上位机发送0xFE,SPFM Light收到0xFE之后,对YM2151复位,然后返回’O”K’2个字节。
- VGM数据发送
- 上位机发送如下几种可能的命令
- 0x0[slot] – 0x0[a] – 0x[Addr] – 0x[Data];这表示向第[slot]插槽的[a]脚选择的芯片的[Addr]寄存器发送[Data]数据。
- 0x0[slot] – 0x8[a] – 0x[BusData];这表示向第[slot]插槽的[a]脚选择的芯片的总线发送[BusData]数据。
- 0x0[slot] – 0x20 – 0x[BusData];这表示向第[slot]插槽的SN76489芯片发送[BusData]数据。
- 上位机发送如下几种可能的命令
上位机的运行
- 安装MDPlayer的STL449版本,这是MDPlayer最后一个支持SCCI 1.0的版本。
- 将SCCI压缩包的dll和配置程序解压到MDPLayer的根目录下。
- 连接硬音源,打开scciconfig.exe,此时应该能够检测到SPFM Light硬件,并将slot 0设置成YM2151,时钟为4MHz,然后关闭配置程序。
- 打开MDPlayer,在设置的Sound页面中的YM2151框架中选择Real并勾选4MHz时钟。
- 在VGMRips中选择YM2151对应的VGM音乐,下载下来使用MDPLayer播放。
SPFM固件
基于上述SCCI 1.0协议,我们可以写出如下RP2040 Arduino程序。
/*
Copyright (C) 2025 Motto (https://x.com/YangMotto)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
//Please Modify CFG_TUD_CDC_RX_BUFSIZE in "tusb_config.h"
#include "Arduino.h"
#include "RP2040_PWM.h"
#include "hardware/clocks.h"
// Define the pins for the data bus and control signals
const int d0 = 26;
const int d1 = 27;
const int d2 = 17;
const int d3 = 16;
const int d4 = 15;
const int d5 = 14;
const int d6 = 13;
const int d7 = 12;
const int rs0 = 19;
const int n_cs = 22;
const int n_rd = 21;
const int n_wr = 20;
const int n_rst = 18;
const int n_rst2 = 11;
// PWM for clock signal
const int clkPin = 28;
const int clkFreq = 4000000;
RP2040_PWM* ym2151_clk_pwm;
// Uart Parsing
int uart_idle_cnt = 10000;
int uart_parse_idx = 0;
const int uart_tx = 0;
const int uart_rx = 1;
// Led for Feedback
const int led_pin = 25;
void init_reg() {
digitalWrite(rs0, HIGH);
digitalWrite(n_cs, HIGH);
digitalWrite(n_rd, HIGH);
digitalWrite(n_wr, HIGH);
delay(1);
digitalWrite(n_rst, LOW);
digitalWrite(n_rst2, LOW);
delay(1);
digitalWrite(n_rst, HIGH);
digitalWrite(n_rst2, HIGH);
}
// Set the data bus pins
void set_data_bus(int i) {
digitalWrite(d0, (i >> 0) & 0x01);
digitalWrite(d1, (i >> 1) & 0x01);
digitalWrite(d2, (i >> 2) & 0x01);
digitalWrite(d3, (i >> 3) & 0x01);
digitalWrite(d4, (i >> 4) & 0x01);
digitalWrite(d5, (i >> 5) & 0x01);
digitalWrite(d6, (i >> 6) & 0x01);
digitalWrite(d7, (i >> 7) & 0x01);
}
void spfm_bus_select_slot(int slot) {
if (slot == 0) {
digitalWrite(n_cs, LOW);
} else {
digitalWrite(n_cs, HIGH);
}
delayMicroseconds(1);
}
void spfm_bus_release_slots() {
digitalWrite(n_cs, HIGH);
}
void spfm_bus_write(int slot, int reg_sel, int data) {
digitalWrite(rs0, reg_sel & 0x01);
//delayMicroseconds(1);
spfm_bus_select_slot(slot);
digitalWrite(n_wr, LOW);
delayMicroseconds(1);
set_data_bus(data);
delayMicroseconds(1);
digitalWrite(n_wr, HIGH);
spfm_bus_release_slots();
}
void loop();
void loop1();
void setup() {
// Set pin modes
pinMode(d0, OUTPUT);
pinMode(d1, OUTPUT);
pinMode(d2, OUTPUT);
pinMode(d3, OUTPUT);
pinMode(d4, OUTPUT);
pinMode(d5, OUTPUT);
pinMode(d6, OUTPUT);
pinMode(d7, OUTPUT);
pinMode(rs0, OUTPUT);
pinMode(n_cs, OUTPUT);
pinMode(n_rd, OUTPUT);
pinMode(n_wr, OUTPUT);
pinMode(n_rst, OUTPUT);
pinMode(n_rst2, OUTPUT);
pinMode(led_pin, OUTPUT);
// Initialize PWM for clock
ym2151_clk_pwm = new RP2040_PWM(clkPin, clkFreq, 50, false);
ym2151_clk_pwm->setPWM(clkPin, clkFreq, 50, false);
// UART 0 for Input - Deprecated
Serial1.setFIFOSize(512);
Serial1.setRX(uart_rx);
Serial1.setTX(uart_tx);
Serial1.begin(1500000);
// UART over USB for IO
Serial.ignoreFlowControl(true); // Very Important to for SCCI to Receive
Serial.begin(1500000);
delay(10);
init_reg();
digitalWrite(led_pin, HIGH);
// Multi-Core Task Assignment
multicore_launch_core1(loop1);
}
#include "CoreMutex.h"
extern mutex_t __usb_mutex;
#include "tusb.h"
void loop() {
// Most of the TinyUSB ISR is handled by core 0
CoreMutex m(&__usb_mutex, false);
tud_task();
}
// Variables to parse SPFM Data
uint8_t uart_data = 0;
uint8_t scci_parse_idx = 0; // 0-frame part 0, 1-part 1, 2-part 2
uint8_t scci_slot = 0;
uint8_t scci_cmd = 0;
uint8_t scci_a = 0;
uint8_t scci_addr = 0;
uint8_t scci_data = 0;
void loop1() {
// Handlers for the SPFM protocols
uart_idle_cnt--;
if (uart_idle_cnt == 0) {
uart_parse_idx = 0;
uart_idle_cnt = 10000;
}
if (Serial.available() > 0) {
uart_idle_cnt = 10000;
uart_data = Serial.read();
digitalWrite(led_pin, !digitalRead(led_pin));
if (scci_parse_idx == 0) {
if (uart_data == 0xFF) {
// Device Response Code
// LT - SPFM Light
Serial.write("LT", 2);
} else if (uart_data == 0xFE) {
// Reset
init_reg();
Serial.write("OK", 2);
} else if ((uart_data & 0xF0) == 0x00) {
// 0x0[slot] - 0x0[An] - Addr - Data
// 0x0[slot] - 0x8[An] - BusData
// 0x0[slot] - 0x20 - SN76489_Data
scci_slot = uart_data & 0x0F;
scci_parse_idx = 1;
} else {
}
}
else if (scci_parse_idx == 1) {
scci_cmd = uart_data & 0xF0;
if (scci_cmd == 0x00) {
// 0x0[An] - Addr - Data
scci_a = uart_data & 0x0F;
scci_parse_idx = 2;
} else if (scci_cmd == 0x80) {
// 0x8[An] - BusData
scci_a = uart_data & 0x0F;
scci_parse_idx = 2;
} else if (scci_cmd == 0x20) {
// 0x20 - SN76489_Data
scci_parse_idx = 2;
} else {
scci_parse_idx = 0;
}
}
else if (scci_parse_idx == 2) {
if (scci_cmd == 0x00) {
// 0x0[An] - Addr - Data
scci_addr = uart_data;
spfm_bus_write(scci_slot, scci_a & 0xFE, scci_addr);
scci_parse_idx = 3;
} else if (scci_cmd == 0x80) {
// 0x8[An] - BusData
scci_data = uart_data;
spfm_bus_write(scci_slot, scci_a, scci_data);
scci_parse_idx = 0;
} else if (scci_cmd == 0x20) {
// 0x20 - SN76489_Data
scci_data = uart_data;
// We don't have SN76489, so we ignore this.
//spfm_bus_write(scci_slot, 0x00, scci_data);
scci_parse_idx = 0;
} else {
scci_parse_idx = 0;
}
}
else if (scci_parse_idx == 3) {
if (scci_cmd == 0x00) {
// 0x0[An] - Addr - Data
scci_data = uart_data;
spfm_bus_write(scci_slot, scci_a | 0x01, scci_data);
scci_parse_idx = 0;
} else {
scci_parse_idx = 0;
}
}
else {
scci_parse_idx = 0;
}
}
}
可能的后续工作
- 尝试雅马哈的其他FM芯片。
- 板子上附加2片SN76489,丰富一下节奏和PWM伴奏。