前回の投稿ではS98ファイルを一回VGMファイルに変換してymfmの再現度を確認しましたが、せっかくなので直接S98ファイルを読み込んでWAVファイルに変換するものを作りました。

実装する上で、fmgenとの違いも少しずつ分かってきました。

とりあえず結果が知りたい方は

GitHub上で、ymfmをフォークしてs98renderというサンプルコードを追加しました。これをビルドすればOKです。

方針

前回利用させていただいたvgmrenderというプログラムは、ymfmの利用サンプルとして同梱されているものです。

中を読んでみると、様々なチップに対応するために綺麗に構造化されています。正直、S98を再生させるのであればこれをいじる(というか、不要な部分を取り除くのが大半)だけで大丈夫そうです。

S98ファイルの操作に関しては、S98Player for iPhoneのコードを使い回すというのでもいいのですが、Objective-Cで書かれているため取り回しが少々面倒ということもあり、C++で基本部分だけ書き起こしてみることにします(C++なんてずっと書いてないので恥ずかしいですが)。

S98ファイルの読み込み

S98ファイルのフォーマットはRu^3 Honpoさんのところにありますので、これをベースにs98file.hppを書いていきます。

typedef struct {
    uint32_t type;
    uint32_t clock;
    uint32_t pan;
    char reserved[4];
} DeviceInfo;

typedef struct {
    char magic[3];char format;
    uint32_t timer1;
    uint32_t timer2;
    uint32_t compress;
    uint32_t nameptr;
    uint32_t dataptr;
    uint32_t loopptr;
    uint32_t devicecount;
    DeviceInfo deviceInfo[];
} S98Header;

class S98File {
private:
    void extractHeader();
public:
    S98File();
    ~S98File();

    bool setFilePath(const char* filepath);

    S98Header* header;
    uint8_t* data;
    uint32_t filesize;
    string songName;
    string gameName;
    string artistName;


    enum DeviceType {
        TYPE_NONE   = 0,
        TYPE_PSG    = 1,
        TYPE_OPN    = 2,
        TYPE_OPN2   = 3,
        TYPE_OPNA   = 4,
        TYPE_OPM    = 5,
        TYPE_OPLL   = 6,
        TYPE_OPL    = 7,
        TYPE_OPL2   = 8,
        TYPE_OPL3   = 9,
        TYPE_DCSG   = 16
    };
};

はい。

あとはまぁ、これにしたがってファイルを読み込んでいけばOKです。S98v1で一部の値が抜けてたりするのもありますが、ひとまず音が出るところ以外は放置。

曲名とかのメタデータも本当はきちんと受け取れるようにすべきですが、まずはUTF-8に変換して標準出力にベタっと書き出すところまでとりあえず実装。

S98のデータをymfmに流し込む

S98ファイルの(ヘッダではなくて)データ部分は、OPNAなどのチップに対して書き込む番地と値がベッタリと書かれています。これをymfmに書き込んでいくわけですが、これはs98render.cpp内で実施しています。

int nextdata(S98File& file, unsigned int& pointer){
    unsigned char* p;
    int n, s, i;
    p = file.data + pointer;
    if(pointer >= file.filesize){
        //filesize error
        return -2;
    }

    switch(p[0]){
        case 0x00:
            write_chip(CHIP_YM2608, 0, p[1], p[2]);
            pointer += 3;
            break;
        case 0x01:
            write_chip(CHIP_YM2608, 0, p[1] | 0x100, p[2]);
            pointer += 3;
            break;
        case 0xff:
            ++pointer;
            return 1;
        case 0xfe:
            n = s = 0; i = 0;
            do{
                ++i;
                n |= (p[i] & 0x7f) << s;
                s += 7;
            } while(p[i] & 0x80);
            n += 2;
            pointer += i + 1;
            
            return n;
        case 0xfd:
            ++pointer;
            return -1;
        default:
            ++pointer;
            return -1;
    }
    
    return 0;
}

こんな感じで、write_chip(CHIP_YM2608, 0, p[1], p[2]);のように値を書き込んでいきます。ループにも対応させたはずです。

その後、S98ファイルのsyncタイミングでWAV波形を取得します。

