0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SONY CDリピーターのディスク解析とエミュレーター開発

Posted at

はじめまして

初めまして、祥太と申します。

本業はプリキュアのファンで、そのかたわらクラウドやインフラ領域でTech PMOなども嗜んでいます。

私自身カトリック信徒なので待降節のアドベントカレンダー(ガチのやつ)には馴染みがありますが、このたび昔から憧れだった技術の方のアドベントカレンダーに参加してみることにしました。

想い出の「CDリピーター」

私が通っていた中高一貫校では、英語の授業に文部省(当時)の検定教科書ではなく、専ら「PROGRESS IN ENGLISH」を使用していました。

IMG_1705.jpeg

これは日本に来日して中高一貫校で教鞭を執っていたイエズス会のロバート・M・フリン神父が開発した英語教材で、オーラル・コグニティブ・アプローチを重視するなど時代を先取りするテキストでした。

私がイエズス会系の中高一貫校に入学したのが1995年。それまで音声教材にオーディオカセットを使用していたプログレスは私の1学年上からSONYの「CDリピーター」という未来的なデバイスを採用し、高速な頭出しや自動リピート、クイズ学習などマルチメディア、インタラクティブならではの学習体験を享受していました。

IMG_1704.jpeg

それから、30年の月日が経ちました。

今や60年の歴史を誇るプログレスも改訂を重ね、音声教材もオーディオカセットからCDリピーター、SDリピーター、そして現在はスマホやタブレット、PCでのアプリへと変遷を遂げています。
https://www.progress21.net/

ふと手元に残された4枚のCDメディアが懐かしくなりこれを再生しようと思ったのですが、人生初の自分専用CD再生機として酷使されたCDリピーターはとっくの昔に壊れてしまって手元にはなく……。

PCで読み込んでみても変なファイルが表示されるだけで再生はできません。

幼き日に勉強した懐かしい英語の音声教材を、もう一度聴いてみたい。そんな願いから一念発起し、Windows上で表示されている「変なファイル」を解析して、CDリピーター実機のエミュレーターを開発してみることにしました。

IMG_1656.jpeg

……まあ今回の開発にあたって実機挙動を確認するためメルカリで買い戻して、実機も手元にあるんですけどね。しかもデッカい教室用まで。

これでもう、聴こうと思えば聴けますよね。めでたしめでたし。

いや、そういうことじゃないんですよ。買い戻した実機もボリュームを中心に劣化が激しく、故障は時間の問題です。ディスクも古いので、安全なストレージ上にしっかりバックアップをとっておきたいところです。

そんなわけで将来のことも考え、保全したデータを再生できるツールも用意しておく必要もあるわけです。

ディスク解析篇

まず手始めに、「PROGRESS IN ENGLISH BOOK1」のディスクを解析してみます。

IMG_1706.jpeg

今から30年前、NEC PC-9821Ceで当時12歳の私が解析を試みて断念したディスクです。同機は当時普及し始めたCD-ROM搭載機のひとつでした。CD-ROMをロードしてWindows3.1のファイルマネージャでファイル構成までは判明したものの、当時の私にはまだ技術的な知識がありません。そこから先には手が出せませんでした。

高校生になると15歳の私はある程度自由自在にプログラミングをすることができるようになりました。様々なファイルのバイナリを解析して、中身を推測することもできるようになっていきました。CDリピーターもその俎上にのぼったのですが、まだインターネット上にも核心的な情報は乏しく。結局、何もできませんでした。

わけのわからない大量のファイルを前になすすべのなかった、1995年。

バイナリを覗く知恵はついたものの技術情報が乏しく太刀打ちできなかった、1998年。

そんな中高時代の無念を晴らすべく、四半世紀の時を超えてリベンジが始まります。

1. CDリピーター用ディスクのフォーマット

まず最初に

ISO Buster
https://www.isobuster.com/jp/

を使って、CDリピーター用ディスクのフォーマットを調べてみます。

image.png

image.png

ディスクタイプは「CD-ROM XA」(Mode2)であることがわかります。

1-1. CD-ROMのフォーマットについて

ここで基礎知識として、CD-ROMの論理フォーマットについて簡単におさらいしておきます。

1-1-1. オーディオCD (CD-DA)

まず普通のオーディオCD (CD-DA)では、音声データを「セクター」という1/75秒ごとの単位に区切って記録しています。

セクター長は量子化16ビット×標本化44,100Hz×ステレオ2ch×1/75秒で下記のように計算でき、2352バイトになります。

$$
\mathrm{Len}(Sector) = 16_{\mathrm{[bits]}} \times 44,100_{\mathrm{[Hz]}} \times 2_{\mathrm{[ch]}} \times \frac{1}{75}_{\mathrm{[sec]}} = 18,816_{\mathrm{[bits]}} = 2,352_{\mathrm{[bytes]}}
$$

1-1-2. CD-ROM (Mode1)

通常のCD-ROM (Mode1)ではこのセクターにデータを記録するにあたり、信頼性向上のため一部の領域を次の用途で使っています。

  • 同期信号…12バイト
  • ヘッダ…4バイト
    • ブロックアドレス(分、秒、フレーム)…3バイト
    • モード…1バイト
  • 誤り検出符号 (EDC)、誤り訂正符号 (ECC)…288バイト

これらは合計304バイトになります。

$$
12_{\mathrm{[bytes]}} + 4_{\mathrm{[bytes]}} + 288_{\mathrm{[bytes]}} = 304_{\mathrm{[bytes]}}
$$

この304バイトをセクタ全2352バイトから引いたら、残りのユーザーデータ領域は2048バイト。2KiB(キビバイト)で、とてもキリの良い数です。

$$
2,352_{\mathrm{[bytes]}} - 304_{\mathrm{[bytes]}} = 2,048_{\mathrm{[bytes]}} = 2_{\mathrm{[KiB]}}
$$

もともとCDオーディオ自体にも誤り訂正の機能はありますが、多少の補間や音飛びがあっても違和感が少ない音楽と異なり、データ用途では1ビットの誤りも許容されません。そのため全体の12%程度の容量を誤り訂正符号に割いています。

1-1-3. CD-ROM XA (Mode2 Form1/2)

通常のCD-ROM Mode1に対して、Mode2も存在します。こちらは主に音楽や動画などマルチメディア用途を想定し、多少のエラー許容した上で信頼性よりも容量を重視しています。

具体的には1セクター2352バイトのうち同期信号+ヘッダの16バイトのみを管理領域に使い、残った2336バイトをすべてユーザーデータとして使用できます。システムの取り分は、わずか0.7%です。

$$
2,352_{\mathrm{[bytes]}} - 12_{\mathrm{[bytes]}} - 4_{\mathrm{[bytes]}} = 2,336_{\mathrm{[bytes]}}
$$

しかしMode2は普及しませんでした。これは信頼性が低かったことに加えて、Mode1とMode2の混在がCD-ROMの規格上認められていなかったことが仇となったものです。

そこでMode2の拡張としてCD-ROM XAという規格が登場しました。Mode2で使用できるユーザーデータ領域2336バイトのうち、

  • サブヘッダ 8バイト
  • 誤り検出符号 (EDC) 4バイト
  • 誤り訂正符号 (ECC) 276バイト ※Form1のみ

を拡張することで、ユーザーデータ容量は

  • ユーザーデータは2048バイトだけど信頼性の高いForm1
    $$
    Form1: 2,336_{\mathrm{[bytes]}} - 8_{\mathrm{[bytes]}} - 4_{\mathrm{[bytes]}} - 276_{\mathrm{[bytes]}} = 2,048_{\mathrm{[bytes]}}
    $$

  • エラーには弱いけど容量が2324バイト取れるForm2
    $$
    Form2: 2,336_{\mathrm{[bytes]}} - 8_{\mathrm{[bytes]}} - 4_{\mathrm{[bytes]}} = 2324_{\mathrm{[bytes]}}
    $$
    を、ディスク上に混在させることができるようになりました。

なおCDフォーマットについては、こちらで詳しく解説されています。ご参照ください。
https://jp.fujitsu.com/family/familyroom/syuppan/family/webs/serial-comp2/index10.html

1-2. CDリピーター用ディスクの構成

1-2-1. トラック1

トラック1はセクタ構造がCD-ROM Mode2で、ファイルシステムがISO 9660で定義されています。

そのためCDリピーター対応ディスクをWindows環境で読み込むと、ディスク上のファイル構造がWindows上でもそのまま再現されることがわかります。

1-2-2. トラック2

