ファミコン(or NES)の APU のノイズ音生成処理の最低限の部分だけ作ってみました。
方針
- ノイズ音生成のしくみを知るのが目的
- エミュレータを作ってゲームを動かすのが目的ではない
- 実際のエミュレータで必須になるような最適化はしない
- 素朴な作りで良い。処理速度は遅くて良い。
- 基本部分だけ
- 長周期のみ
- 長周期の作り方がわかれば短周期はその応用でできそう
- 音量や音長なども扱わない
- 長周期のみ
- 実機で発生する歪みはエミュレートしない
- 今回は実機レベルの忠実な再現は目指さない
完成品
# nes_noise.rb
MASTER_CLOCK_HZ = 236_250_000.0 / 11
CPU_CLOCK_HZ = MASTER_CLOCK_HZ / 12 # 1_789_772.7272727273
class Noise
attr_accessor :timer
PERIOD_TABLE = [
# 0x00 0x01 ... ... 0x0E 0x0F
4, 8, 16, 32, 64, 96, 128, 160, 202, 254, 380, 508, 762, 1016, 2034, 4068
]
def initialize
# タイマー値
@timer = PERIOD_TABLE[0]
# shift register
@shift_reg = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]
end
# 下位から i 桁目のビット
def shift_reg_bit(i)
@shift_reg[14 - i]
end
def update_shift_reg
next_bit = shift_reg_bit(1) ^ shift_reg_bit(0) # XOR
@shift_reg = [next_bit] + @shift_reg[0..13]
end
end
# --------------------------------
# $400E:3-0 にセットする値(0<=, <=15)
reg400e_period = ARGV[0].to_i
# P値 / 実際の周期は P + 1
period = Noise::PERIOD_TABLE[reg400e_period]
noise = Noise.new
noise.timer = period
duration_sec = 1.0
num_cpu_cycles = (CPU_CLOCK_HZ * duration_sec).floor
puts CPU_CLOCK_HZ / (period + 1) # raw_sampling_rate
# 1回のイテレーションが 1 CPUサイクルに対応する
num_cpu_cycles.times do
if noise.timer == 0
noise.timer = period
noise.update_shift_reg()
puts noise.shift_reg_bit(0) * 2 - 1 # -1.0<= .. <=1.0 に補正
else
noise.timer -= 1
end
end
上記がメインの処理で、ファミコンの APU に特有の部分です。生成したノイズ音のデータをプレーンテキストで出力します。
# 引数は 400e の下位4ビットで指定する音程に対応(0〜15)
ruby nes_noise.rb 13 > noise_raw.txt
中間データを以下の to_wav.rb
で wav ファイルに変換します。こっちはファミコンの APU とは何も関係ない単なる音声データ変換処理です。今回の目的からすると枝葉な処理なのでファイルを分けました。
to_wav.rb
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "wavefile", "1.1.2"
end
SAMPLING_RATE = 44_100
# out index から in index へのマッピング
def to_in_index(num_samples_in, num_samples_out, i_out)
ratio = i_out / num_samples_out
(num_samples_in * ratio).floor
end
def average(xs)
xs.sum.to_f / xs.size
end
def resample(samples_in, rate_in, rate_out)
return samples_in if rate_in == rate_out
duration_sec = samples_in.size.to_f / rate_in
num_samples_in = rate_in * duration_sec
num_samples_out = rate_out * duration_sec
i_out = 0
samples_out = []
num_samples_out.floor.times do
samples_out[i_out] =
if rate_in < rate_out
# サンプリングレートを上げる場合
i_in = to_in_index(num_samples_in, num_samples_out, i_out)
samples_in[i_in]
elsif rate_in > rate_out
# サンプリングレートを下げる場合
i_in_0 = to_in_index(num_samples_in, num_samples_out, i_out)
i_in_1 = to_in_index(num_samples_in, num_samples_out, i_out + 1)
average(samples_in[i_in_0...i_in_1])
else
raise "must not happen"
end
i_out += 1
end
samples_out
end
def save_as_wav(samples, sampling_rate, filename)
# そのままだと音量が大きすぎてうるさいので適当に調節
samples.map! { |v| v *= 0.05 }
buf_fmt = WaveFile::Format.new(:mono, :float, sampling_rate)
file_fmt = WaveFile::Format.new(:mono, :pcm_16, sampling_rate)
buf = WaveFile::Buffer.new(samples, buf_fmt)
WaveFile::Writer.new(filename, file_fmt) do |writer|
writer.write(buf)
end
end
# --------------------------------
line0 = gets
raw_sampling_rate = line0.to_f
raw_samples = []
while line = gets
raw_samples << line.to_f
end
samples = resample(raw_samples, raw_sampling_rate, SAMPLING_RATE)
save_as_wav(samples, SAMPLING_RATE, "nes_noise.wav")
cat noise_raw.txt | ruby to_wav.rb
#=> nes_noise.wav ... 出力ファイル名はとりあえず決め打ち
これだけでも昔さんざん聞いたあのノイズ音を鳴らすことができてたいへん満足感がありました。「ウォーーーーー
これだよ! この音! いい音……」ってなります。
練習用のお題として手頃だったので Rust と Elixir でも書いてみました(中間データを生成する部分だけ)。
Elixir は <<_::13, b1::1, b0::1>> = shift_register
のようにビットの並び(bitstring)に対してパターンマッチできておもしろいですね。
検証
何が正解か分からないと正しく作れたか判断できません。そこで、まずは検証のために比較対象を用意しました。
こうして FCEUX で出した音と比較することで正しく実装できたかを検証します。
ノイズ音を出すだけの .nes ファイルを作って FCEUX で実行する
ここは以下の動画を参考にしました。
参考にして書いたものがこちら:
; test.asm
.inesprg 1
.ineschr 0
.inesmir 0
.inesmap 0
;; --------------------------------
.bank 0
.org $8000
start:
;; どのchの音を出すか
lda #%0000_1000
sta $4015
;; --------------------------------
;; ノイズ
;; $400C / 音量
lda #%0011_0111
sta $400C
; $400D / 未使用
; $400E / 周波数・長/短周期
; ++++ 周期に対応する値
lda #%0000_1101
sta $400E
; $400F / キーオン/オフ
lda #%0000_0000
sta $400F ; 書き込みにより発音開始
;; --------------------------------
;; 今回は不要
;; on_vblank:
;; jmp on_vblank
;; --------------------------------
.bank 1
.org $FFFA
; .dw on_vblank
.dw 0
.dw start
.dw 0
(メモ: Rouge は NesAsm の構文ハイライトに対応している)
$400E
の下位 4ビットで音程を指定できます。
%0000
(0)が一番高い音、%1111
(15)が一番低い音。
lda #%0000_1111 ; ここの右側4桁をいじる
sta $400E
アセンブル
nesasm test.asm
#=> test.nes が生成される
FCEUX で test.nes を実行すると音が出ます。
fceux test.nes
波形とスペクトルを見てみる
波形を Audacity で見てみます。上が FCEUX で実行して録音したもの、下が自分で作ったもの。
よさそうですね。high/low の切り替わりのタイミングが一致しています。
FCEUX の方はおそらく実機に近い波形になっていて、この歪んだ形をどうやって作っているのかもそのうち調べてみたいですね。
Audacity の機能で手軽に周波数スペクトルも見れるのでついでに見ておきましょう。
目で見て分かる程度には違いがありますが、ピークの位置はだいたい同じ(周波数成分がだいたい同じ)です。大丈夫そう。
ここまで確認できたので今回はこれでよしとしました。
メモ
感想など
-
仕様だけを見ていきなり実装するのは大変
- 何がどうなってると正しいのか分からない
- 仕様だけ見てもどのパーツがどういう役割でどう組み合わさっているのかが最初は分からない
- 「タイマーがどうこうって書いてあるけどそもそもタイマーって何ですか?」みたいな
- なので、(1) 比較対象となる正解データを用意する (2) 読みやすい既存実装を参考にする のが効率よさそう
-
資料によって揺れがある
- 入門者としてはモヤモヤしますね
-
一定の周期で循環する疑似乱数なので、メモリの制限がない場合などは(律儀にシフトレジスタを操作せずに)あらかじめ生成しておいた波形データを使いまわす方式にすると実装簡素化+負荷低減できたりするかも
- 循環の周期については APU Noise - NESdev Wiki を参照
CPUのクロック周波数
- 資料によって揺れがある
1.789773 MHz
FC音源とは (ファミコンオンゲンとは) [単語記事] - ニコニコ大百科
CPUクロック(1789772.5)
1789772.727272727272
ファミリーコンピュータとNTSC版NESは約21.47MHzのマスタークロックを12分周して、CPUである2A03を約1.79MHzで駆動する。
で、結局どれですか? と思ってしまうわけですが、Cycle reference chart - NESdev Wiki でマスタークロックについての記述を見つけました。
236.25 MHz ÷ 11 by definition
だそうです。これを 12 で割ると 1789772.727272... になります。FCEUX もこの値を使っているようですし、今回はこれを採用しました。
シフトレジスタ
線形帰還シフトレジスタ(LFSR: linear feedback shift register)という呼び方も見かけた。ちょっとかっこいい(?)。
シフトレジスタの初期状態
資料によってまちまち。今回は 0b000_0000_0000_0001
にしました。
On power-up, the shift register is loaded with the value 1.
On system reset, this shift register is loaded with a value of 1.
15ビットシフトレジスタにはリセット時に1をセットしておく必要があります。
GAME PROGRAMMING UNIT>OpalAL>波形>ファミコン音源のノイズについて
シフトレジスターの初期値は、0x4000(1<<14、100,0000,0000,0000)である。
FC音源とは (ファミコンオンゲンとは) [単語記事] - ニコニコ大百科
//レジスタの初期値 (間違ってるかもしれない)
reg = 0x8000;
開始状態が15個のうちどれか1つビットが立っている状態であれば更新するうちに同じ状態になるので、位相がわずかにずれるだけの違いとは言えそう。
0b000_0000_0000_0010 → 0回更新すると 0b000_0000_0000_0010 になる
0b000_0000_0000_0100 → 1回更新すると 0b000_0000_0000_0010 になる
0b100_0000_0000_0000 → 13回更新すると 0b000_0000_0000_0010 になる
0b000_0000_0000_0001 → 14回更新すると 0b000_0000_0000_0010 になる
シフトレジスタのどのビットを出力として使うか
When bit 0 of the shift register is set, the DAC receives 0.
The 1-bit random number output is taken from pin E, is inverted, then is sent to the volume/envelope decay hardware for the noise channel.
シフトレジスタのビット0が1なら、チャンネルの出力は0となります。
とのことなのでビット0 を使いました。
シフトレジスタの更新周期 = P値 + 1
例えば P値 = 4 の場合、タイマー値の変化は
4,3,2,1,0, 4,3,2,1,0, ...
なので周期は 5 = 4 + 1 、という理解でよいはず。
中間データ
中間データの中身は1行目がサンプリングレート(Hz)、2行目以降が波形のデータ(1サンプル/行)です。
実はというか、引数で指定する周期を変えても2行目以降の波形データは毎回同じ内容が出力されます。なんだか無駄な感じもしますが、「周期が変わってもシフトレジスタを使って生成される疑似乱数の変化のパターンが変わるわけではない」という性質をそのまま反映した出力になっていると考えるとこれはこれでいいのかなと。
$ ruby nes_noise.rb 15 | head -20
439.8556714850645
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
-1
1
-1
-1
-1
-1
参考
最初のとっかかりに参考になりました。
VirtuaNesのソースに潜ったら,sampleRateより周波数が高い場合,出力を平均化してwaveバッファに書き込んでるっぽい
P値が 32 以下の場合はサンプリングレート 44,100 Hz 以上になるけどその場合はどうなる問題。今回はここで書かれているように単純に平均化する方式にしました。
関連
Ruby 製のエミュレータ。APU の実装もあります。
この記事を読んだ人は(ひょっとしたら)こちらも読んでいます