dustpg / BlogFM

Blog for Me
MIT License
155 stars 23 forks source link

Re: 从零开始的红白机模拟 - [37] 音频可视化 #50

Open dustpg opened 5 years ago

dustpg commented 5 years ago

StepE: 高级音频支持

数数这一步的内容:

音频可视化

终于, 进入本步骤的最后一篇——音频可视化. 已经到E了, 16进制已经快装不下了, 预示着《Re: 从零开始的红白机模拟》也快结束了.

说到音频可视化, 可能就不得不提到傅里叶老人家. 不过在这里, 可能是见不到他老人家了——因为我们能拿到更基础的信息

数据可视化

可视化的目的就是为了直观的了解到相关'信息', 那么音频方面信息有哪些呢?

结合实际情况, 我们需要对音高、音色、音量等信息进行可视化.

音高

音高反映的是频率, 频率可视化自然就是映射到键盘上——

piano-keyboard-clipart

例如440Hz就是A4(A440标准), 中央C大致是262Hz. 键盘上是以'3白夹2黑 + 4白夹3黑'作为一节, 然后向两个方向扩展开来.

piano-keyboard-sec

白键从左到右依次叫做CDEFGAB, 黑键则是对应的两个白键之间的半音, 记作升(♯, sharp, 就是C#的那个), 或者降(♭, flat). 即C# = D-flat. (♯#或许有区别, 不过'#'比较容易输入, 这里就用#作为半音符号表示).

其中EF, BC其实也是半音的关系. 应该是为了方便钢琴家定位才这么排列的.

标准钢琴则是选取的: 以A0开始的88键这个一区间. 挑战! 回头找一下'中央C'在哪!

键位计算

Equations for the Frequency Table给出了计算公式:

f(n)=  (\sqrt[12]2{})^{n-49} \times A_{440}

func

现在自然要做出逆运算:

/// <summary>
/// 以A4为基础计算键ID
/// </summary>
/// <param name="freq">The freq.</param>
/// <returns></returns>
float calc_key_id_a4(float freq) {
    /*
     换底公式
                    log(c, b)
        log(a, b) = ------------
                    log(c, a)
    */
    const float a4 = 440.f;
    return logf(freq / a4) * 17.3123404907f;
}

实际上, 因为一节是以C开始的所以实际实现中, 是以C4作为0. 再具体, 可能需要进行四舍五入转成整型方便查表, 注意:

// 浮点转整型实现A
return static_cast<int>(floorf(code + 0.5f));

// 浮点转整型实现B
const float adj = code < 0.f ? -0.5f : 0.5f;
return static_cast<int>(code + adj);

// 负数膜运算实现A
const auto b = ((a % 12) + 12) % 12;
return lut[b];

// 负数膜运算实现B:
// 直接膜, 但是LUT是双向的
const int lut[24] = {1,2,3, ... };
return (lut+12)[a%12];

也就是负数需要特别注意.

具体处理

颜色生成

这里颜色生成是通过HSL色彩空间映射到RGB空间的方式, 生成的颜色. 需要一些随机颜色就可以随机'H'即可, 直接随机RGB生成的颜色不怎么好看, 这也算是一个小技巧.

目前需要的数据:

struct visualizers_data {
    // 键盘
    color_t     color;
    float       freq;
    bool        key_on;
};

当然, 音量可以也用不透明度表示——不过交给用户控制, 不然可能看不清. 目前简单乘以2再钳制到1.

static inline float vol2alpha(float vol) {
    vol *= 2.f; return vol > 1.f ? 1.f : vol;
}

自己(正式释出)打算用(类对数)曲线控制:

   ^
   |
   |                         XXXXXXXXXXX
   |                 XXXXXXXX
   |              XXX
   |            XX
   |          X
   |        X
   |      XX
   |     X
   |    X
   |   X
   |  X
   | X
   |X 
   |X
+---------------------------------------->
   |
   +

音色

我们知道, 音色是由其谐波决定的. 不过除开类似ΔPCM、FDS之类的基于波形信息的, 其余声部的音色其实已经预定好了, 我们需要的就是展现其内在信息.

例如方波就展示其'占空比'这一属性.

音量

不少声部都支持音量的调制.

sq1

例如看出橙色的方波#1占空比是25%, 音量大约是11. 绿色的方波#2占空比是75%, 音量约8.

各个声道计算

2A03

VRC6

VRC7

频率调制:

     49716 Hz * freq
F = -----------------
     2^(19 - octave)

注: 使用FM功能另算.

音量:

FDS1

MMC5

N163

波形表频率:

f = wave frequency
l = wave length
c = number of channels
p = 18-bit frequency value
n = CPU clock rate (≈1789773 Hz)

f = (n * p) / (15 * 65536 * l * c)

相关参数:

* w[$80] = the 163's internal memory
* sample(x) = (w[x/2] >> ((x&1)*4)) & $0F
* phase = (w[$7D] << 16) + (w[$7B] << 8) + w[$79]
* freq = ((w[$7C] & $03) << 16) + (w[$7A] << 8) + w[$78]
* length = 256 - (w[$7C] & $FC)
* offset = w[$7E]
* volume = w[$7F] & $0F

FME7

波形调制

多试了几个NSF, 在播放FDS时, 某些NSF音调上还是有点问题, 这个感觉找不到原因了, 这个BUG估计得留下来了.

N163, FDS1, 以及VRC7都可以算是通过给定波形进行指定频率输出. 当然还可以算上半个DMC. 对于这种我们可以显示原始波形在一旁作为参考.

N163和FDS1的波形是可以直接获取的. 但是VRC7的不能. DMC的这里暂时不考虑, 如果长度1kb的话就有8k个样本了.

n163

获取VRC7波形

我们再来回顾一下VRC7声道的控制位:

影响音色的只有 Sustain覆盖位, 以及 乐器索引. 后者是理所当然的, 前者是无关紧要的, 是用来控制衰减.

然后就是样本数量了, 参考FDS1的64个, N163的最大256个. 那就决定采样128个. 49716Hz / 128 = 388.4Hz, 比G4的392Hz略低, 即octave = 4.

freq = F * 2^(19-octave) / VRC7
freq = VRC7 / n * 2^(19-octave) / VRC7
freq = 2^(19-7-4)
freq = 256

ADSR包络

直接生成的话几乎没有声音, 因为正在处于'A'阶段. 最暴力的办法当然就是修改核心代码让这个情况返回0衰减. 这里就采用'ADSR'增加一个阶段, 这这个阶段直接返回0.

    case SFC_VRC7_Debug:
        rv = 0;
        break;

这样生成的波还是有点问题, 也就是粗糙模拟而已. 如果精确模拟的话可能太花时间了.

vrc7

VRC7补漏

补充说明: 这里VRC7有一些不太影响的'小错误', 下面是后面补充的. 果然小细节就容易忘记! VRC7的音量是'衰减值', 15是最小音量, 0是最大音量. 所以单位变成负数了 -3.00dB x N.

然后自己又想到一个稍微精确的波形计算, 更简单但是需要一点额外的运算(也不多, 和上次一样128样本, 只不过是每帧都需要):

/// <summary>
/// StepFC: VRC7生成波表
/// </summary>
/// <remarks>
/// out是 长度为128 x 6的缓冲区
/// </remarks>
/// <param name="famicom">The famicom.</param>
/// <param name="out">The out.</param>
/// <param name="instrument">The instrument.</param>
void sfc_vrc7_wavetable_update(sfc_famicom_t* famicom, float* const out) {
    // 获取当前VRC7状态
    sfc_vrc7_data_t vrc7_bk = famicom->apu.vrc7;
    const uint8_t* const vrc7_patch = sfc_get_vrc7_patch(famicom);
    // 仅仅重置频率
    for (int i = 0; i != 6; ++i) {
        sfc_vrc7_ch_t* const ch = vrc7_bk.ch + i;
        ch->freq = 256;
        ch->octave = 4;
        ch->carrier.phase = 0;
        sfc_vrc7_operator_changed(vrc7_patch, ch, &ch->modulator, 0);
        sfc_vrc7_operator_changed(vrc7_patch, ch, &ch->carrier, 1);
    }
    // 生成样本
    for (int i = 0; i != 128; ++i) {
        int32_t vrc7_output[6];
        sfc_vrc7_49716hz(&vrc7_bk, vrc7_patch, vrc7_output);
        for (int j = 0; j != 6; ++j) {
            const double v0 = (double)vrc7_output[j] / (double)(1 << 20);
            out[128 * j + i] = (float)v0;
        }
    }
}

这样生成的波形依然不准确, 但是可以体现ASDR的衰减和调制器!

vrc7

减少DrawCall

减少DrawCall是提示图形显示效率的最直接的办法. 这里, 如果相邻的两条线段斜率一致则可以进行合并.

实时版

这样称为'实时版', 不存在时间的问题, 可以在游戏的时候也能打开, 最后大致结果就是:

demo1

测试视频地址

框架优势

现在就要发挥目前框架优势了, 目前这个项目的优势为:

接口意味着允许'动态更换'.

事件驱动本来是自己对于音频的一种实现手段, 并且精度比不上基于周期的'次等手段', 不过本身就包含了音频事件信息, 并且没有真正处理音频.

非单例, 几乎每条函数都自带了一个sfc_famicom_t*参数, 这本来是'累赘'.

音频事件时间线

这是FT的界面

ft

我们可以看出有一条'时间线', 下方的就是未来的事件, 上方的就是之前的事件. 是时候发挥框架优点了: 变废为宝.

前面提到的优势就是: 我们可以同时运行两台虚拟机, 使用不同的接口. 一个跑在前面, 仅仅处理音频事件并且记录下来, 后面的才是真正的播放.

内容

时间线实际上可以表示很多(几乎所有)东西, 但是出于简单化, 考虑仅仅处理音量和音高.

其中音高值得注意的是, 作曲家可能会手动模拟FM——也就是'抖音'. 频率在一个音符范围内来回摆动, 这样在时间线是看不出区别的——都是一个音. 所以可以打上FM的标记, 键盘上的波浪线'~'就比较合适.

音量用线段表示就行. 而对应的AM暂时不考虑, 背景可以从折线看出来.

目前没有做优化, 每次都是从现有数据生成的. 这种最末端的代码, 自然是放在最后优化, 不然一改就傻了

预载版

这样称为'预载版', 只能在播放NSF时才能启用. 并且, 再配合'即时读档'功能时需要特别注意:

demo2

测试视频地址

.

大致是这样的, 不过还是太简陋了, 不过反正仅仅是随便写的, 还没有正式释出.

还有一种比较典型的解决方案就是:

hist

这种也是一种解决方案, 比如可以用颜色表示声道, 宽度表示音量, 位置就是音高了. 不过自己还是希望看一下'谱子', 而不是'键位'.

StepE: 高级音频支持

这就本步的最后一节, 音频可视化. 这一步统称'高级音频支持'. 本步骤项目地址:

Github-StepFC-StepE

REF

附录: 负数MOD运算

((x % 12) + 12) % 12 微软编译器的实现:

mov         eax,2AAAAAABh  
imul        ecx  
push        esi  
sar         edx,1  
mov         eax,1  
mov         esi,edx  
shr         esi,1Fh  
add         esi,edx  
sub         eax,esi  
pop         esi  
lea         eax,[eax+eax*2]  
lea         eax,[ecx+eax*4]  
mov         ecx,0Ch  
cdq  
idiv        eax,ecx  
mov         eax,edx  
ret

将原本两次idiv优化成一次了. 所以, 膜还是很花时间的, 大家不要随便膜.