トラック2は、純粋なオーディオトラックです。

本ディスクがCDリピーター用ディスクであることを伝え、ノイズで故障のリスクがあるためCDプレーヤーで再生しないよう警告する音声になっています。


したがって、このディスクのデータはWindowsファイルシステム上でそのまま扱うことができるようです。

以下では、ディスクの論理構造に言及する必要がある場合を除き、基本的にWindowsシステムから「見えている」ファイルを対象として解析を続けます。

わざわざ「見えている」にカギ括弧をつけたのには、理由があります。詳しくは後述しますが、必ずしも「見えている」イコール「そこにそのまま在る」というわけではないからです。

2. ファイル構成

ISO Busterで、ISO 9660フォーマットで記述されたディレクトリを確認してみましょう。

Windowsのエクスプローラーでも同様の表示になりますが、各ファイルの開始セクターと終了セクターを厳密に知りたいのでここでもISO Busterを使用します。

image.png
image.png

CDリピーター用ディスクは、下記のようなファイル構成になっていることがわかります。

  • CHAP.TBL … チャプターのインデックス
  • C{000}.TBL … 各チャプターのメタ情報を記録したファイル
  • SOUND.RTF … 音声データを記録したファイル
  • WARNING. … 警告音声(Windows上ではファイル名の制約で「WARNING」)

それぞれ、下記のツールでバイナリを読んで解析してみます。

Binary Editor BZ
https://www.vcraft.jp/

2-1. CHAP.TBL

ディスク上で最も最初に記録されているファイルです。CHAPはチャプター、TBLはおそらくテーブルでしょう。となれば、各チャプターの目次のような役割を果たしていることが容易に想像されます。

image.png

さっそくバイナリを読んでみましょう。

0x00–0x03 "CHAT"

これは「チャプターテーブル」を表すシグネチャでしょう。

0x04–0x07 00 00 00 00

ブランクとして00が4つ入っています。

0x08–0x09 00 00

こちらもブランクとして00が2つ入っています。

0x0A–0x0B 03 78

この2バイトをBE16で10進に変換すると「888」となり、チャプター数であることがわかります。
0x08–0x0BでBE32として解釈することも可能ですが、チャプター番号は実機の仕様上1~999であることがわかっているため、チャプター数として実際に使用されるのは2バイトになります。ゆえに、このような解釈としています。

0x0C–0x0F 00 00 34 00

ここから、4バイト×888=3552バイトのゾーンが続きます。

4バイトごとに分析すると、

  • 1,2バイト目 … 00 00
  • 3バイト目 … 3445まで10進(BCD)で増加
  • 4バイト目 … 0074まで10進(BCD)で増加、74までいくと00に戻り3バイト目が1つ増加

という形になっています。

75といえば、前述したCD-ROMフォーマットでの、1秒あたりのセクタの数です。
そこで先頭チャプターと思われるC001.TBLのセクター、LBA:2400を確認してみましょう。

image.png

image.png

ヘッダ領域にしっかり00 34 00、すなわち00分34秒00フレームを表すMSF (Minute-Second-Frame)が入っていました。

というわけで、CHAP.TBLに格納されているのはヘッダと、各チャプターテーブルのMSFということになります。

なおLBAとMSFの関係は、次の式で表せます。
$$
\mathrm{LBA} = (60 M + S - 2) \times 75 + F
$$
ちなみに秒数から引かれている定数「2」は、CDのフォーマット構造でいう「リードイン領域」2秒分の尺です。

CHAP.TBLフォーマットまとめ

オフセット範囲 サイズ 内容 説明
0x00–0x03 4バイト "CHAT" チャプターテーブルのシグネチャ
0x04–0x07 4バイト 00 00 00 00 ブランク
0x08–0x09 2バイト 00 00 ブランク
0x0A–0x0B 2バイト 例: 03 78 チャプター数(BE16)
0x0C– 4バイト チャプターインデックスブロックアドレス 00 + 各チャプターの先頭MSF(分:秒:フレーム)を、チャプター数だけ繰り返し

2-2. C{000}.TBL

チャプターのインデックスと思われるファイルです。サイズは最大でも1120バイトで、このディスクではいずれも1セクターに収まっています。

いくつかのファイルを見てみましょう。

2-2-1. C888.TBL

最後のチャプターです。最も短いチャプターのひとつです。
最初に説明があり、インデックスをまたいで発音練習本体があります。

image.png

0x00-0x03 "TRCK"

おそらくトラックを意味するシグネチャでしょう。CDリピーターでは一貫してこの単位は「チャプター」と呼称されていますが、内部ではトラックとも呼ぶのかも知れません。

0x04–0x07 00 00 00 00

ブランクとして00が4つ入っています。

0x08–0x09 00 34

BE16で10進化すると52。このトラックの全体長、52バイトと一致します。

0x0A–0x0B 00 01

全チャプター共通で00 01です。ここは後述する「ファイル番号」になります。

0x0C 00

チャプターごとに00から0Fまでの値を取ります。これは後述する「チャンネル番号」です。

0x0D 02

チャプターごとに02から56(10進で86)までの値を取り、チャプターのインデックス数に一致します。

0x0E–0x0F 00 10

BE16で10進化すると16。このトラックのヘッダー長、16バイトと一致します。

0x10-0x13 "INDX"

これはインデックスを表すシグネチャと思われます。

0x14–0x17 00 00 00 00

トラックヘッダー同様にブランクも4つ入っています。

0x18–0x19 00 24

BE16で10進化すると36。このトラックのINDX以降のデータ長、36バイトと一致します。

0x1A 00

ここは全チャプター共通で00です。

0x1B 02

チャプターごとに02から56(10進で86)までの値を取り、チャプターのインデックス数に一致します。全チャプターで必ず0x0Dと同一です。

0x1C-1F 11 00 00 00

チャプターによって、次のような値がインデックスの数だけ出現します。

  • 11 00 00 00
  • 01 00 00 00
  • 80 00 00 00

このチャプターでは11 00 00 0001 00 00 00が出現しています。

0x24-27 01 00 00 00

2バイト目が0059、3バイト目が0074に収まっており、先頭3バイトはMSFと解釈できます。
4バイト目は通常、00を取ります。

0x28-2B 01 07 51 00

こちらも同様にMSF + 00と解釈できます。

インデックスが2つでMSFと思われるものが4つ。そして、2番目と3番目は必ず16フレームの間隔で空いている。

このデータはおそらく、各インデックスの開始時間と終了時間を記述しているものと思われます。

実際、SOUND.RTFはLBA:4350=01:00:00の領域から記録され、

image.png

そして最後はLBA:218974=48:41:49まで続いています。

image.png

SOUND.RTF上はチャプター番号の大きい方から始まって小さい方に向かってデータが連続しており、実際、C013.TBLでは最大値として最後に48 41 35が入っています。

image.png

2-2-2. C052.TBL

文章を1文ずつ区切って聴いていくチャプターです。全てのインデックスは、再生後自動的に一時停止します。

image.png

0x1C-43 01 00 00 00 × 10

10個あるチャプターに対応する全てが01 00 00 00になっています。
11 00 00 00が次のインデックスを再生するマーカーで、01 00 00 00がそのインデックスを再生後一時停止するマーカーなのでしょうか。以後、このブロックを「インデックス制御子」と呼びます。

2-2-3. C055.TBL

絵を見ながら“This is a XXXX.”という英文を聴いて繰り返すチャプターです。
一周した後はアナウンスが流れ、チャプターの最初に戻り、今度は自分で発音してから聴くように促されます。

image.png

0x4C-4F 80 00 00 00

問題案内の00からラストの12まで13個あるインデックスのうち、大半は一時停止をする「01000000」で、最後だけが80 00 00 00になっています。

しかも、最後のインデックスの開始時刻、終了時刻が入るべき8バイトには、00 00 A8 00 00 00 00 00というおかしなバイト列が入っています。しかもその後には「SUB0」から始まる領域が36バイト続いています。

0xB1-0xB2 00 A8

トラックヘッダーを除いたインデックス領域内での「SUB0」のアドレス「0xA8」をBE16で表しているようです。

0xB8-0xBB "SUB0"

インデックスの中のインデックス、サブインデックスを表すシグネチャのようです。

0xC0-0xC1 00 24

インデックスヘッダに倣って、こちらはサブインデックスチャンクの長さのようです。

0xC2-0xC3 00 02

これもインデックスヘッダに倣って、サブインデックス数のようです。

0xC4-0xC7 21 00 00 00

