前回の投稿でymfmを使ったs98playerを作りましたが、以下の部分が上手くいっていなかったので修正しました。

  • ADPCM音源が上手く再生できていなかった
  • S98V3の複数音源に対応していなかった

例によってフォークしたymfmリポジトリのサンプル部分を更新しています。

以下は、今回の修正で得た学びなど。今後実際にymfmを使ったプログラムを作るときに役に立つはず。

ADPCMの取り扱い

YM2608という音源クラスがあるのでADPCMのデータも書き込めるだろうと思っていたのですが、違いました。

ymfmは、それぞれの音源部分と外側のプログラムとの界面を繋ぐymfm_interfaceというクラスがあります。音源からのコールバックを受け取って追加処理をしたりする役割があります。メソッドは以下のような感じです。

// timing and synchronization
virtual void ymfm_sync_mode_write(uint8_t data) { m_engine->engine_mode_write(data); }
virtual void ymfm_sync_check_interrupts() { m_engine->engine_check_interrupts(); }
virtual void ymfm_set_timer(uint32_t tnum, int32_t duration_in_clocks) { }
virtual void ymfm_set_busy_end(uint32_t clocks) { }
virtual bool ymfm_is_busy() { return false; }

// I/O functions
virtual void ymfm_update_irq(bool asserted) { }
virtual uint8_t ymfm_external_read(access_class type, uint32_t address) { return 0; }
virtual void ymfm_external_write(access_class type, uint32_t address, uint8_t data) { }

s98renderの元にしたvgmrenderでは、vgm_chipクラスがこのymfm_interfaceを継承しているのですが、追加で実装しているのはymfm_external_readのみです。

vgmrenderのymfm_external_readの動き

実際にどういったことをやっているかと言えば

// handle a read from the buffer
virtual uint8_t ymfm_external_read(ymfm::access_class type, uint32_t offset) override
{
	auto &data = m_data[type];
	return (offset < data.size()) ? data[offset] : 0;
}

std::vectorのバイト列から必要なデータを受け取っている形です。

ちなみに、このymfm::access_classというのは

// external I/O access classes
enum access_class : uint32_t
{
	ACCESS_IO = 0,
	ACCESS_ADPCM_A,
	ACCESS_ADPCM_B,
	ACCESS_PCM,
	ACCESS_CLASSES
};

こんな感じに、どのタイプのデータ領域にアクセスするかを指定するための列挙子になります。

音源側がこのymfm_external_readを叩くことを期待しているので、リズム音源(ADPCM_A)やADPCM音源のサンプリングデータを読み込むときに使いそうです。ということで探してみると、ymfm_adpcm.cppで呼んでますね。

