dustpg / BlogFM

Blog for Me
MIT License
155 stars 23 forks source link

Re: 从零开始的红白机模拟 - [31] VRC7 吟唱 #44

Open dustpg opened 5 years ago

dustpg commented 5 years ago

拉格朗日点

之所以将游戏名称作为小标题, 自然是说到可乐妹的VRC7, 就不得不说'拉格朗日点'了, 因为这是一款唯一使用了VRC7的游戏. 被不少人冠上'FC最强音乐'的帽子.

当然实际上还有一款'兔宝宝历险记2(日版)'也使用了VRC7, 不过'兔宝宝历险记2'没有使用到VRC7的扩展音源(实体卡带比前者小了不少, 可以看作前者使用了VRC7a, 后者使用了VRC7b).

BANK

CPU $8000-$9FFF: 8 KB switchable PRG ROM bank
CPU $A000-$BFFF: 8 KB switchable PRG ROM bank
CPU $C000-$DFFF: 8 KB switchable PRG ROM bank
CPU $E000-$FFFF: 8 KB PRG ROM bank, fixed to the last bank
CHR $0000-$03FF: 1 KB switchable CHR ROM bank
CHR $0400-$07FF: 1 KB switchable CHR ROM bank
CHR $0800-$0BFF: 1 KB switchable CHR ROM bank
CHR $0C00-$0FFF: 1 KB switchable CHR ROM bank
CHR $1000-$13FF: 1 KB switchable CHR ROM bank
CHR $1400-$17FF: 1 KB switchable CHR ROM bank
CHR $1800-$1BFF: 1 KB switchable CHR ROM bank
CHR $1C00-$1FFF: 1 KB switchable CHR ROM bank

感觉都还科学. 变种区别为

PRG Select 0 ($8000)

7  bit  0
---------
..PP PPPP
  || ||||
  ++-++++- Select 8 KB PRG ROM at $8000

64*8=512

PRG Select 1 ($8010, $8008)

7  bit  0
---------
..PP PPPP
  || ||||
  ++-++++- Select 8 KB PRG ROM at $A000

PRG Select 2 ($9000)

7  bit  0
---------
..PP PPPP
  || ||||
  ++-++++- Select 8 KB PRG ROM at $C000

CHR Select 0…7 ($A000…$DFFF)

Write to CPU address 1KB CHR bank affected
$A000 $0000-$03FF
$A008 or $A010 $0400-$07FF
$B000 $0800-$0BFF
$B008 or $B010 $0C00-$0FFF
$C000 $1000-$13FF
$C008 or $C010 $1400-$17FF
$D000 $1800-$1BFF
$D008 or $D010 $1C00-$1FFF

Mirroring Control ($E000)

7  bit  0
---------
RS.. ..MM
||     ||
||     ++- Mirroring (0: vertical; 1: horizontal;
||                        2: one-screen, lower bank; 3: one-screen, upper bank)
|+-------- Silence expansion sound if set
+--------- WRAM enable (1: enable WRAM, 0: protect)

IRQ Control ($E008 - $F010)

$E008, $E010:  IRQ Latch
       $F000:  IRQ Control
$F008, $F010:  IRQ Acknowledge

对比起VRC6起来, 简直不知道友好到哪里去! 根据地址线的规律, 可以使用:

    const uint16_t vrc7a = address >> 4;
    const uint16_t vrc7b = address >> 3;
    const uint16_t base = (((address >> 11) & 0xfffe) | ((vrc7a | vrc7b) & 1)) & 0xf;

将分散的数据聚集在一起方便switch编写.

兔宝宝历险记2 模拟出现的问题

bug1

一开始这个东西根本就不能运行, 到处出错. 一步一步反汇编后发现, 其实是IRQ的实现有问题. CLI后不久, 强行触发中断导致程序乱跑.

根本原因还是IRQ实现有问题(废话), APU禁用IRQ后依然触发了已经挂起的IRQ. 目前先将挂起的IRQ清除掉, 以后好好研究一下IRQ.