同じくインデックスヘッダに倣えば、これも制御子のようです。
さしずめ21 00 00 00は、次のサブインデックスに進むマーカーといったところでしょうか。

以降はストップ制御子、サブインデックスの開始・終了位置と既知の情報で読み取れそうな領域です。

2-2-4. C663.TBL

4つの選択肢から下線部の発音が異なるものを数字ボタンで答えるチャプターです。全部で5問あり、最初の説明がインデックス0、設問はインデックス1~5に対応しているようです。

それぞれの設問インデックスには、サブインデックス0が出題、サブインデックス1~4が選択肢に対応しているようです。

image.png

0x3C-0x63

インデックスチャンク内のサブインデックスヘッダの位置を表す8バイトのブロックが5つ連続しています。インデックスの開始・終了位置に相当する領域を一旦全サブインデックス分埋めた後に、サブインデックスの中身を記述する領域が始まる仕組みになっているようです。

0x70-0x73 09 00 00 04

インデックス01-サブインデックス01の制御子です。1バイト目の下位ニブルが9、4バイト目が04になっています。
他の同様のクイズチャプターと比較すると、どうやら1バイト目の下位ニブルの9は出題サブインデックスであることを、4バイト目の04は選択肢の数を表しているようです。

0x74-0x77 03 00 00 00

インデックス01-サブインデックス01の制御子です。1バイト目の下位ニブルが3になっています。
テキストを確認するとこれは誤った選択肢で、1バイト目下位ニブルの3は誤った選択肢を表しているようです。

0x7C-0x7F 15 00 00 00

インデックス01-サブインデックス03の制御子です。1バイト目の下位ニブルが5になっています。
テキストを確認すると今度は正しい選択肢で、1バイト目の下位ニブルの5は正解の選択肢を表しているようです。

となれば、従来見てきた1バイト目下位ニブルが1であるものは「通常のインデックス/サブインデックス」を表しているようですね。

気になるのが1バイト目の上位ニブルの1。これまで見た限りでは0が「一時停止」、1が「次のインデックスへ進む」、2が「次のサブインデックスへ進む」のようです。しかしここはサブインデックス。次のインデックスに進んで…いいに決まってますね。正解の3番から、誤った4番へ進んだらおかしなことになります。進行上は01-03から02-00、すなわち次の問題に進むのが正しい動きです。

0x1A0-0x1A3 05 00 00 00

これも正解の選択肢のサブインデックスですが、1バイト目の上位ニブルが0になっています。
ラストの問題なので、正解してもここで一旦ストップするという動きのようです。

2-2-5. C627.TBL

例文の一部に指定された単語を代入してみて、その後で正解を聴くタイプのチャプターです。

image.png

0x1C8-0x1CB 17 35 47 09

インデックス09-サブインデックス00の開始位置を表すブロックです。
冒頭3バイトはMSFですが、4バイト目が09になっています。ここではどうやら、インデックス番号が入っているようです。すべてのインデックスの先頭サブインデックスで共通しています。

0x1CC-0x1CF 17 37 09 01

インデックス09-サブインデックス00の終了位置を表すブロックです。
今度は4バイト目が01になっています。これもすべてのインデックスの先頭サブインデックスで共通しています。なお、サブインデックス01はいずれも00が入っています。

2-2-6. C{000}.TBLフォーマットまとめ

ここまで得られた洞察をもとに、C{000}.TBLファイルのフォーマットを表にしてみましょう。

C{000}.TBL トラックヘッダー・インデックスチャンクフォーマット
オフセット範囲 サイズ 内容 説明
0x00–0x03 4バイト "TRCK" トラック(チャプター)を表すシグネチャ
0x04–0x07 4バイト 00 00 00 00 ブランク
0x08–0x09 2バイト 例: 00 34 トラック全体の長さ(バイト数, BE16)。ヘッダ+INDX+SUB群の合計サイズ
0x0A–0x0B 2バイト 00 01 ファイル番号(後述)
0x0C 1バイト 例: 00 チャンネル番号(後述)
0x0D 1バイト 例: 02 インデックス数 N
0x0E–0x0F 2バイト 例: 00 10 トラックヘッダ長(通常 16バイト, BE16)
0x10–0x13 4バイト "INDX" インデックスチャンクのシグネチャ
0x14–0x17 4バイト 00 00 00 00 ブランク
0x18–0x19 2バイト 例: 00 24 INDX以降のデータ長(バイト数, BE16)
0x1A 1バイト 00 ブランク
0x1B 1バイト =0x0D インデックス数 N
0x1C–(0x1C+4×N–1) 4バイト × N インデックス制御子群 インデックス挙動の制御。1バイト目上位ニブルが遷移種別(0:ストップ 1:次のインデックスに遷移)、下位ニブルがモード(1:通常)。ただし80000000の場合はサブインデックスが入る。
(0x1C+4×N)–(0x1C+12×N–1) 8バイト × N インデックス開始/終了位置、またはサブインデックスヘッダポインタ群 開始位置ブロック(4バイト)+終了位置ブロック(4バイト)がセットになって 8バイト。それをインデックス数 N 回繰り返す。ブロックは先頭3バイトがMSF(分:秒:フレーム)、4バイト目がブランク。サブインデックスの場合は、インデックスチャンク内のサブインデックスチャンク先頭位置を示すオフセットが2・3バイト目にBE16で入り、他はブランク。
各サブインデックスチャンク先頭位置〜 可変 サブインデックスチャンク サブインデックスの定義領域。構造は下表参照。
C{000}.TBL サブインデックスチャンクフォーマット
オフセット範囲(サブインデックスチャンク先頭基準) サイズ 内容 説明
0x00–0x03 4バイト "SUB0" サブインデックスチャンクのシグネチャ
0x04–0x07 4バイト 00 00 00 00 ブランク
0x08–0x09 2バイト 例: 00 24 SUB本体の長さ(ヘッダ以降, BE16)
0x0A–0x0B 2バイト 例: 00 02 サブインデックス数
0x0C–(0x0C+4×M–1) 4バイト × M サブインデックス制御子群 サブインデックス挙動の制御。1バイト目上位ニブルが遷移種別(0:ストップ 1:次のインデックスに遷移 2:次のサブインデックスに遷移)、下位ニブルがモード(1:通常 9:クイズ出題 5:クイズ正答 3:クイズ誤答)。4バイト目が選択肢数を表す。
(0x0C+4×M)–(0x0C+12×M–1) 8バイト × M サブインデックス開始/終了位置 各サブインデックスの開始MSF+終了MSF。ただし先頭サブインデックスでは各ブロックの4バイト目が、開始MSFではインデックス番号、終了MSFでは01

インデックスヘッダのファイル番号とチャンネル番号については、SOUND.RTFの解析で説明します。


2-3. SOUND.RTF

SOUND.RTFについては、特殊な事情があります。

image.png

image.png

歴史的な経緯から、元のCD-ROMにはないRIFFヘッダをWindowsシステム側で付加してファイルとして表示するようになっています。

2-3-1. SOUND.RTF RIFFヘッダ/フォーマットヘッダ構造

オフセット範囲 サイズ 内容 説明
0x00–0x03 4バイト "RIFF" RIFF コンテナのシグネチャ
0x04–0x07 4バイト RIFFデータサイズ(LE32) これより後のデータサイズ。ファイル全体のバイト数 - 8。
0x08–0x0B 4バイト "CDXA" フォームタイプ。ここでは CD-ROM XA ADPCM を意味する "CDXA"
0x0C–0x0F 4バイト "fmt " フォーマットチャンクのシグネチャ
0x10–0x13 4バイト 10 00 00 00 フォーマットサイズ(LE32)。CDリピーターでは常に 0x10(16バイト)固定。
0x14–0x17 4バイト 00 00 00 00 ブランク
0x18–0x1F 8バイト 例: 39 11 58 41 01 00 00 00 ISO 9660のPVDからファイル情報のLEN_SU領域の一部がコピーされている。
0x20–0x23 4バイト 00 00 00 00 ブランク
0x24–0x27 4バイト "data" データチャンクのシグネチャ
0x28–0x2B 4バイト dataSize(LE32) 直後に続く XA セクタ列の合計バイト数(CDリピーターでは音声セクタ数 × 0x930)

これらは元々CD-ROMに含まれていないので、SOUND.RTFのフォーマットチェックにのみ用います。


この次に並んでいるのが、音声データを格納したセクター群本体です。
セクターがLBA:4350(01:00:00)から順に連結され、C{000}.TBL で指定される MSF は、この領域の位置(セクター単位のオフセット)を指しています。

