ファミコンのDPCMで再生した音をシミュレートする

ファミコンの音源には、サンプリング音声を再生するDPCMチャンネルがあります。 用意した音声ファイルをサンプリングしてDPCMチャンネルから再生したときに、どのような音になるかをシミュレートするpythonスクリプトを書きました。

DPCMチャンネルの仕様は、こちらの記事を参考にしました。
FC音源とは (ファミコンオンゲンとは) [単語記事] - ニコニコ大百科

動作確認は、Macpython 2.7.6 (homebrew)で行いました。ライブラリにnumpyを使っています。

スクリプト

dpcm.py

# coding: UTF-8

# 入力の音声ファイルから、
# あたかもRP2A03のDPCMで再生したような音声ファイルを作る

import wave
import sys
import struct
import numpy as np
import math

# 音声データをwavファイルとして保存する
# 音声データは、モノラル, 量子化ビット数16とする。
# @param inData 音声データ
# @param inSampFreq サンプリング周波数[Hz]
# @param inFileName 出力するファイルのファイル名
def writeWav(inData, inSampFreq, inFileName):
    data = struct.pack("h" * len(inData), *inData)
    wf = wave.open(inFileName, "w")
    wf.setnchannels(1)
    wf.setsampwidth(2)
    wf.setframerate(inSampFreq)
    wf.writeframes(data)
    wf.close()

# 音声ファイルを読み込む
# 非圧縮, 1サンプル2バイト(量子化ビット数16)のwavファイルを読み込む。
# チャンネル数は1または2。チャンネル数が2の場合には、Lチャンネルのみを読み込む。
# @param inFileName 音声ファイル名(.wav)
# @return [音声データ, サンプリング周波数[Hz]] 
def readWav(inFileName):

    # 音声ファイルを開く
    wr = wave.open(inFileName, "rb")

    # 音声ファイルの検査
    # 1サンプルのバイト数は2であること
    if wr.getsampwidth() != 2:
        print " error : size per one sample must be 2."
        wr.close()
        usage()
        quit()
    # 非圧縮であること
    if wr.getcomptype() != "NONE":
        print " error : input file must be uncompresed format."
        wr.close()
        usage()
        quit()
    # チャンネル数は1か2であること
    channels = wr.getnchannels()
    if channels != 1  and channels != 2 :
        print " error : channel number must be 1 or 2."
        wr.close()
        usage()
        quit()

    # 読み込む
    sampFreq = wr.getframerate()
    data = wr.readframes(wr.getnframes())
    numData = np.frombuffer(data, dtype=np.int16)
    wr.close()

    # 読み込んだデータをintのリストに付け替える
    i=0
    out = []
    while i<len(numData):
        out.append(int(np.asscalar(np.float64(numData[i]))))
        # 音声ファイルが2チャンネルならLだけを取る
        i += channels

    return [out, sampFreq]

# DPCMチャンネルで再生した音を作る
# 参考URL: http://dic.nicovideo.jp/a/fc音源
# ロジック:
# 1. 圧縮後サンプル値を作る
#   1個目のサンプル
#     元のサンプル値(16 bit)を、7 bitに圧縮する
#   2サンプル以降:
#     当該サンプル値(16 bit)を、7 bitに圧縮する
#     直前の圧縮後サンプル値(7 bit)と比較し、
#     ・当該サンプルのほうが大きければ、直前の圧縮後サンプル値+1を追加する
#     ・当該サンプルのほうが小さいまたは等しければ、直前の圧縮後サンプル値-1を追加する
# 2. 音声に変換する
#   サンプル値の下位1 bitを0にする
#   (デルタ初期ボリュームは7bit長だが、再生時は下位1bitを無視した64段階で
#    ボリューム変更される)
#   そのあと、9 bit(=16-9)だけ左シフトし、量子化ビット数を16に戻す。
# @param inData 入力となる音声波形
# @param inSampFreq 入力音声のサンプリング周波数 [Hz]
# @param inRegId DPCM化後のサンプリング周波数を決める値
def convDpcm(inData, inSampFreq, inRegId):

    # DPCM再生のサンプリング周波数を決める
    # 指定された0-15の値に対応する値をテーブルから引く
    # サンプリング周波数[Hz] = 1789772.5 / テーブルから引いた値
    if inRegId < 0 or inRegId > 15:
        print " error : number for determining sampling frequency must be from 0 to 15."
        usage()
        quit()
    dpcmFreqTable = [428,380,340,320,286,254,226,214,190,160,142,128,106,85,72,54]
    dpcmFreq = 1789772.5 / dpcmFreqTable[inRegId]
    print " DPCM-converting sampling frequency[Hz]:", dpcmFreq

    # 音声の総時間
    totalTime = float(len(inData)) / inSampFreq
    
    # 初期値の設定
    # 注目サンプルの時刻
    t = 0.0
    # 直前サンプルの圧縮後の値
    prev = 0.0
    # 出力
    out = []

    while t < totalTime:

        # 注目サンプルの値を取る
        sample = inData[int(math.floor(t * inSampFreq))]

        if t <= 0.0:
            # 第1サンプルのとき
            # 音を7 bitに圧縮する
            # pythonは算術シフトなので、普通に9 bit(=16-7)だけ右シフトさせればよい
            val = sample >> 9
        else:
            # 第2サンプル以降
            # 当該サンプルを圧縮する
            tmpVal = sample >> 9
            # 圧縮後の当該サンプルが直前のサンプルより大きければ、直前の圧縮後サンプルの+1にする。
            # 以下ならば-1する。
            if tmpVal > prev:
                val = prev+1
            else:
                val = prev-1

        # 圧縮後サンプル値が範囲を出ていれば、範囲内に収める
        # 範囲は、-64〜63 (7 bitの符号付き整数)
        if val > 63:
            val = 63
        elif val < -64:
            val = -64

        # 出力のリストに登録
        out.append(val)
        # 当該圧縮後サンプル値は次につかうので、保存
        prev = val

        # 時刻を更新
        t += 1.0 / dpcmFreq

    # 圧縮後サンプル値を、量子化ビット16 bitの波形に戻す
    # 圧縮後サンプル値は7 bitだが、下位1bitはボリュームは再生時のボリュームには反映されない(無視される)ため、
    # 下位1 bitを削除した後に、16 bitに戻す
    for i in xrange(len(out)):
        out[i] = (out[i] >> 1) << 10

    # 出力を返す
    return [out, dpcmFreq]