int delay = ret * output_rate * file.header->timer1 / file.header->timer2;
while (delay-- != 0)
{
    int32_t outputs[2] = { 0 };
    for (auto chip : active_chips)
        chip->generate(output_pos, output_step, outputs);
    output_pos += output_step;
    wav_buffer.push_back(outputs[0]);
    wav_buffer.push_back(outputs[1]);
}

ここでのdelay変数は、実際にWAVとして書き出すサンプル数です。S98のsyncの単位は timer1 / timer2 秒になるので、syncの回数であるret * WAVファイルの周波数(例えば44100)* timer1/timer2 で実際のサンプル数が求められます。

fmgenの場合はサンプル数を与えるとその分一気に波形を書き出してくれるのですが、ymfmは1サンプルごとにgenerate関数を呼ぶということになるようです。

SSGの音量調整

前回懸案となっていたSSGの音量調整ですが、きちんとできる場所がありました。

OPNAの場合、ymfmにおける音声の出力は3種類です。

  • FM + ADPCMのL側
  • FM + ADPCMのR側
  • SSG

これを最終的にWAVにするときに2chにしているのがこの部分です。

else if (m_type == CHIP_YM2608 || m_type == CHIP_YM2610)
{
	int32_t out0 = m_output.data[0];
	int32_t out1 = m_output.data[1 % ChipType::OUTPUTS];
	int32_t out2 = m_output.data[2 % ChipType::OUTPUTS];
	*buffer++ += out0 + out2 * ssgvol;
	*buffer++ += out1 + out2 * ssgvol;
}

out2がSSGですね。元々のvgmrenderではssgvolを掛けてなかったのですが、今回調整できるようにしています。これで一件落着です。fmgenのようにdbで設定してもいいのですが、今回は直接比率を入力してもらうことに(前回は4で割っていたので、0.25にすると同じ値になる)。

使い方

READMEにも記載しましたが、まずはビルドします。iconvをリンクしているので、Windowsでやろうとするともしかしたら工夫が必要かも?

clang++ --std=c++17 -liconv -I../../src s98render.cpp s98file.cpp ../../src/ymfm_misc.cpp ../../src/ymfm_opl.cpp ../../src/ymfm_opm.cpp ../../src/ymfm_opn.cpp ../../src/ymfm_adpcm.cpp ../../src/ymfm_pcm.cpp ../../src/ymfm_ssg.cpp -o s98render

あとは、以下のコマンドを実行します。

s98render <inputfile> -o <outputfile> [-v <ssg volume ratio>] [-l <loop count>] [-r <rate>]

rateは44100Hzがデフォルトになっています。このままでも破綻なく音が鳴るのは、ymfm側で処理が入っているからかもしれません。ただ、55466Hzを指定した方が上手くいくような曲もありました。特に高音かつLFOをかけてるようなものです。
ssgのボリュームは1がデフォルトになっているので、PC-98関係のサウンドログの場合には0.25くらいにしてみてください。

概ね正しく鳴っていると思うのですが、ADPCMが含まれているS98ファイルが正しく再生できないようです。これが自分のコードのせいなのかymfmの制限なのかはまだ分かっていません。もう少し探ってみます。

まとめ

ymfmでS98ファイルからWAVを出力するプログラムをvgmrenderをベースに作ってみました。

実装する上でわかったfmgenとの大きな違いは、

  • ymfmは1サンプルごと音を生成する
  • ymfmのYM2608は出力が3系統(FM+ADPCMのL、FM+ADPCMのR、SSG)なので、それを2chに混ぜるときに音量を調節できる。音量調整用のAPIはymfm本体側には用意されていない
  • ymfmではfmgenでお馴染みのリズム用WAVデータは不要なものの、代わりに実機のダンプデータ(ym2608_adpcm_rom.bin)がないと正しくリズム音が鳴らない

あたりでしょうか。

ADPCM周りをなんとかしたいところですが、それができればS98Player for iPhoneへの組み込みもできそうです。ただ、PMDの再生は現状PMDWinに頼りきっているため、まずはこいつをfmgenから切り離す作業をしないといけなそうです。

Mac(Big Sur)でビルドしたものをこちらに置いておきます。署名とかしてないのでセキュリティで怒られるかもしれませんが。