2-3-2. CD-ROM XA Mode2 Form2 セクタ構造(XA ADPCM)

1セクターのサイズは2352バイト(0x000–0x92F)です。

オフセット範囲 サイズ 内容 説明
0x00–0x0B 12バイト 同期パターン 00 FF FF FF FF FF FF FF FF FF FF 00 固定。CD-DA / CD-ROM 共通のセクタ同期パターン。
0x0C–0x0E 3バイト ブロックアドレス(MSF) Minute, Second, Frame をBCDで格納。例: 00 02 16 = 00分02秒16フレーム。論理ブロックアドレス(LBA)と1:1対応。
0x0F 1バイト Mode セクタ種別。CD-ROM XA の場合は 0x02(Mode2)固定。
0x10–0x13 4バイト サブヘッダ 詳細は下記のサブヘッダ構造参照。
0x14–0x17 4バイト サブヘッダ(コピー) サブヘッダの完全なコピー。エラー検出・訂正のために 2 重化されている。通常は 0x10–0x13 と同一。
0x18–0x917 2304バイト ユーザーデータ XA ADPCM音声ストリームの本体。
0x918–0x92B 20バイト ユーザーデータ末尾 CDリピーターではブランク 00 00 ... 00
0x92C–0x92F 4バイト EDC(Error Detection Code) 32bit CRC によるエラーチェックコード。オプションであり、CDリピーターでは使用されていない。ブランク 00 00 00 00

Form2の仕様上のユーザーデータ長は2324バイトですが、XA ADPCMでは2304を使用し、残り20バイトはブランクとしています。

サブヘッダ構造

サブヘッダは、XA ADPCM にまつわる制御情報を格納しています。

バイト位置 フィールド 説明
0x10 ファイル番号 XA ファイル番号。トラック内での論理ストリーム ID。CDリピーターでは01固定。
0x11 チャンネル番号 チャンネル番号。同一ファイル内で複数音声ストリームを持つ場合に使用。CDリピーターでは000F(15)まで16種類の値を取る。
0x12 サブモード データ種別フラグ群。CDリピーターでは大半のセクターで64
0x13 符号化情報 コーデックの各種情報。CDリピーターでは大半のセクターで04

CDリピーターでは、ファイルはSOUND.RTF一本のみ、これにファイル番号 01 が割り当てられています。

さらにSOUND.RTF内部では、チャンネル 0015 の音声セクタが1チャンネルずつストライピングされて配列されています。そのため再生時は通常のオーディオCDのようにセクターを順に再生するのではなく、対象チャンネルごとに飛ばして読んでいく必要があります。

サブモード、符号化情報はさらに次のようなビット配列で細分化できます。

サブモード ビット配列
ビット配列 フィールド 説明
0x12 bit7 EOF ファイル終端セクターのみ 1。他は 0
0x12 bit6 Real time リアルタイムセクターの場合 1。CDリピーターでは全て 1
0x12 bit5 Form 0=Form1, 1=Form2 。CDリピーターなどXA ADPCM では 1 (Form2)。
0x12 bit4 Trigger トリガビット。用途はアプリ依存。CDリピーターでは全て 0
0x12 bit3 Data データセクタの場合に 1。CDリピーターでは全て 0
0x12 bit2 Audio ADPCM 音声セクタの場合に 1。CDリピーターでは音声収録セクタのみ 1 で、ブランク領域は 0をとる。
0x12 bit1 Video 動画データの場合に 1。CDリピーターでは全て 0
0x12 bit0 EOR インデックス、サブインデックスなど音声ユニットの終端セクターのみ 1。他は 0

CDリピーターでは大半の音声セクターが 64 = 0110 0100 となっており、「リアルタイム、Form2、音声」を表していることがわかります。

符号化情報 ビット配列(オーディオセクター)
ビット配列 フィールド 説明
0x13 bit7 Reserved 予約ビット。常に 0
0x13 bit6 Emphasis 0=通常, 1=プリエンファシスあり。CDリピーターでは全て 0
0x13 bit5–bit4 Bits per sample ADPCM レベル。00=4bit (レベルB/C), 01=8bit (レベルA)。CDリピーターでは全て 00(4bit)。
0x13 bit3–bit2 Sample rate サンプルレート。(00: 37,800Hz, 01: 18,900Hz)CDリピーターでは音声領域は全て 01(18,900Hz)。ブランク領域では 00(37,800Hz) になっている。
0x13 bit1-bit0 mono/stereo 00=モノラル, 01=ステレオ。CDリピーターでは全て 00

CDリピーターでは大半の音声セクターが 04 = 0000 0100 となっており、「プリエンファシスなし、4bit、18,900Hz、モノラル」を表していることがわかります。

ADPCMについて

ここで、ADPCMについておさらいしておきましょう。

ADPCMとは、“Adaptive Differential Pulse Code Modulation”、日本語では「適応的差分パルス符号変調」です。

ADPCMについて説明する前に、まずはDPCM(差分パルス符号変調)についても説明しておきます。

というか念のため、そもそもPCMについても説明しておきます。

PCM(パルス符号変調)とは、時間方向(波形の横軸)、振幅方向(波形の縦軸)ともにアナログ量である音声波形を、縦軸である振幅は「量子化」によって離散的な数値に、横軸である時間は「標本化」によって離散的な数値に変換し、それを符号化することでディジタルデータ化する方式です。

ざっくり言えば、量子化が細かいほどノイズの少ない綺麗な音に、標本化が細かいほど高い周波数の音まで収録できます。よく聞く「ハイレゾ」では、通常のCDが量子化16ビット、標本化44,100Hzであるところ、例えば量子化24ビット、標本化96,000Hzとより詳細なデータで表現して音質を向上させています。

そしてPCM(リニアPCM)は、データ効率が非常に悪い記録方法です。無音でも大音響でも同じだけの容量を食います。

そこで「隣り合ったサンプルはだいたい似たような値である」という性質に着目したのがDPCM、“Differential Pulse Code Modulation”(差分パルス符号変調)です。たとえばPCMで「31425、31426、31424、31427」のようなサンプルが送られてきたとき、DPCMではこれを「+31425、+1、-2、+3」のようなイメージで量子化します。かなり雑な説明ですが、データが節約できることは伝わると思います。

そしてお待たせしました。ここで本日の主役、ADPCMです。

DPCMが「差分だけを送る」方式であるのに対し、ADPCMは「差分の量子化ステップ幅を状況に応じて変える」という仕組みです。“Adaptive”(適応的)というのは、要するにそういうことです。

人間の声の波形を見てみると長い母音のような単調な部分と、急激に変化する破裂音(p、t、kなど)では波形がまったく異なっています。これらを同じ幅で量子化するのは効率が悪いのです。

単調な部分、すなわち前のサンプルとの差分が小さいところは多少粗く(量子化ステップ:大)、激しく変化する部分、すなわち前のサンプルとの差が大きいところは細かく(量子化ステップ:小)、という判断をサンプルごとに自動で行うのです。

そのために必要なのが、「フィルタ」と「シフト(量子化ステップ)」の二つの要素です。
XA ADPCMでは、サウンドパラメータに

  • Filter(フィルタ番号 / 予測係数) … 波形の「形」(傾き)を予測する
  • Shift(シフト値 / 量子化ステップ) … 波形の「大きさ」(振幅)を適応する

の2種類が記録されています。

これらは「今どんな波形か」を見て、最適な量子化方法に切り替えるためのスイッチのようなものです。この二つがあるおかげで、4ビット(16段階)という非常に小さなデータ量でも、驚くほどクリアな音声圧縮を実現しています。

まとめるとADPCMとは

「波形の変化を見ながら、差分を巧みに粗くしたり細かくしたりする圧縮方式」

と言えます。

PCMが「全部そのまま保存」、DPCMが「差分だけ保存」、ADPCMは「差分の幅を状況を見ながら変えて保存」という関係です。

CD-ROM XAはもともと、データを大量に詰め込みたいゲームやマルチメディアなどの用途で使われました。4ビット18,900Hzでも会話なら十分聞ける音質ですし、それでいてデータサイズはリニアPCMの1/4以下。計算負荷も軽く、専用DSPを要せずデコードが可能。こうした特徴のため、CD-ROM XAはADPCMと非常に相性がいいのです。CDリピーターのほか、同じくSONYのPlayStationでも採用されていました。

ユーザーデータ構造