VRC7 扩展音源

VRC7拥有6个FM合成音源声道, 实现了Yamaha YM2413 OPLL的一个功能子集(阉割版). 似乎叫做'Yamaha DS1001', 下面为了方便描述, VRC7与'Yamaha DS1001'在描述上等价.

前面有一个寄存器Mirroring Control ($E000), 其中Silence位为1的话, 会让VRC7部分静音并清空相关数据状态.

Audio Register Select ($9010)

7......0
VVVVVVVV
++++++++- The 8-bit internal register to select for use with $9030

写入后, 程序不得在6个CPU周期(N制, 实际是12个内部周期)内写入$9030以及$9010, VRC7内部处理需要一点时间.

Audio Register Write ($9030)

7......0
VVVVVVVV
++++++++- The 8-bit value to write to the internal register selected with $9010

写入后, 程序不得在42个CPU周期(N制, 实际是84个内部周期)内写入$9030以及$9010, VRC7内部处理需要一点时间.

42周期可是比三分之一扫描行还多! 不过, 一般地, 作为模拟器不必担心.

内部寄存器

虽然$9010表明几乎有256个寄存器, 似乎内部只有26个:

频率调制

FM是使高频振荡波的频率按调制信号规律变化的一种调制方式. 采用不同调制波频率和调制指数, 就可以方便地合成具有不同频谱分布的波形, 再现某些乐器的音色.

翻开(已经不存在的)大学教材, 回想起被傅里叶老人家支配的痛苦, 不由得感慨万分, 于是合上(已经不存在的)这本教材.

频率调制(Frequency modulation)FM, 现在似乎变成了电台的代名词了. 简单来说, 这里就是通过调制振荡器(Modulator)与载波振荡器(Carrier)进行合成, 即双算子-FM.

在音乐合成时, 双算子-FM可以采用:


F(t) = A sin(ωt + I sin ωt )
             c          m

     Modulator              Carrier
     调制振荡器             载波振荡器
  +-----------------+  +-----------------+
  |                 |  |                 |
  | +-+    +------+ |  | +-+    +------+ |
+-->+X+--->+ Sin  +------+X+--->+  Sin +---> 
  | +++    +--+---+ |  | +++    +---+--+ |
  |  ^        ^     |  |  ^         ^    |
  |  |        | I   |  |  |         | A  |
  |  |        |     |  |  |         |    |
  | -|-    +--+---+ |  | -|-    +---+--+ |
  | P|G    |  EG  | |  | P|G    |  EG  | |
  | -|-    +------+ |  | -|-    +------+ |
  |  |              |  |  |              |
  +--|--------------+  +--|--------------+
     |                    |
     +                    +
     ωm                   ωc

EG: 包络发生器 Envelope Generator
PG: 相位发生器 Phase    Generator

ADSR包络提供一个比较自然的A(t), I(t)函数:


   ON            OFF
    ---------------
---|              |-----------

       /\          
      /  \________ 
     /            \
    /              \

    AAAADDSSSSSSSSRR 

VRC7内部拥有15个预置好的乐器PATCH和1个自定义PATCH, 也就是每8字节一个乐器:

Register Bitfield Description
$00 TVSK MMMM Modulator tremolo (T), vibrato (V), sustain (S), key rate scaling (K), multiplier (M)
$01 TVSK MMMM Carrier tremolo (T), vibrato (V), sustain (S), key rate scaling (K), multiplier (M)
$02 KKOO OOOO Modulator key level scaling (K), output level (O)
$03 KK-Q WFFF Carrier key level scaling (K), unused (-), carrier waveform (Q), modulator waveform (W), feedback (F)
$04 AAAA DDDD Modulator attack (A), decay (D)
$05 AAAA DDDD Carrier attack (A), decay (D)
$06 SSSS RRRR Modulator sustain (S), release (R)
$07 SSSS RRRR Carrier sustain (S), release (R)

声道