# usage
def usage():

    print "usage: python dpcm.py (input file) (output file) [options]"
    print " This script converts input speech so as to be played from NES DPCM channel."
    print "  (input file) : input wav file name."
    print "  (output file) : output wav file name."
    print "  [options]"
    print "   -ds (number) : specify DPCM sampling frequency."
    print "     The specified number must be in the range from 0 to 15 [defalut=15]."
    print "     Each number indicates a stored value to a register that determines"
    print "     DPCM sampling frequency."
    print "     The relationship between the specified numbers and the sampling"
    print "     frequencies are as followings:"
    print "       (number) (frequency[Hz])"
    print "             0            4182"
    print "             1            4710"
    print "             2            5264"
    print "             3            5593"
    print "             4            6258"
    print "             5            7046"
    print "             6            7919"
    print "             7            8363"
    print "             8            9420"
    print "             9           11186"
    print "            10           12604"
    print "            11           13983"
    print "            12           16885"
    print "            13           21056"
    print "            14           24858"
    print "            15           33144"


# メインルーチン
if __name__ == "__main__" :

    # 引数を取る
    if len(sys.argv) < 3:
        print " error : less args."
        usage()
        quit()
    
    # 入力ファイル名を受け取る
    inFileName = sys.argv[1]
    # 出力ファイル名を受け取る
    outFileName = sys.argv[2]

    # DPCMのサンプリングレートを決める値を受け取る
    # (デフォルト値)
    dpcmFlag = 15
    i=3
    while i<len(sys.argv):
        if sys.argv[i] == '-ds':
            i+=1
            if i>=len(sys.argv):
                print " error : invalid args."
                usage()
                quit()
            if not sys.argv[i].isdigit():
                print " error : invalid args."
                usage()
                quit()
            dpcmFlag = int(sys.argv[i])
        else:
            print " error : invalid args."
            usage()
            quit()
        i+=1
        
    # 入力音声ファイルを受け取る
    [data, sampFreq] = readWav(inFileName)

    print " input file:", inFileName
    print " output file:", outFileName
    print " input sampling frequency [Hz]:", sampFreq
    print " speech length [s]:", float(len(data)) / sampFreq

    
    # DPCM変換 → 復元を実行
    [out, dpcmFreq] = convDpcm(data, sampFreq, dpcmFlag)

    # 出力時のサンプリング周波数
    # wavの場合、サンプリング周波数は整数になるので、
    # DPCM変換時の周波数の四捨五入にする
    outSampFreq = round(dpcmFreq)
    print " output sampling frequency[Hz]:", outSampFreq

    # できた音声ファイルを出力する
    writeWav(out, outSampFreq, outFileName)

使い方

用意した音声ファイル(in.wav)から、DPCMチャンネルからの再生をシミュレートした音声ファイル(out.wav)を作るには、以下のコマンドを実行します。

$ python dpcm.py in.wav out.wav

ファミコンDPCMチャンネルでは、再生音のサンプリング周波数を16個のなかから選択できます。再生音のサンプリング周波数は、デフォルト設定でもっとも高い33144Hzになります。再生音のサンプリング周波数を変更するには、-dsオプションで値(0-15)を指定します。この値とサンプリング周波数の対応は、スクリプトのusageメッセージに記載しています。

実際のファミコンでは、再生音のサンプリング周波数は小数になるようです。このスクリプトでは、wavファイルとして出力する関係上、再生時のサンプリング周波数は、整数に四捨五入しています。

例として、ドラム音を使って、DPCMチャンネルから再生した音を作ります。再生音のサンプリング周波数の設定は、デフォルト(33144Hz)です。

元のドラム音 Loop01 by uenewsar on SoundCloud - Hear the world’s sounds

DPCMチャンネルからの再生音に変換した音 Converted by uenewsar on SoundCloud - Hear the world’s sounds

ドラム音には、以下のサイトのものを使わせていただきました。
Music is VFR ~BGM・ジングル・効果音のフリー素材~