XA ADPCM の ユーザーデータ2,304 バイトは、下記のように18個のサウンドグループ単位で構成されています。

オフセット範囲(ユーザーデータ先頭基準) サイズ 内容 説明
0x000–0x07F 128バイト サウンドグループ #0 ADPCM データブロック
0x080–0x0FF 128バイト サウンドグループ #1
0x100–0x17F 128バイト サウンドグループ #2
... ... ... (同様に連続)
0x800–0x87F 128バイト サウンドグループ #16
0x880–0x8FF 128バイト サウンドグループ #17
サウンドグループ(128バイト)の構造

さらに一つのサウンドグループは、下記のような構造をとります。

オフセット範囲(グループ先頭基準) サイズ 内容 説明
0x00–0x0F 16バイト サウンドパラメータヘッダー 各 ADPCM ブロックの Shift/Filter パラメータ
0x10–0x7F 112バイト ADPCM データワード列 32LE のデータワードが 28 個並ぶ
サウンドパラメータヘッダー(モノラルの場合)

サウンドグループの冒頭16バイト・サウンドパラメーターは、次のような構造をとります。

オフセット範囲 サイズ 内容 説明
0x00–0x03 4バイト ブロック1~4ヘッダー(コピー) 0x04–0x07 のコピー(冗長化用)
0x04 1バイト ブロック1ヘッダー ブロック1のShift / Filter
0x05 1バイト ブロック2ヘッダー ブロック2のShift / Filter
0x06 1バイト ブロック3ヘッダー ブロック3のShift / Filter
0x07 1バイト ブロック4ヘッダー ブロック4のShift / Filter
0x08 1バイト ブロック5ヘッダー ブロック5のShift / Filter
0x09 1バイト ブロック6ヘッダー ブロック6のShift / Filter
0x0A 1バイト ブロック7ヘッダー ブロック7のShift / Filter
0x0B 1バイト ブロック8ヘッダー ブロック8のShift / Filter
0x0C–0x0F 4バイト ブロック5~8ヘッダー(コピー) 0x08–0x0B のコピー(冗長化用)

さらに各ブロックのヘッダーは次のようなビット配列になっています。

ビット位置 マスク フィールド 説明
bit7–bit6 0xC0 未使用 常に 0
bit5–bit4 0x30 Filter 03: 予測フィルタ番号。XA用の4種類のフィルタテーブルから選択する。
bit3–bit0 0x0F Shift 012: シフト量。0が最大音量で、値が大きいほど小さくなる。1315 は予約で、9と同様になる。

なお、XA ADPCMのフィルタ係数テーブルは下記のようになっています。

$$
\mathrm{T_Pos} = (0, +60, +115, +98, +122)
$$

$$
\mathrm{T_Neg} = (0, 0, -52, -55, -60)
$$

最後の5番目は使用されませんが、規格に従って一応入れています。

それぞれのフィルタの役割は概ね次のとおりです。

  • 0 … ノイズや単調音声向き。
  • 1 … 上昇や下降のなだらかな波向き。
  • 2 … 母音や倍音など強くカーブする波向き。
  • 3 … 波形の山や谷の折り返しや減衰部分向き。
ADPCM データワード(量子化4ビットの場合)

サウンドグループの 0x10〜0x7F は、32ビットリトルエンディアンの「データワード」が28個並んでいます。

オフセット範囲(グループ先頭基準) サイズ 内容 説明
0x10–0x13 4バイト データワード#1 各ブロックの「1 サンプル目」のニブル群
0x14–0x17 4バイト データワード#2 各ブロックの「2 サンプル目」のニブル群
0x18–0x1B 4バイト データワード#3 各ブロックの「3 サンプル目」のニブル群
... ... ... ...
0x7C–0x7F 4バイト データワード#28 各ブロックの「28 サンプル目」のニブル群
4ビットモノラルXA-ADPCMデータワード(32LE)内のニブル配列

各ニブルは次のようにブロックと対応しています。

ビット位置(32bit ワード内) ニブル位置 対応ブロック 説明
bit 0–3 ニブル0 ブロック1 値範囲 -8h..+7h の 4bit 符号付き
bit 4–7 ニブル1 ブロック2
bit 8–11 ニブル2 ブロック3
bit 12–15 ニブル3 ブロック4
bit 16–19 ニブル4 ブロック5
bit 20–23 ニブル5 ブロック6
bit 24–27 ニブル6 ブロック7
bit 28–31 ニブル7 ブロック8

このように各サンプルは「ブロックごとに連続」ではなく、「同じサンプル番号のブロックが横に並ぶ」形でインターリーブされています。

波形上の論理的な順序は

b1s1, b1s2, b1s3, ... b1s28, b2s1, b2s2, ...

のように「ブロックごとに時系列に沿って並ぶ」のに対し、データ上は

(b1s1, b2s1, b3s1, ... b8s1), (b1s2, b2s2, ... b8s2), ...

というように「同じサンプル番号の8ブロック分が1ワードに詰められて順に並んでいる」と考えると分かりやすいでしょう。

こうしておくと誤り訂正、特にエラーが連続するバースト誤りを分散させることができますし、処理の上でも色々と都合が良いのです。

4ビットモノラルXA ADPCMのデコード手順は、ざっくり下記のようになります。

1. サウンドグループごとにヘッダー冒頭16バイトを読み、各ブロックのShift / Filterを決定
2. 各グループにつき28個の4バイトデータワードを順に処理
3. 各データワードのニブルを取り出し、シフトとフィルタ番号をもとに16ビット量子化サンプルへ伸長
4. 1グループあたり8ブロック×28サンプル=224サンプル相当(モノラルの場合)を生成

つまりオーディオCD1秒間=75セクターの領域に、モノラルで実に302,400サンプルを記録できます。

$$
8_\mathrm{[blocks]} \times 28_\mathrm{[samples]} \times 18_\mathrm{[groups]} \times 75_\mathrm{[sectors]} = 302,400_\mathrm{[samples]}
$$

これをサンプリングレート18,900Hzで再生した場合、

$$
302,400_\mathrm{[samples]} \div 18,900_\mathrm{[Hz]} = 16_\mathrm{[sec]}
$$

つまり、オーディオCD1秒分に16秒もの音声を詰め込むことができます。CDリピーターでは0~15の計16チャンネルを使用していますが、つまりこれら全てのチャンネルがCDの収録時間分の音声を持つことができ、ディスク1枚あたりの総収録時間は実に20時間近くに及んでいます。

実際、今回解析したプログレスのディスク1は合計208,503個もの音声セクターを持っています(各チャンネルが必ずしも同じ尺でないため、パディングとして6,122個のブランクセクターが含まれています)。これはサンプリングレート18,900Hzで12時間21分20秒64に相当する音声です。


2-4. WARNING.

ISO 9660のPrimary Volume Descriptor (PVD)で定義されているためWindows上からはファイルとして見えていますが、実際のデータの実態はトラック2の前半部分です。トラック2には警告音声がCDオーディオフォーマットで収録されています。

image.png

image.png

「WARNING.」もトラック2も開始LBAが共通で、終了LBAのみ異なっていることがわかります。

この「ファイル」を通常のオーディオCDと同じように「リニアPCM、符号付き16ビット、リトルエンディアン、ステレオ2ch」として下記の波形編集ツールで読み込んでみます。

GoldWave
https://goldwave.com/

image.png

すると、ちゃんと音声として読み取ってくれました。

image.png

PVDでの定義サイズが実際のトラック2の尺よりも短いので、後半部分は切れてしまっています。

しかし後半はオーディオCDとして再生した場合のノイズや故障についての警告であり、CD-ROMとして読む場合、こちらの方が筋が通っています。したがって、これは意図的な設定と思われます。

CD-DA(オーディオCD)とCD-ROMは別物のフォーマットだと長年思い込んでいたので、こうしてISO 9660経由でオーディオトラックを読み込むというアクロバティックな技を目の当たりにすると、不思議な気分です。

純粋なデータメディアであるCD-ROMがオーディオCDの概念である再生時間をブロックアドレスとして持っているのはかなり意外で、CD-ROMがオーディオCDの拡張規格であることを思い出させてくれます。


というわけでCDリピーターの場合、ISO 9660として読み取れるファイル、それもC{000}.TBL群およびSOUND.RTFに含まれるだけで理論的には再生が可能であることがわかりました。

チャプター数および各チャプターテーブル情報はWindowsファイルシステムで捕捉できるため、CHAP.TBLはスルーできます。というより、そもそもWindowsから見える各チャプターテーブルはセクターのブロックアドレス情報がヘッダともども欠落しており、紐付けることができません。