Register Bitfield Description
$10-$15 LLLL LLLL Channel low 8 bits of frequency
$20-$25 --ST OOOH Channel sustain (S), trigger (T), octave (O), high bit of frequency (H)
$30-$35 IIII VVVV Channel instrument (I), volume (V)
     49716 Hz * freq
F = -----------------
     2^(19 - octave)  

例如, A440, 记作A4, 八度(octave)为4. 这时freq为288.

49716Hz(49715.909Hz)是因为内部需要72周期处理全部声道, 所以可以反推VRC7的内部运行频率大致是3.579552MHz, 大约是N制CPU频率的两倍.

而且这个东西是硬件, 不会说插在P制红白机上就自动降频了(不过由于是VRC7内部驱动, 所以插在P制上也是发出的是同一个频率音符, 不像内部声部). 并且, 由于49716Hz>44100Hz, 所以我们可以认为, 最后输出是由各个声道通过混频得到的.

下面, 如果没有特殊说明, 说到VRC7的时钟周期, 是指49716Hz的周期.

VRC7的场合

'VRC7 Audio'里面并没有详细介绍细节, 不过下面论坛链接就有了.

相位计算:

每个算子拥有一个18bit的计数器用于确定当前相位, 每个时钟周期都会递增:


$00/$01 MMMM  $0  $1  $2  $3  $4  $5  $6  $7  $8  $9  $A  $B  $C  $D  $E  $F
Multiplier    1/2  1   2   3   4   5   6   7   8   9  10  10  12  12  15  15

phase += F * (1<<O) * M * V / 2

实际使用中会使用'phase_secondary': phase_secondary = phase + adj

$03         FFF    $0     $1     $2     $3     $4    $5    $6    $7
Modulation Index    0    π/16    π/8    π/4    π/2    π    2π    4π

18bit的'phase_secondary'构造为: [RI IIII III. .... ....]

R和I都是之后需要使用的:

输出计算:

每个周期每个算子输出一个衰减值:

TOTAL = half_sine_table[I] + base + key_scale + envelope + AM

其中half_sine_table并不是真正的正弦函数表, 而是衰减值:

sin(pi/2) = 1   ~~~>  I='0100 0000'  ~~~>  half_sine_table[ I ] = 0 dB
sin(0) = 0      ~~~>  I='0000 0000'  ~~~>  half_sine_table[ I ] = +inf dB

其中base:

其中key_scale是$02$03的2bit值K:

F: 9bit的频率数据
Oct: 八度

IF K==0, THEN
  key_scale = 0
ELSE
  A = table[ F >> 5 ] - 6 * (7-Oct)
  IF A < 0, THEN
    key_scale = 0
  ELSE
    key_scale = A >> (3-K)
  ENDIF
ENDIF

table:
   F:     $0     $1     $2     $3     $4     $5     $6     $7     $8     $9     $A     $B     $C     $D     $E     $F
   A:     0.00  18.00  24.00  27.75  30.00  32.25  33.75  35.25  36.00  37.50  38.25  39.00  39.75  40.50  41.25  42.00

后面的包络envelope, AM, 后面说明.

  1. 到这里, 每个算子的输出就计算好了. 不过这是一个衰减值, 需要转换成一个初步的线性输出值, 20bit.
  2. 根据'R'与'waveform'的值决定是否钳制为0.
  3. 还要通过一个滤波器, 不过很简单, 只需要和前一个输出(不是最终值)做平均就行
  4. 输出线性值
  5. 衰减值与线性值转换公式为:
dB     = -20 * log10( Linear ) * scale      (if Linear = 0, dB = +inf)
Linear = 10 ^ (dB / -20 / scale)

包络发生器

每个算子的包络发生器拥有一个23bit的计数器(记为EGC), 为输出添加衰减值(控制音量). 拥有ADSR阶段, 结束后进入空状态(Idle).

除开Attack阶段, EGC为零时输出0dB, EGC1<<23时, 输出48dB. 文中建议dB使用(1<<23)/48作为基础单位, 以做出最少的单位转换.

这里列出一些数据之后会用上:

Attack阶段:

Decay阶段:

Sustain阶段:

Release阶段:

Idle阶段:

敲键盘

敲下和弹起后会处理一些事. 前面说了, 卡住的不算, 换一个键盘再来.

敲下:

弹起

AM/FM

前面提到的什么抖音颤音就在这了. 与前面不同的是, AM/FM的状态是全算子共用的(VRC7就一个AM/FM). AM/FM拥有一个20bit的计数器, 每个周期加上rate, 有一个共有的sinx = sin(2 * PI * counter / (1<<20)).

AM:

FM:

也就是说, FM/AM的计算不受用户(这里是指程序猿)的影响, 用户能控制的是决定用不用AM/FM.

可以看出很多地方的计算相当复杂, 具体可以用查表实现. 涉及到精度的查表, 自己会用常量控制, 然后试试什么精度可以接受.

实际编写

实际编写中自然是遇到了大量问题:

还一个架构上的问题:

之前提到了, 目前的处理模式是处理上一个状态. 所以外部有一个状态机, 保存了模拟器声部的上一次状态. 但是由于VRC7状态太复杂了, 不方便弄, 所以实现与其他不同:

合并输出

vrc7

PATCH表

预置PATCH似乎到目前还没有明确值, 自己使用的自然是wiki提供的:

// 内部PATCH表
const uint8_t sfc_vrc7_internal_patch_set[128] = {
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Custom
    0x03, 0x21, 0x05, 0x06, 0xB8, 0x81, 0x42, 0x27, // Buzzy Bell
    0x13, 0x41, 0x13, 0x0D, 0xD8, 0xD6, 0x23, 0x12, // Guitar
    0x31, 0x11, 0x08, 0x08, 0xFA, 0x9A, 0x22, 0x02, // Wurly
    0x31, 0x61, 0x18, 0x07, 0x78, 0x64, 0x30, 0x27, // Flute
    0x22, 0x21, 0x1E, 0x06, 0xF0, 0x76, 0x08, 0x28, // Clarinet
    0x02, 0x01, 0x06, 0x00, 0xF0, 0xF2, 0x03, 0xF5, // Synth
    0x21, 0x61, 0x1D, 0x07, 0x82, 0x81, 0x16, 0x07, // Trumpet
    0x23, 0x21, 0x1A, 0x17, 0xCF, 0x72, 0x25, 0x17, // Organ
    0x15, 0x11, 0x25, 0x00, 0x4F, 0x71, 0x00, 0x11, // Bells
    0x85, 0x01, 0x12, 0x0F, 0x99, 0xA2, 0x40, 0x02, // Vibes
    0x07, 0xC1, 0x69, 0x07, 0xF3, 0xF5, 0xA7, 0x12, // Vibraphone
    0x71, 0x23, 0x0D, 0x06, 0x67, 0x75, 0x23, 0x16, // Tutti
    0x01, 0x02, 0xD3, 0x05, 0xA3, 0x92, 0xF7, 0x52, // Fretless
    0x61, 0x63, 0x0C, 0x00, 0x94, 0xAF, 0x34, 0x06, // Synth Bass
    0x21, 0x72, 0x0D, 0x00, 0xC1, 0xA0, 0x54, 0x16, // Sweep
};

这有128字节, 之前提到自定义BUS有256字节咩用到, 刚好可以用! 不过由于不在APU区, 储存状态时需要提供接口写入状态(这里是读取, 写入类似):

/// <summary>
/// VRC7: 从流读取至RAM
/// </summary>
/// <param name="famicom">The famicom.</param>
static void sfc_mapper_55_read_ram(sfc_famicom_t* famicom) {
    // 读取VRC7 PATCH表
    famicom->interfaces.sl_read_stream(
        famicom->argument,
        sfc_get_vrc7_patch(famicom),
        sizeof(sfc_vrc7_internal_patch_set)
    );
    // 流中读取至CHR-RAM[拉格朗日点使用了CHR-RAM]
    sfc_mapper_rrfs_defualt(famicom);
}

