1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ファミコンのノイズ音を作ってみた(最低限の部分だけ)

Last updated at Posted at 2025-09-07

ファミコン(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 ... 出力ファイル名はとりあえず決め打ち

これだけでも昔さんざん聞いたあのノイズ音を鳴らすことができてたいへん満足感がありました。「ウォーーーーー :raised_hands: :raised_hands: :raised_hands: :raised_hands: これだよ! この音! いい音……」ってなります。


練習用のお題として手頃だったので Rust と Elixir でも書いてみました(中間データを生成する部分だけ)。

  • Rust (Rust v1.86.0)
  • Elixir (Elixir v1.18.4-otp-27)

Elixir は <<_::13, b1::1, b0::1>> = shift_register のようにビットの並び(bitstring)に対してパターンマッチできておもしろいですね。

検証

何が正解か分からないと正しく作れたか判断できません。そこで、まずは検証のために比較対象を用意しました。

  • ノイズ音を出す最低限のプログラム(アセンブリコード)を書く
  • nesasm を使って nes ファイルを生成
  • エミュレータ FCEUX で実行し、音を出す

こうして 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 で実行して録音したもの、下が自分で作ったもの。

image.png

よさそうですね。high/low の切り替わりのタイミングが一致しています。

FCEUX の方はおそらく実機に近い波形になっていて、この歪んだ形をどうやって作っているのかもそのうち調べてみたいですね。


Audacity の機能で手軽に周波数スペクトルも見れるのでついでに見ておきましょう。

FCEUX
image.png

自分で作ったもの
image.png

目で見て分かる程度には違いがありますが、ピークの位置はだいたい同じ(周波数成分がだいたい同じ)です。大丈夫そう。

ここまで確認できたので今回はこれでよしとしました。

メモ

感想など

  • 仕様だけを見ていきなり実装するのは大変

    • 何がどうなってると正しいのか分からない
    • 仕様だけ見てもどのパーツがどういう役割でどう組み合わさっているのかが最初は分からない
      • 「タイマーがどうこうって書いてあるけどそもそもタイマーって何ですか?」みたいな
    • なので、(1) 比較対象となる正解データを用意する (2) 読みやすい既存実装を参考にする のが効率よさそう
  • 資料によって揺れがある

    • 入門者としてはモヤモヤしますね
  • 一定の周期で循環する疑似乱数なので、メモリの制限がない場合などは(律儀にシフトレジスタを操作せずに)あらかじめ生成しておいた波形データを使いまわす方式にすると実装簡素化+負荷低減できたりするかも

CPUのクロック周波数

  • 資料によって揺れがある

CPU - NESdev Wiki

1.789773 MHz

FC音源とは (ファミコンオンゲンとは) [単語記事] - ニコニコ大百科

CPUクロック(1789772.5)

TASEmulators/fceux

1789772.727272727272

Ricoh 2A03 - Wikipedia

ファミリーコンピュータと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)という呼び方も見かけた。ちょっとかっこいい(?)。

参考: 線形帰還シフトレジスタ - Wikipedia

シフトレジスタの初期状態

資料によってまちまち。今回は 0b000_0000_0000_0001 にしました。

www.nesdev.org/apu_ref.txt

On power-up, the shift register is loaded with the value 1.

www.nesdev.org/NESSOUND.txt

On system reset, this shift register is loaded with a value of 1.

NES on FPGA APU

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 になる

シフトレジスタのどのビットを出力として使うか

www.nesdev.org/apu_ref.txt

When bit 0 of the shift register is set, the DAC receives 0.

www.nesdev.org/NESSOUND.txt

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.

NES on FPGA APU

シフトレジスタのビット0が1なら、チャンネルの出力は0となります。

とのことなのでビット0 を使いました。

シフトレジスタの更新周期 = P値 + 1

例えば P値 = 4 の場合、タイマー値の変化は

4,3,2,1,0, 4,3,2,1,0, ...

なので周期は 5 = 4 + 1 、という理解でよいはず。

参考: APU - NESdev Wiki

中間データ

中間データの中身は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

参考

最初のとっかかりに参考になりました。 :pray:


VirtuaNesのソースに潜ったら,sampleRateより周波数が高い場合,出力を平均化してwaveバッファに書き込んでるっぽい

P値が 32 以下の場合はサンプリングレート 44,100 Hz 以上になるけどその場合はどうなる問題。今回はここで書かれているように単純に平均化する方式にしました。


関連


Ruby 製のエミュレータ。APU の実装もあります。

この記事を読んだ人は(ひょっとしたら)こちらも読んでいます

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?