言うまでもなくWARNING.も不要でしょう。

ここからは一旦CDというメディアを忘れ、中身のファイルをベースにエミュレーターを開発していきます。

エミュレーター開発篇

前半の解析篇によって、CDリピーターのディスクは下記で構成されていることが判明しました。

  • チャプターインデックスファイル C{000}.TBL群
  • CD-ROM XA形式音声データ SOUND.RTF

後半の開発篇ではC#.NET Windowsフォームアプリとして、CDリピーター用ディスクを解析して実機の挙動をある程度模した再生を実現するエミュレーターを実装します。

シンプルに音声を聴くだけなら、SOUND.RTFのセクターをチャンネルごとに抽出し直し、チャプターごとに分割して出力するコンバーターを実装すれば事足ります。

しかし今回は一歩踏み込んで、CDリピータによる学習体験を再現するところを目指しました。いや、ちょっと色々あって(TOEICのスコアが、会社からギリ受験料の補助が出る体たらくだった)英語を初心に帰ってしっかり学び直したいという気持ちが芽生え、どうせなら

  • 各インデックスが連続で再生される
  • 問題が読み上げられたら自動的に一時停止して回答を待つ
  • 正解するとリアクションと共に次の問題に進む

といったCDリピーターの挙動も再現しようと思ったのです。

これらを実現するためには、各チャプターインデックスの記述を正しく解釈し、デコーダーとUIが連動したアプリとして実装する必要があります。

なお、今回開発したエミュレーターのソースコードはGitHubで公開しています。本記事の解説と照らし合わせながらご覧ください。(ちょこちょこいじってるので引用したソースと異なっている可能性があります。ご諒承ください。)

1. 全体アーキテクチャの設計

アプリ全体の構成は次のようにしてみました。チャプターインデックスのデータモデルと音声デコーダーエンジンを本来から切り離す形ですね。

名前空間 / フォルダ 役割 主なクラス
ProgressusInLinguaAnglica.Model データモデル層
独自のTBLファイルのバイナリ解析と、メモリ上でのデータ構造定義を担当。
TblParser, TrackTable, Segment
ProgressusInLinguaAnglica.Xa 音声エンジン層
CD-ROM XA形式の物理的な読み取り、ADPCMデコード、WAV生成を担当。
XaRiffReader, XaSectorIndex, XaAdpcmDecoder
ProgressusInLinguaAnglica UI・制御層
ユーザー操作の受け付け、再生状態の管理、タイマーによる自動遷移制御。
MainForm

2. インデックステーブルの構造化 (Model層)

C{000}.TBLファイルの解析をするモジュールです。
解析編で判明した通り、CDリピーターのチャプターは Track (チャプター) → Index (インデックス) → SubIndex (サブインデックス) という階層構造を持っています。

実際のファイルの中身はファイル内のオフセットアドレス(ポインタ)が入り乱れた複雑なバイナリデータです。これをC#のオブジェクトとして扱いやすい形に変換してあげるのです。

2-1. データ構造の二重化戦略

さて。実機のエミュレーションを行う上で、データ構造には対立する2つの要件がありました。

  1. 論理的な構造の保持: 「問1(Index 1)の中に選択肢1(SubIndex 1)がある」といった親子関係はそのまま維持したい。
  2. 物理的な再生順序の管理: UI上では暫定的にリストボックスに一列に並べたいし、再生時の連続再生も考慮するとフラットに線形で扱いたい。

そこで、TrackTable クラスには階層構造を持たせつつ、それとは別にフラットな再生リスト用のクラス Segment を用意することにしました。

Segment クラスは Model/TrackTable.cs で定義されています。

    /// <summary>
    /// 既存 UI/再生用のフラットな区間情報。
    /// </summary>
    public sealed class Segment
    {
        /// <summary>
        /// 便宜上の番号(0,1,2,...)
        /// インデックス番号とは独立。
        /// </summary>
        public int Index { get; set; }

        /// <summary>
        /// 開始フレーム (mm:ss_ff → 75fps)
        /// </summary>
        public int StartFrame { get; set; }

        /// <summary>
        /// 開始フレームの最終バイト (用途不明)
        /// </summary>
        public byte StartByte { get; init; }

        /// <summary>
        /// 終了フレーム
        /// </summary>
        public int EndFrame { get; set; }

        /// <summary>
        /// 終了フレームの最終バイト (用途不明)
        /// </summary>
        public byte EndByte { get; init; }

        /// <summary>
        /// この Segment の元になった INDX(あれば)
        /// SUB0 由来の場合も親インデックスが入る。
        /// </summary>
        public TrackIndex? SourceIndex { get; init; }

        /// <summary>
        /// この Segment の元になった SUB0(あれば)
        /// </summary>
        public TrackSubIndex? SourceSubIndex { get; init; }
    }

このように Segment クラスが元の SourceIndexSourceSubIndex への参照を持つことで、フラットなリストとして扱いながらも、必要に応じて「親インデックスの制御情報」にアクセスできるよう設計しています。

2-2. ポインタを追跡する再帰的パース処理

TblParser.cs の実装において最大の難所は、インデックス (INDX) とサブインデックス (SUB0) の関係性でした。

インデックスチャンク (INDX) には、各インデックスの制御情報が並んでいます。通常であればそこに「開始時間・終了時間」が書かれているのですが、「制御子の最上位ビットが立っている場合(0x80000000)だけは、そこには時間ではなく『SUB0チャンクへのファイル内オフセット』が書かれている」 という変則的な仕様になっています。

これを素直に前から順に読むと、「時間を読もうとしたらオフセットだった」という混乱が生じます。そこで、一度辞書(Dictionary)を使って「後で読むべき場所」を予約しておくという実装にしてみました。

以下のコードは Model/TblParser.csParseIndxAndSubChunks メソッドの一部です。

            // インデックス制御子を先に全部読む
            var indexCtrls = new uint[indexCount];
            for (int i = 0; i < indexCount; i++)
            {
                indexCtrls[i] = ReadBe32u(data, ctrlStart + 4 * i);
            }

            // SUB0 の位置 → 親インデックス番号 の対応
            // (同じ SUB0 を複数インデックスが指す可能性も考えつつ、最初のものを優先)
            var subPositions = new Dictionary<int, int>();

            // INDX 本体を全部 TrackIndex に起こしつつ、
            // サブインデックスを持たないものはそのまま Segment にする。
            for (int i = 0; i < indexCount; i++)
            {
                uint ctrl = indexCtrls[i];
                int controlOffset = ctrlStart + 4 * i;
                int pairPos = timeStart + 8 * i;
                if (pairPos + 8 > indxEnd)
                    break;

                DecodeControlWord(ctrl, out var playback, out var kind);

                var index = new TrackIndex
                {
                    IndexNumber = i,                 // 0 始まり
                    ControlWord = ctrl,
                    PlaybackContinuation = playback,
                    SegmentKind = kind,
                    RawOffset = indxPos,
                    RawLength = indxEnd - indxPos,
                    ControlOffset = controlOffset,
                    TimePairOffset = pairPos,
                };
                table.Indices.Add(index);

                if (ctrl == 0x80000000u)
                {
                    // サブインデックスを持つインデックス → マーカーから SUB0 オフセットを読む
                    byte b0 = data[pairPos + 0];
                    byte b1 = data[pairPos + 1];
                    byte b2 = data[pairPos + 2];
                    byte b3 = data[pairPos + 3];

                    // [0] 0x00, [1-2] BE16 offset, [3] 0x00 の想定
                    int subOffset = (b1 << 8) | b2;
                    int subPos = indxPos + subOffset;
                    if (b0 == 0x00 && b3 == 0x00 && subPos >= 0 && subPos < data.Length)
                    {
                        if (!subPositions.ContainsKey(subPos))
                        {
                            subPositions[subPos] = i;
                        }
                    }
                    // このインデックス自体には直接再生区間が無いので、Segment は作らない
                }
                else
                {
                    // 通常インデックス → start/end 時刻を読む
                    if (TryParseTimeCode(data, pairPos, out int startFrame, out byte startByte) &&
                        TryParseTimeCode(data, pairPos + 4, out int endFrame, out byte endByte))
                    {
                        if (endFrame < startFrame)
                            (startFrame, endFrame) = (endFrame, startFrame);

                        index.StartFrame = startFrame;
                        index.EndFrame = endFrame;

                        table.Segments.Add(new Segment
                        {
                            Index = table.Segments.Count,
                            StartFrame = startFrame,
                            StartByte = startByte,
                            EndFrame = endFrame,
                            EndByte = endByte,
                            SourceIndex = index,
                            SourceSubIndex = null
                        });
                    }
                }
            }