拉格朗日点 模拟出现的问题

音效: 开头start就出现问题了. 遂搜索论坛, 频率扫描的位移器, 如果为0则不进行扫描.

wtf

坑爹呢这是! wiki用0作为例子自己还以为是可以是0的! 这个问题已经在前面的博客修改并注明了(希望不要再出幺蛾子). 之前的代码自然是错的, 不过也不会去改了.

这游戏感觉完全在炫耀机能一般, 第一个BGM就在大量使用FM特性.

bug2

REF

附录: 各个表长的研究

FM

第一个说FM是因为有两个考虑的地方, 深度与宽度, 其中如果采用的是浮点的话, '深度'就无需考虑了. 但是代码就这一个地方使用了浮点也太奇怪了.

    for (int i = 0; i != SFC_VRC7_FM_LUTLEN; ++i) {
        const double sinx = sin(SFC_2PI * i / SFC_VRC7_FM_LUTLEN);
        const double out = pow(2.0, 13.75 / 1200.0 * sinx);
        sfc_vrc7_fmlut[i] = out;
    }

宽度同其他的, 这里谈谈深度. F * (1<<O) * M * V / 2, 转成(F * (1<<O) * M / 2) * V. 其中(F * (1<<O) * M / 2)播放一个音符中, 是一个固定值.

/// <summary>
/// StepFC: VRC7 FM计算
/// </summary>
/// <param name="left">The left.</param>
/// <param name="right">The right.</param>
/// <returns></returns>
static inline uint32_t sfc_vrc7_fm_do(uint32_t left, sfc_vrc7_fm_t right) {
#ifdef SFC_FM_FLOAT
    return (uint32_t)((double)left * (double)right);
#else
    return (left * right) >> SFC_VRC7_INT_BITWIDTH;
#endif
}
/// <summary>
/// StepFC: VRC7 FM计算
/// </summary>
/// <param name="left">The left.</param>
/// <param name="right">The right.</param>
/// <returns></returns>
static inline uint32_t sfc_vrc7_fm_do(uint32_t left, sfc_vrc7_fm_t right) {
#ifdef SFC_FM_FLOAT
    return (uint32_t)((double)left * (double)right);
#else
    const int32_t ileft = (int32_t)left;
    const int32_t extra = (ileft * right) / (1 << SFC_VRC7_INT_BITWIDTH);
    return (uint32_t)(ileft + extra);
#endif
}

实际实现中, 由于有除以4的操作, 这样会丢失精度. 所以上面函数uint32_t left, 实际是还没有除以4的uint32_t left_x4

宽度

a2l1

精度10位时, 最后几个差距有点大.

a2l2

精度14位时, 精度大致介于1与2之间.

a2l3

精度16位时, 精度已经有0.5了.

所以选择: 15位, 这基本是最低要求, 可以使用16位

// 可修改
enum {
#if 1
    // VRC7 半正弦表位长
    SFC_VRC7_HALF_SINE_LUT_BIT = 10,
    // VRC7 AM 表位长
    SFC_VRC7_AM_LUT_BIT = 8,
    // VRC7 FM 表位长
    SFC_VRC7_FM_LUT_BIT = 8,
    // VRC7 Attack输出 表位长
    SFC_VRC7_ATKOUT_LUT_BIT = 8,
    // VRC7 衰减转线性查找表位长
    SFC_VRC7_A2L_LUT_BIT = 15,
#else
    // VRC7 半正弦表位长
    SFC_VRC7_HALF_SINE_LUT_BIT = 16,
    // VRC7 AM 表位长
    SFC_VRC7_AM_LUT_BIT = 16,
    // VRC7 FM 表位长
    SFC_VRC7_FM_LUT_BIT = 16,
    // VRC7 Attack输出 表位长
    SFC_VRC7_ATKOUT_LUT_BIT = 16,
    // VRC7 衰减转线性查找表位长
    SFC_VRC7_A2L_LUT_BIT = 16,
#endif
};