bool adpcm_a_channel::clock()
{

...

	// if we're about to read nibble 0, fetch the data
	uint8_t data;
	if (m_curnibble == 0)
	{
		// stop when we hit the end address; apparently only low 20 bits are used for
		// comparison on the YM2610: this affects sample playback in some games, for
		// example twinspri character select screen music will skip some samples if
		// this is not correct
		//
		// note also: end address is inclusive, so wait until we are about to fetch
		// the sample just after the end before stopping; this is needed for nitd's
		// jump sound, for example
		uint32_t end = (m_regs.ch_end(m_choffs) + 1) << m_address_shift;
		if (((m_curaddress ^ end) & 0xfffff) == 0)
		{
			m_playing = m_accumulator = 0;
			return true;
		}

		m_curbyte = m_owner.intf().ymfm_external_read(ACCESS_ADPCM_A, m_curaddress++);

...

同期をとるタイミングで読み込んでます。

さて、ymfm_external_readという形でデータ領域へのアクセスを音源の外側に出している理由ですが、このデータ領域をプログラム側で色々いじることを想定しているようです。今回の場合は、YM2608のリズム音源データの読み込みです。

vgmrenderでは以下のようになっています

if (type == CHIP_YM2608)
{
	FILE *rom = fopen("ym2608_adpcm_rom.bin", "rb");
	if (rom == nullptr)
		fprintf(stderr, "Warning: YM2608 enabled but ym2608_adpcm_rom.bin not found\n");
	else
	{
		fseek(rom, 0, SEEK_END);
		uint32_t size = ftell(rom);
		fseek(rom, 0, SEEK_SET);
		std::vector<uint8_t> temp(size);
		fread(&temp[0], 1, size, rom);
		fclose(rom);
		for (auto chip : active_chips)
			if (chip->type() == type)
				chip->write_data(ymfm::ACCESS_ADPCM_A, 0, size, &temp[0]);
	}
}

ym2608_adpcm_rom.binをバイナリで読み込み、chip->write_dataに渡しています。そちらで何をやっているかと言えば、

void write_data(ymfm::access_class type, uint32_t base, uint32_t length, uint8_t const *src)
{
	uint32_t end = base + length;
	if (end > m_data[type].size())
		m_data[type].resize(end);
	memcpy(&m_data[type][base], src, length);
}

vectorにデータをコピーしています。

追っていけばなぁんだですが、音源内部にこれらのデータ領域が確保されているわけではない、というところがfmgenと異なる点です。なので、音源側にadpcmのromデータを流し込むわけではなく、あくまでymfm_interface側での作業というのがポイントです。

S98ファイル内にあるADPCMデータの書き込み

ここまで分かってくると、S98ファイル内にあるADPCMデータもymfm_interface側で用意したデータ領域に書き込むんだろうなということが見えてきます。ただ、ym2608_adpcm_rom.binと違うのは、アプリケーション側で事前にデータ領域に書き込むのではなく、音源側のレジスタに書き込んだ結果としてデータ領域に書き込まれる、という流れになる点です。

具体的に流れを追っていくと、以下のような形になります。

  1. YM2608に対して、アドレス0x108にデータを書き込む(ADPCMデータの書き込み命令)
  2. 拡張領域なので、ym2608::write_data_hiが呼ばれる
  3. 0x100 – 0x110まではADPCM関係の命令なので、adpcm_b_engine::writeが呼ばれ、その後adpcm_b_channel::writeが呼ばれる
  4. 番地が0x108なので、 m_owner.intf().ymfm_external_writeが呼ばれる

予想通り、ymfm_external_writeが呼ばれるようです。なので、ADPCMデータを書き込めるようにymfm_external_writeを実装すれば良いということになります。

今回は、シンプルに、

virtual void ymfm_external_write(ymfm::access_class type, uint32_t address, uint8_t data) override
{
	write_data(type, address, 1, &data);
}

としています。write_dataは、上記の通り元々vgmrenderに用意されている関数なのでそれを使うことにしました。

これによってS98ファイル内に記録されているADPCMデータをymfm側に正しく伝えられるようになりました。

複数音源への対応

これは完全に自分がサボっていた部分です。

generate_allの中で、s98のヘッダを見て音源タイプ別に音源を作成し、vectorに詰め込んでいます。その後s98のデータ部の音源のindexに合わせてデータを書き込み、音源ごとに波形を生成してまとめています。

S98Player for iPhoneはCoreAudioのノードをそれぞれの音源に割り当て、ミキサーノードでミックスするという実装にしていました。今回の実装はvgmrenderをベースに小修正を繰り返していますが、そろそろきちんとした設計で起こさないといけないかもしれません(元々のvgmrenderはindexで呼び出すのではなく、listの中から音源名で引っ張ってくる形になっており、同じチップも2台までという制約もあるようでした)。

まとめ

前回のまとめに引き続き、今回のfmgenとの違い及び実装の方針について分かったことは以下のような感じでしょうか。

  • 音源部分と外側のプログラムとの界面を繋ぐymfm_interfaceというクラスを実装する
  • ADPCM周りはymfm_external_read、ymfm_external_writeを実装することで実現できる

今後は、PMDWinでymfmを使った再生をできるようにトライし、その後S98Player for iPhoneへの移植を試みます。

PMDWinに関しては、opnawというfmgenのopnaを継承したクラスがあるので、同じインタフェースでymfmを呼び出すようにラップしてやれば良いはずです。さてどうなることやら…。