このように処理を2段階に分けることで、ファイル内を行ったり来たりする複雑な構造を、最終的に table.Segments という一本のリストに綺麗に整列させることができました。

2-3. 制御子(Control Word)のデコード

解析篇で発見した「1バイト目の上位ニブルが遷移種別、下位ニブルがモード」という法則。これをコード上で扱う際に毎回ビット演算をするのは、煩雑にも程があります。
そこでC# 8.0以降の switch 式を活用し、意味のある Enum 値へ変換するメソッドを用意しました。

以下のコードは Model/TblParser.csDecodeControlWord メソッドです。

        /// <summary>
        /// 制御子デコード
        /// </summary>
        /// <param name="controlWord">制御子</param>
        /// <param name="playback">連続再生フラグ</param>
        /// <param name="kind">種別フラグ</param>
        private static void DecodeControlWord(uint controlWord, out PlaybackContinuation playback, out SegmentKind kind)
        {
            // 制御子を8桁16進に変換 → 必ず8文字になる
            string hex = controlWord.ToString("X8");

            // 左から1文字目・2文字目の16進数字
            char c0 = hex[0];
            char c1 = hex[1];

            // 連続再生フラグ
            playback = c0 switch
            {
                '1' => PlaybackContinuation.NextIndex,     // 次のインデックス
                '2' => PlaybackContinuation.NextSubIndex, // 次のサブインデックス
                _ => PlaybackContinuation.Stop          // 0やその他 → ストップ
            };

            // 種別フラグ
            kind = c1 switch
            {
                '1' => SegmentKind.Regular,
                '9' => SegmentKind.Question,
                '5' => SegmentKind.CorrectAnswer,
                '3' => SegmentKind.WrongAnswer,
                _ => SegmentKind.Regular
            };
        }

これにより、メインロジック側では 0x21000004 のようなマジックナンバーを一切扱わず、「ここはクイズ問題なので一旦ストップ」「ここは続きがあるので連続再生」といった判定を直感的に記述できるようになります。

3. XA ADPCM 音声の抽出とデコード (Xa層)

次に、本エミュレーターの心臓部である音声処理です。
ここには「巨大なファイルのランダムアクセス」と「ADPCMのデコード」という2つの技術的な壁があります。

3-1. セクター単位の高速シークシステム

音声データが格納されている SOUND.RTF は数百メガバイトにも及ぶ巨大なファイルです。さらに、CD-ROM XA Mode2 Form2 の仕様上、各チャンネルのデータはインターリーブ配列されています。

例えば「チャンネル1のデータ」が欲しい場合、ファイル上のデータは連続しておらず、およそ16セクタ(約37KB)ごとに飛び飛びに現れます。これを再生のたびに毎回ファイル先頭から走査するのは、ちょっと非効率です。今回の実機エミュレーターで再現したいのはあくまで「使い心地」であって、必ずしも実機同様の処理にはこだわっていません。

そこでファイルを開いた直後に一度だけ全セクターのヘッダをスキャンし、「どのチャンネルの、どの時間のセクタが、ファイルのどこにあるか」というインデックスをメモリ上に展開することにしました。

Xa/XaSectorIndex.cs における SectorInfo クラスの定義です。

        /// <summary>
        /// 
        /// </summary>
        public sealed class SectorInfo
        {
            public long SectorIndex { get; init; }
            public int Minute { get; init; }
            public int Second { get; init; }
            public int Frame { get; init; }
            public int TotalFrame { get; init; }
            public int Channel { get; init; }
            public long FileOffset { get; init; }
        }

XaSectorIndex クラスのコンストラクタでファイルを舐め、この SectorInfo のリストを作成します。
これにより、再生時には _sectors.Where(s => s.Channel == ch && s.TotalFrame >= start && s.TotalFrame <= end) のようにLINQを使って、再生に必要なセクタの物理位置を一瞬で特定できるようになりました。

3-2. XA ADPCM デコードの実装

抽出したセクターデータは、まだ音として聴けません。XA ADPCM形式の音声をデコードしてあげる必要があります。

デコード処理の実装は XaAdpcmDecoder.cs に集約されています。
仕様書にある計算式

$$
S_{t} = (D \ll Shift) + (S_{t-1} \times F_0 + S_{t-2} \times F_1) \gg 6
$$

を実装するのですが、ここで最も重要なのは 「状態(History)の引き継ぎ」 です。

あるサンプルの値を決めるには、必ず「1つ前の値」と「2つ前の値」が必要です。これはセクターが変わっても継続しなければなりません。これがないと、セクターの継ぎ目で波形が不連続になり、プチプチした周期的なノイズが発生してしまいます。

以下は Xa/XaAdpcmDecoder.csDecode28Nibbles メソッドです。ref による状態の参照渡しに注目してください。

        /// <summary>
        /// 1 ポーション内の 28 サンプル (1 ブロック・1 ニブル分) を ADPCM デコードする。
        /// </summary>
        /// <param name="sector">セクターのバイト列データ。</param>
        /// <param name="portionOffset">現在のポーション開始位置のオフセット。</param>
        /// <param name="blk">処理対象のブロック番号 (0-7)。規格上の番号-1。</param>
        /// <param name="dst">デコード結果のPCMサンプルを格納する出力Span。</param>
        /// <param name="dstIndex">dstへの書き込み開始インデックス(参照渡し)。</param>
        /// <param name="old">予測フィルタの前回のサンプル値(デコード状態を引き継ぐための参照渡し)。</param>
        /// <param name="older">予測フィルタの前々回のサンプル値(デコード状態を引き継ぐための参照渡し)。</param>
        private static void Decode28Nibbles(
            byte[] sector,
            int portionOffset,
            int blk,
            Span<short> dst,
            ref int dstIndex,
            ref int old,
            ref int older)
        {
            // ヘッダバイト:portionOffset + 4 + blk
            byte header = sector[portionOffset + 4 + blk];

            int shift = 12 - (header & 0x0F);
            if (shift < 0) shift = 9; // 13以上は強制9

            int filter = (header & 0x30) >> 4;
            if (filter < 0 || filter >= PosTable.Length)
            {
                filter = 0;
            }

            int f0 = PosTable[filter];
            int f1 = NegTable[filter];

            for (int j = 0; j < 28; j++)
            {
                // データワード:portionOffset + 16 + (blk / 2) + j*4
                byte data = sector[portionOffset + 16 + (blk / 2) + j * 4];
                int nib;

                if (blk % 2 == 0)
                {
                    nib = data & 0x0F; // LO
                }
                else
                {
                    nib = (data >> 4) & 0x0F; // HI
                }

                // 4bit 符号拡張 (-8..+7)
                if ((nib & 0x08) != 0)
                    nib |= unchecked((int)0xFFFFFFF0);

                int sample = (nib << shift) + ((old * f0 + older * f1 + 32) >> 6);

                // -32768..+32767 にクリップ
                if (sample < short.MinValue) sample = short.MinValue;
                if (sample > short.MaxValue) sample = short.MaxValue;

                dst[dstIndex++] = (short)sample;
                older = old;
                old = sample;
            }
        }

このメソッドの引数が ref int old, ref int older となっています。
C# の ref キーワードを使うことで、呼び出し元の変数を参照渡しで直接更新しています。これによりメソッドを抜けた後も「直前の値」が保持され、次のブロック、次のセクターへとバケツリレーのように値が引き継がれていきます。

これで、XA ADPCM音声をノイズのないクリアな音声として再生できるようになります。

4. UIとインタラクションの実装 (UI層)

最後に、UIと全体制御を担う MainForm.cs です。

4-1. 「オンメモリ再生」という割り切り

今回は現代のPCスペック(メモリ容量・CPU速度)に全面的に甘えて、だいぶ豪胆な設計方針をとりました。それはストリーミング再生をするのではなく**「再生時に最初っから最後まで全部デコードしてメモリに乗せる」**という方針です。あの当時じゃ考えられないですよね。

ええい、この30年で人類の技術は大きな飛躍を遂げたんだ。これは誰が何と言おうと進化だ。

CDリピーターの1トラックは長くても数分程度。リニアPCM (16bit/18.9kHz) に展開しても数MB〜数十MB程度に収まります。複雑なストリーミング制御をするよりも、C#標準の System.Media.SoundPlayerMemoryStream を渡すだけのシンプルな実装の方が、モダンな環境では安定性もレスポンスも圧倒的に優位性が高いと判断しました。

