-
Notifications
You must be signed in to change notification settings - Fork 23
Description
NSF深入
蓦然回首, 已经可以几乎完全播放NSF了. 也快进入本步骤的尾声了, 这一步似乎过于长了.
标准音源
'标准音源'在这里指的是最多有一个扩展音源的那种: exsound为0(2A03音源), 或者说有一位为1(有一个扩展音源), 可以用这个判断:
(exsound & (exsound - 1)) == 0;当然这里只是指出这个位计算技巧而已, 实际上还是需要一个一个判断. 在这种情况下, 可以直接将写入操作'嫁接'到原来的写入就行(当然, 需要注意NSF的BANK切换寄存器).
多种混合
自然就是NSF支持的, 理论上, 在实机上也能播放(不然支持多音源就没有意义了!), 例如NicoNicoNi的这一篇:
从右往左分插了:
- 吉米克!
- 女神転生Ⅱ
- 大航海时代
- 魍魎戦記MADARA
- 拉格朗日点
就可以在实机上播放NSF(同一个作者的另一个视频就另有FDS). 一般地, 对于'标准'(最多一个), 可以使用对应的镜像地址. 不过多种混合就支持最低地址(镜像地址重合).
VRC6
- $9000-9003
- $A000-A002
- $B000-B002
- 以 恶魔城3 为标准, ab两个变种的两条地址线反了
VRC7
- $9010
- $9030
FDS1
- $4040-$4092
- FDS情况很特殊, RAM布局什么的都可以不管, 但是BANK切换就不同了:
- $5FF6,$5FF7能够控制$6000-6FFF, $7000-7FFF这一区域
- NSF 文件头 $076-$077也有能控制. 详细的还是看wiki吧
MMC5
- $5000-5015, 音频支持
- $5205,$5206, 8x8的乘法器
- $5C00-5FF5, ExRAM区, 最上面的和NSF的BANK切换寄存器冲突了导致不可用
N163
- $4800
- $F800
FME7
- $C000
- $E000
作为软件播放器, 支持最低地址就行(当然支持所有镜像地址也是不错的), 在实机上由于地址互相重叠, 可能会使用一些技巧.
权重分配
之前使用的权重标准是基于0.00752, 来至wiki的线性逼近公式:
output = pulse_out + tnd_out
pulse_out = 0.00752 * (pulse1 + pulse2)
tnd_out = 0.00851 * triangle + 0.00494 * noise + 0.00335 * dmc
自己理所当然地以为这些加起来等于1, 但是实际上是0.8XXX, 索性直接用Mixer的 95.88/(8128/15+100), 这个值大约0.1494. 所以就用这个值作为基础值, 双通道是0.2585.
- VRC6, 方波声道(复数)最大音量和2A03差不多
-
- [6bit]
0.2583 / 30 * vrc6
- [6bit]
- VRC7, 无比较
- FDS1, 最大音量似乎是方波的2.4倍
-
- [6bit]
0.1494 * 2.4 / 63 * fds1
- [6bit]
- MMC5, 与2A03一致, 8bit PCM则看自己吧
- N163, 把
0.00752/16换成0.1494/225即可 - FME7, 无比较
当然, 除了wiki直接给出的, 还有一个参考: NSFe. 这个是NSF的扩展版本. 里面有相关的比较(单位是百分之一dB):
- VRC6 - Default: 0
-
- 这就是提到的'差不多'
- VRC7 - Comparison: Pseudo-square - Default: 1100
-
- 11dB大致为3.5倍, 也就是达到0.523, 考虑到是有符号的所有应该除以2. 6声道暴力加起来就是1.57(双向).
-
- 不过自己是认为的是共有8声道, 所以权重为2.092(近似看作2也行).
- FDS - Default: 700
-
- 7dB和'2.4'倍差不多
- MMC5 - Default: 0
-
- 这就是提到的'差不多'
- N163 - Comparison: 1-Channel mode - Default: 1100 or 1900
-
- wiki提到是11dB~19.5dB
- Sunsoft 5B - Comparison: Volume 12 ($C) - Default: -130
-
- 差距是-1.3dB, 不过比较的'12', 和15差了9dB. 加起来7.7dB. 和FDS差不多2.4倍吧
-
- 权重
0.1494*2.4*3, 几乎和1.0相差无几, 索性看着1.0.
- 权重
VRC7 产生的伪方波:
NSFe虽然支持这个, 不过基本就是针对N163设计的, 其他的几乎就是用实体卡带简单测试一下, 不过作为参考足够了.
这样可以试试这个2A03+VRC6+FDS1+MMC5+FME7的NSF了!
FDS 补遗
之前 FDS篇 FDS实现有问题, 由于测试样本不足导致没有发现:
0 = %000 --> 0
1 = %001 --> +1
2 = %010 --> +2
3 = %011 --> +4
4 = %100 --> reset to 0
5 = %101 --> -4
6 = %110 --> -2
7 = %111 --> -1
'4'是'reset to 0', 自己还以为和0一样...导致调制出现问题.
const int8_t value = table[famicom->apu.fds.modtbl_index++];
fds->modtbl_index &= 0x3f;
// 0, 2, 4, 8, 1, -8, -4, -2
if (value & 1) fds->mod_counter_x2 = 0;
else fds->mod_counter_x2 += value;N163声道模式
之前 N163篇 提到自己的实现是'声道模式', 输出最后处理的几个历史输出. 使用的是'3', 其实本身就是一个滤波器, 适当延长数字能够更好一点(8声道杂音太重, 比如可以是声道数量-1, 但是不能低于3).
非标准速度支持
前面 FDS篇 提到了64Hz/60Hz, 如果将一个要求64Hz的用60Hz播放会发生什么事情?
自己想了想, 之前将PLAY频率和FPS想在一起了. 其实没有必要, 比如60Hz就是每隔大约3万CPU周期调用一次PLAY, 120Hz就每隔1.5万CPU周期调用一次PLAY就行.
// 播放速度提示
info->clock_per_play_n = (uint64_t)1789773 * (uint64_t)header.play_speed_ntsc_le / (uint64_t)1000000;
info->clock_per_play_p = (uint64_t)1662607 * (uint64_t)header.play_speed__pal_le / (uint64_t)1000000;
printf("NSF NTSC RATE: %.02fHz\n", 1000000.0 / header.play_speed_ntsc_le);提示INIT结束, 之前INIT结束是没有通知的, 因为大致是到了垂直同步时, INIT应该完成了. 这显然是不准确的, 所以自己HACK了OpCode-02让其通知NSF初始化结束了:
/// <summary>
/// HK2: Hack $02 - 用于提示NSF初始化
/// </summary>
/// <param name="address">The address.</param>
/// <param name="famicom">The famicom.</param>
static inline void sfc_operation_HK2(uint16_t address, sfc_famicom_t* famicom, uint32_t* const cycle) {
famicom->nsf.play_clock = famicom->rom_info.clock_per_play;
}INIT和PLAY调用的程序也就不一样了:
// PLAY时钟周期
famicom->nsf.play_clock = 0xffffffff;
// 调用INIT程序
const uint16_t address = famicom->rom_info.init_addr;
famicom->registers.program_counter = 0x4106;
const uint32_t loop_point = 0x410A;
// JSR
famicom->bus_memory[0x106] = 0x20;
famicom->bus_memory[0x107] = (uint8_t)(address & 0xff);
famicom->bus_memory[0x108] = (uint8_t)(address >> 8);
// (HACK) HK2
famicom->bus_memory[0x109] = 0x02;
// JMP $410A
famicom->bus_memory[0x10A] = 0x4c;
famicom->bus_memory[0x10B] = (uint8_t)(loop_point & 0xff);
famicom->bus_memory[0x10C] = (uint8_t)(loop_point >> 8);整个流程也简单起来了:
while (famicom->cpu_cycle_count < cpu_cycle_per_frame) {
const uint32_t cycle = sfc_cpu_execute_one(famicom);
// PLAY
if (famicom->nsf.play_clock < cycle) {
famicom->nsf.play_clock += famicom->rom_info.clock_per_play;
sfc_famicom_nsf_play(famicom);
}
// FRAME COUNTER
if (famicom->nsf.framecounter_clock < cycle) {
famicom->nsf.framecounter_clock += cpu_cycle_per_frame_4;
sfc_trigger_frame_counter(famicom);
}
famicom->nsf.play_clock -= cycle;
famicom->nsf.framecounter_clock -= cycle;
}现在回答开头的提问, 答案是'慢一点', 不过不是音乐的频率慢一点, 而是节奏或者说BPM慢一点, 频率还是一致. 就像用不同速率敲键盘.
长度计算
原本的NSF是没有曲子的长度信息的, 我们可以进行估计:
- 放完了, 部分曲子放完了, 就播放完毕. 我们可以设定一个阈值, 比如说1分钟. 超过阈值还没有改变就认为放完了. 将当前长度减去阈值就行.
- 循环. 这个就是常见的游戏BGM的实现了, 要怎么检查曲子是无限循环的呢?
- 这里提供一个思路, 在每次PLAY后监视哪些变量进行修改了, 最后进行评估.
- 或者比如超过5分钟还没完就认为是3分钟的曲子
随机访问
比如很想支持Seek功能, 这里提供一个思路:
- 类似于S/L, 将数据尽可能简化(比如不用储存VRAM), 数据量可能在30kb左右(没简化的数据量, 其实NSF用不了多少RAM, 甚至能够简化到几kb).
- 用1mb为上限大致能存30个'快照'
- 找到最近的前快照然后加速到目标时间
辣鸡ROM支持
load_addr + length 没有对齐BANK. 之前弄到一个NSF文件, 15.3kb, 理所当然认为占用了4个BANK(4kb), 但是实际上用了5个BANK. 典型的辣鸡.
上面就是一些核心支持, 下面就是一些具体实现, 可以略过.
配合MMC5
// 将BANK3-WRAM使用扩展RAM代替
famicom->prg_banks[6] = famicom->expansion_ram32 + 4 * 1024 * 0;
famicom->prg_banks[7] = famicom->expansion_ram32 + 4 * 1024 * 4;
原来8kb的WRAM分配给MMC5, NSF播放使用多余的32kb扩展区的前面8kb作为新的WRAM, 再接下来的128字节是N163的扩展RAM.
音频事件
之前实现的是audio_changed事件, 目的是为了"增量更新". 关键在于'ed', 本意是修改了再触发, 没有修改就没有必要. 其实专门用来处理'帧计数器/序列器'的(这个判断很复杂). 现在先无视状态直接触发事件, 以后优化.
所以将audio_changed事件改成audio_change事件.
尘归尘, 浮点归定点
之前提到由于"每样本CPU周期数"由于除不尽(44.1kHz大致是40.58), 采用浮点避免误差. 但是这东西一会整型转浮点, 一会浮点转整型, 烦得很.
其实是为了方便描述而已, 这里就用定点小数模拟就行. 比如uint32_t高16位看着整数, 高16位看作小数, 由于每帧样本数大致在一千数量级, 所以误差数量级少于周期每帧.
// 目前使用16.16定点小数, 可以换成6.10~8.8的16bit
typedef uint32_t sfc_fixed_t;
// 创建
static inline sfc_fixed_t sfc_fixed_make(uint32_t a, uint32_t b) {
//------- 防溢出操作
// 整数部分
const uint32_t part0 = a / b;
const uint32_t hi16 = part0 << 16;
// 小数部分
const uint32_t part1 = a % b;
const uint32_t lo16 = (part1 << 16) / b;
return hi16 | lo16;
}
// 增加
static inline uint16_t sfc_fixed_add(sfc_fixed_t* a, sfc_fixed_t b) {
*a += b; const uint16_t rv = *a >> 16; *a &= 0xffff; return rv;
}由于全部从浮点小数重写为定点小数, 很容易出现自己的'ctrl+c/v'的BUG, 只能祈祷岩田聪了.
不过这样一来, 代码终于变得清爽了:
float output = 0.f;
// 2A03
{
sfc_2a03_smi_ctx_t* const ctx = &g_states.ctx_2a03;
const float* const weight_list = g_states.ch_weight + SFC_2A03_Square1;
sfc_2a03_smi_sample(g_famicom, ctx, weight_list, cps_fixed);
const float squ = sfc_mix_square(ctx->sq1_output, ctx->sq2_output);
const float tnd = sfc_mix_tnd(ctx->tri_output, ctx->noi_output, ctx->dmc_output);
output += squ + tnd;
}
// VRC6
if (extra_sound & SFC_NSF_EX_VCR6) {
const float* const weight_list = g_states.ch_weight + SFC_VRC6_Square1;
sfc_vrc6_smi_ctx_t* const ctx = &g_states.ctx_vrc6;
sfc_vrc6_smi_sample(g_famicom, ctx, weight_list, cps_fixed);
const float vrc6 = ctx->square1_output + ctx->square2_output + ctx->sawtooth_output;
output += (0.2583f / 30.f) * vrc6;
}
// VRC7
if (extra_sound & SFC_NSF_EX_VCR7) {
const float* const weight_list = g_states.ch_weight + SFC_VRC7_FM0;
sfc_vrc7_smi_sample(g_famicom, &g_states.ctx_vrc7, weight_list, cps_fixed);
const float weight = (float)(0.1494 * 3.5 * 8 * 0.5);
output += g_states.ctx_vrc7.mixed * weight;
}
// FDS1
if (extra_sound & SFC_NSF_EX_FDS1) {
const float* const weight_list = g_states.ch_weight + SFC_FDS1_Wavefrom;
sfc_fds1_smi_sample(g_famicom, &g_states.ctx_fds1, weight_list, cps_fixed);
float out = g_states.ctx_fds1.output * (2.4f * 0.1494f / 63.0f);
out = sfc_filter_rclp(&g_states.fds_lp2k, out);
output += out;
}
// MMC5
if (extra_sound & SFC_NSF_EX_MMC5) {
sfc_mmc5_smi_ctx_t* const ctx = &g_states.ctx_mmc5;
const float* const weight_list = g_states.ch_weight + SFC_MMC5_Square1;
sfc_mmc5_smi_sample(g_famicom, ctx, weight_list, cps_fixed);
const float squ = sfc_mix_square(ctx->sq1_output, ctx->sq2_output);
const float pcm = 0.002f * ctx->pcm_output;
output += squ + pcm;
}
// N163
if (extra_sound & SFC_NSF_EX_N163) {
// N声道模式
const uint8_t mode = 7;
const float* const weight_list = g_states.ch_weight + SFC_N163_Wavefrom0;
sfc_n163_smi_ctx_t* const ctx = &g_states.ctx_n163;
sfc_n163_smi_sample(g_famicom, ctx, weight_list, cps_fixed, mode);
output += ctx->output * ctx->subweight * (0.1494f / 225.f);
}
// FME7
if (extra_sound & SFC_NSF_EX_FME7) {
sfc_fme7_smi_ctx_t* const ctx = &g_states.ctx_fme7;
const float* const weight_list = g_states.ch_weight + SFC_FME7_ChannelA;
sfc_fme7_smi_sample(g_famicom, ctx, weight_list, cps_fixed);
// FME7 权重近似0.1494*2.4*3 近似 = 1
output += ctx->output[0] + ctx->output[1] + ctx->output[2];
}VRC6 锯齿波
锯齿波的强化除了之前提到的利用浮点进行插值, 还有一个:
VRC6的锯齿波之前提到了, 由于寄存器宽度问题, 音量(其实叫做rate, $B000)超过42就会导致失真. 所以可以考虑为寄存器增加宽度防止失真.
VRC7使用浮点模拟
双精度浮点可是可以将uint32_t也能完整地保存, 采用双精度浮点模拟VRC7的话音质应该是最高的了.
VRC7变频模拟
之前提到VRC7内部几乎以50kHz运行, 我们需要进行重采样. 这里就回答之前提到的问题: 为什么其他的没有进行重采样.
答案很简单, 实现困难(1.79MHz->44.1kHz), 收益很低(不会故意放出超声波, 只有较弱的高次谐波).
而VRC7的50kHz, 一是固定的, 二是和使用的采样率比较接近, 于是就特意指出了. 而最好的重采样算法自然就是"不用重采样", 我们将VRC7的运行频率变得和当前使用的采样率一样的话, 自然就不需要了.
如果直接暴力变频自然是, 声音音调不对. 这一点就需要内部部件和VRC7同时变频即可. 这一点需要双精度浮点支持, 不然误差太大反而弄巧成拙.
比如采样率是44.1kHz, VRC7降低了约十分之一, 内部声道的频率增加十分之一, FM/AM速度也增加十分之一就行.
N163变频模拟
这个和VRC7差不多, 由于声道多了, 1~2声道应该不错, 3声道和目前输出采样率差不多, 也行. 所以可在超过3声道时, 给N163'超频': 把声道频率降低, N163频率提高这样应该可以有效解决杂音问题.
同样'变频'这个概念可以推广在其他音源上.