セグメント再生の要、 MainForm.csStartPlaybackForSegment メソッドです。

        /// <summary>
        /// 指定したリスト行(セグメント)を再生開始し、必要なら次の行への連続再生もセットする。
        /// </summary>
        /// <param name="listIndex"></param>
        private void StartPlaybackForSegment(int listIndex)
        {
            if (_xaIndex is null || _xaRiff is null)
                return;
            if (listIndex < 0 || listIndex >= _segmentItems.Count)
                return;

            var item = _segmentItems[listIndex];
            var track = item.Track;
            var seg = item.Segment;

            // セグメントの開始~終了フレーム
            int channel = track.Header.Channel;
            int startFrame = seg.StartFrame;
            int endFrame = seg.EndFrame;

            if (startFrame >= endFrame)
            {
                MessageBox.Show(this, "このセグメントの時間情報が不正です。", "エラー",
                    MessageBoxButtons.OK, MessageBoxIcon.Error);
                return;
            }

            try
            {
                statusLabel.Text = "音声抽出中...";
                Cursor = Cursors.WaitCursor;

                // 該当区間のセクタ一覧を取得
                var sectors = _xaIndex.GetSectors(channel, startFrame, endFrame).ToList();
                if (sectors.Count == 0)
                {
                    MessageBox.Show(this, "指定範囲に対応するセクタが見つかりませんでした。", "エラー",
                        MessageBoxButtons.OK, MessageBoxIcon.Error);
                    return;
                }

                // XA セクタのユーザーデータ(2336バイト)を全部繋げる
                using var msXa = new MemoryStream();
                foreach (var s in sectors)
                {
                    var userData = _xaRiff.ReadUserData(s.FileOffset);
                    msXa.Write(userData, 0, userData.Length);
                }

                byte[] xaBytes = msXa.ToArray();

                // XA ADPCM → PCM16
                const int sampleRate = 18900; // いったん固定値
                short[] pcm = XaAdpcmDecoder.DecodeMono(xaBytes, sampleRate);
                if (pcm.Length == 0)
                {
                    MessageBox.Show(this, "デコード結果が空でした。", "エラー",
                        MessageBoxButtons.OK, MessageBoxIcon.Error);
                    return;
                }

                // 既存再生を停止
                _playbackTimer?.Stop();
                _player?.Stop();
                _player?.Dispose();
                _player = null;
                _currentAudioStream?.Dispose();
                _currentAudioStream = null;

                // メモリ上に WAV を構築して再生
                _currentAudioStream = new MemoryStream();
                XaWavWriter.WritePcm16MonoWav(_currentAudioStream, sampleRate, pcm);
                _currentAudioStream.Position = 0;

                _currentSegmentIndex = listIndex;
                lstChapters.SelectedIndex = listIndex; // 再生中のセグメント行を選択状態にする

                statusLabel.Text = item.DisplayText;
                _player = new SoundPlayer(_currentAudioStream);
                _player.Play(); // 非同期再生

                // このセグメントの制御子がストップマーカーを持つなら、ここで一旦停止(次の自動再生は行わない)
                var (playback, kind) = GetSegmentFlags(item);

                if (playback != PlaybackContinuation.Stop)
                {
                    // セグメント長から次セグメントの再生開始タイミングをだいたい計算
                    int lengthMs = Math.Max(100, (int)(pcm.Length * 1000.0 / sampleRate));
                    if (_playbackTimer is not null)
                    {
                        _playbackTimer.Interval = lengthMs + 500;
                        _playbackTimer.Start();
                    }
                }
            }
            catch (Exception ex)
            {
                MessageBox.Show(this, $"再生中にエラーが発生しました。\r\n{ex.Message}", "エラー",
                    MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
            finally
            {
                Cursor = Cursors.Default;
            }
        }

4-2. インタラクティブ性の再現

インタラクティブ。マルチメディア。いかにも1994~1995年って感じがしますよね。私の青春です。

CDリピーターの最大の特徴である「インタラクティブな音声再生」。これを再現するために、System.Windows.Forms.Timer を使った連続再生機構を実装しました。

再生を開始した直後に、そのPCMデータの長さから「再生が終わる時間」を計算し、タイマーを仕掛けます。そしてタイマーが発火した(=再生が終わった)タイミングで、次の挙動を決定します。

以下は MainForm.cs のタイマーイベントハンドラです。

        /// <summary>
        /// 再生中のセグメントが終わったらタイマー経由で次の行へ。
        /// </summary>
        /// <param name="sender">イベント送信元オブジェクト</param>
        /// <param name="e">イベントパラメータ</param>
        private void PlaybackTimer_Tick(object? sender, EventArgs e)
        {
            _playbackTimer?.Stop();
            int next = GetNextSegmentIndex(_currentSegmentIndex);
            if (next >= 0 && next < _segmentItems.Count)
            {
                StartPlaybackForSegment(next);
            }
        }

芸の無いシンプルな実装ですが、このタイマー連携により「問題が流れる」→「自動的に次の選択肢が流れる」とか、「正解が流れる」→「停止して解説を待つ」といった実機同様の流れを完全再現できました。

4-3. リストボックスによるチャプター再生

実際のUI画面がこちらです。

チャプター番号、インデックス番号、サブインデックス番号、開始・終了ブロックアドレス、そしてセグメント制御子。これらの情報を視認できるようにしています。

image.png

実機では数字ボタンでチャプターを選択しますが、今回は実装の簡易化からこんな形に落ち着きました。

image.png

リストビューにセグメント制御子の情報をアイコン表示したはいいものの、クイズの答とかまるわかりでちょっとインチキですね。

5. まだまだやりたいこと

こうして最初の挫折から30年の時を経て、無事にCDリピーター用ディスクをWindows上で独自に再生できるようになりました。

音声もノイズなく実機同様クリアに再生され、懐かしいプログレスによる英語学習を再び味わうことができました。

5-1. 今後の実装予定

現在は「とりあえず音は出たし挙動も再現できた」という段階ですが、今後はさらに下記のような機能拡張を考えています。

  1. 実機インターフェースの忠実な再現
    • 現状のリストボックス表示ではなく、実機の液晶画面やボタンレイアウトを模したUIを実装し、ビジュアル的にも実機を再現したいですね。
  2. アドバンストモードの充実
    • 実機にはないシークバーによる再生位置の視認・操作や、波形やスペクトルの可視化、インデックス情報の詳細表示など、解析ツールとしての側面も強化していきたいです。

6. まとめ

本エミュレーターは、“Progressus In Lingua Anglica”と命名しました。プログレスの書名のラテン語訳です。

プログレスによる幼き日の英語学習が語学への興味と誘ってくれてラテン語に興味を持つきっかけになったこと、そしてイエズス会の日本における教育事業に敬意を表しローマカトリック風味を醸し出してみたかったことがあり、このタイトルです。

まあ実際のところ「プログレス」も「(SD)リピーター」もエデックの商標なので堂々と冠するわけにはいかないんすよね。

それはさておき、今回の開発で得られた技術的な知見を振り返ります。

  • CD-ROM XA (Mode2 Form2): パーサーやデコーダーの実装にはセクタ構造やフォーマットの理解が不可欠でした。
  • チャプターインデックス: 階層構造とビットフラグによる制御フローの解析が、エミュレーター実装の鍵でした。サブインデックスの仕様や挙動を解き明かすのは、宝探しのようでした。
  • XA ADPCM: 仕様通りのフィルタ演算と状態(History)引き継ぎを実装することで、ライブラリなしのフルスクラッチでも容易に高音質なデコードが可能でした。

中学1年生の頃は手も足も出なかった、「変なファイル」たち。30年経って、ようやく仲直りすることができました。誰よりもまずあの日の自分に、この成果を報告してあげたい気分です。

かつでできなかったことを、技術の力で可能にする。エンジニアの醍醐味だと思います。やってること“車輪の再発明”だけどな……まあエミュレーター開発の愉しみってそういうもんですよね。

これで手元のCDリピーター実機が故障しても、ただちに困ることはなさそうです。まあその時は部品かき集めて修理しますけどね。

参考文献

https://psx-spx.consoledev.net/cdromformat/#cdrom-xa-subheader-file-channel-interleave
https://psx-spx.consoledev.net/cdromformat/#cdrom-xa-audio-adpcm-compression

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?