はじめまして
初めまして、祥太と申します。
本業はプリキュアのファンで、そのかたわらクラウドやインフラ領域でTech PMOなども嗜んでいます。
私自身カトリック信徒なので待降節のアドベントカレンダー(ガチのやつ)には馴染みがありますが、このたび昔から憧れだった技術の方のアドベントカレンダーに参加してみることにしました。
想い出の「CDリピーター」
私が通っていた中高一貫校では、英語の授業に文部省(当時)の検定教科書ではなく、専ら「PROGRESS IN ENGLISH」を使用していました。
これは日本に来日して中高一貫校で教鞭を執っていたイエズス会のロバート・M・フリン神父が開発した英語教材で、オーラル・コグニティブ・アプローチを重視するなど時代を先取りするテキストでした。
私がイエズス会系の中高一貫校に入学したのが1995年。それまで音声教材にオーディオカセットを使用していたプログレスは私の1学年上からSONYの「CDリピーター」という未来的なデバイスを採用し、高速な頭出しや自動リピート、クイズ学習などマルチメディア、インタラクティブならではの学習体験を享受していました。
それから、30年の月日が経ちました。
今や60年の歴史を誇るプログレスも改訂を重ね、音声教材もオーディオカセットからCDリピーター、SDリピーター、そして現在はスマホやタブレット、PCでのアプリへと変遷を遂げています。
https://www.progress21.net/
ふと手元に残された4枚のCDメディアが懐かしくなりこれを再生しようと思ったのですが、人生初の自分専用CD再生機として酷使されたCDリピーターはとっくの昔に壊れてしまって手元にはなく……。
PCで読み込んでみても変なファイルが表示されるだけで再生はできません。
幼き日に勉強した懐かしい英語の音声教材を、もう一度聴いてみたい。そんな願いから一念発起し、Windows上で表示されている「変なファイル」を解析して、CDリピーター実機のエミュレーターを開発してみることにしました。
……まあ今回の開発にあたって実機挙動を確認するためメルカリで買い戻して、実機も手元にあるんですけどね。しかもデッカい教室用まで。
これでもう、聴こうと思えば聴けますよね。めでたしめでたし。
いや、そういうことじゃないんですよ。買い戻した実機もボリュームを中心に劣化が激しく、故障は時間の問題です。ディスクも古いので、安全なストレージ上にしっかりバックアップをとっておきたいところです。
そんなわけで将来のことも考え、保全したデータを再生できるツールも用意しておく必要もあるわけです。
ディスク解析篇
まず手始めに、「PROGRESS IN ENGLISH BOOK1」のディスクを解析してみます。
今から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リピーター用ディスクのフォーマットを調べてみます。
ディスクタイプは「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を使用します。
CDリピーター用ディスクは、下記のようなファイル構成になっていることがわかります。
- CHAP.TBL … チャプターのインデックス
- C{000}.TBL … 各チャプターのメタ情報を記録したファイル
- SOUND.RTF … 音声データを記録したファイル
- WARNING. … 警告音声(Windows上ではファイル名の制約で「WARNING」)
それぞれ、下記のツールでバイナリを読んで解析してみます。
Binary Editor BZ
https://www.vcraft.jp/
2-1. CHAP.TBL
ディスク上で最も最初に記録されているファイルです。CHAPはチャプター、TBLはおそらくテーブルでしょう。となれば、各チャプターの目次のような役割を果たしていることが容易に想像されます。
さっそくバイナリを読んでみましょう。
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バイト目 …
34~45まで10進(BCD)で増加 - 4バイト目 …
00~74まで10進(BCD)で増加、74までいくと00に戻り3バイト目が1つ増加
という形になっています。
75といえば、前述したCD-ROMフォーマットでの、1秒あたりのセクタの数です。
そこで先頭チャプターと思われるC001.TBLのセクター、LBA:2400を確認してみましょう。
ヘッダ領域にしっかり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
最後のチャプターです。最も短いチャプターのひとつです。
最初に説明があり、インデックスをまたいで発音練習本体があります。
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 0001 00 00 0080 00 00 00
このチャプターでは11 00 00 00と01 00 00 00が出現しています。
0x24-27 01 00 00 00
2バイト目が00~59、3バイト目が00~74に収まっており、先頭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の領域から記録され、
そして最後はLBA:218974=48:41:49まで続いています。
SOUND.RTF上はチャプター番号の大きい方から始まって小さい方に向かってデータが連続しており、実際、C013.TBLでは最大値として最後に48 41 35が入っています。
2-2-2. C052.TBL
文章を1文ずつ区切って聴いていくチャプターです。全てのインデックスは、再生後自動的に一時停止します。
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.”という英文を聴いて繰り返すチャプターです。
一周した後はアナウンスが流れ、チャプターの最初に戻り、今度は自分で発音してから聴くように促されます。
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が選択肢に対応しているようです。
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
例文の一部に指定された単語を代入してみて、その後で正解を聴くタイプのチャプターです。
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については、特殊な事情があります。
歴史的な経緯から、元の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リピーターでは00~0F(15)まで16種類の値を取る。 |
| 0x12 | サブモード | データ種別フラグ群。CDリピーターでは大半のセクターで64。 |
| 0x13 | 符号化情報 | コーデックの各種情報。CDリピーターでは大半のセクターで04。 |
CDリピーターでは、ファイルはSOUND.RTF一本のみ、これにファイル番号 01 が割り当てられています。
さらにSOUND.RTF内部では、チャンネル 00 ~ 15 の音声セクタが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 |
0〜3: 予測フィルタ番号。XA用の4種類のフィルタテーブルから選択する。 |
| bit3–bit0 | 0x0F | Shift |
0〜12: シフト量。0が最大音量で、値が大きいほど小さくなる。13〜15 は予約で、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オーディオフォーマットで収録されています。
「WARNING.」もトラック2も開始LBAが共通で、終了LBAのみ異なっていることがわかります。
この「ファイル」を通常のオーディオCDと同じように「リニアPCM、符号付き16ビット、リトルエンディアン、ステレオ2ch」として下記の波形編集ツールで読み込んでみます。
GoldWave
https://goldwave.com/
すると、ちゃんと音声として読み取ってくれました。
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(Index 1)の中に選択肢1(SubIndex 1)がある」といった親子関係はそのまま維持したい。
- 物理的な再生順序の管理: 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 クラスが元の SourceIndex や SourceSubIndex への参照を持つことで、フラットなリストとして扱いながらも、必要に応じて「親インデックスの制御情報」にアクセスできるよう設計しています。
2-2. ポインタを追跡する再帰的パース処理
TblParser.cs の実装において最大の難所は、インデックス (INDX) とサブインデックス (SUB0) の関係性でした。
インデックスチャンク (INDX) には、各インデックスの制御情報が並んでいます。通常であればそこに「開始時間・終了時間」が書かれているのですが、「制御子の最上位ビットが立っている場合(0x80000000)だけは、そこには時間ではなく『SUB0チャンクへのファイル内オフセット』が書かれている」 という変則的な仕様になっています。
これを素直に前から順に読むと、「時間を読もうとしたらオフセットだった」という混乱が生じます。そこで、一度辞書(Dictionary)を使って「後で読むべき場所」を予約しておくという実装にしてみました。
以下のコードは Model/TblParser.cs の ParseIndxAndSubChunks メソッドの一部です。
// インデックス制御子を先に全部読む
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.cs の DecodeControlWord メソッドです。
/// <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.cs の Decode28Nibbles メソッドです。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.SoundPlayer に MemoryStream を渡すだけのシンプルな実装の方が、モダンな環境では安定性もレスポンスも圧倒的に優位性が高いと判断しました。
セグメント再生の要、 MainForm.cs の StartPlaybackForSegment メソッドです。
/// <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画面がこちらです。
チャプター番号、インデックス番号、サブインデックス番号、開始・終了ブロックアドレス、そしてセグメント制御子。これらの情報を視認できるようにしています。
実機では数字ボタンでチャプターを選択しますが、今回は実装の簡易化からこんな形に落ち着きました。
リストビューにセグメント制御子の情報をアイコン表示したはいいものの、クイズの答とかまるわかりでちょっとインチキですね。
5. まだまだやりたいこと
こうして最初の挫折から30年の時を経て、無事にCDリピーター用ディスクをWindows上で独自に再生できるようになりました。
音声もノイズなく実機同様クリアに再生され、懐かしいプログレスによる英語学習を再び味わうことができました。
5-1. 今後の実装予定
現在は「とりあえず音は出たし挙動も再現できた」という段階ですが、今後はさらに下記のような機能拡張を考えています。
-
実機インターフェースの忠実な再現
- 現状のリストボックス表示ではなく、実機の液晶画面やボタンレイアウトを模したUIを実装し、ビジュアル的にも実機を再現したいですね。
-
アドバンストモードの充実
- 実機にはないシークバーによる再生位置の視認・操作や、波形やスペクトルの可視化、インデックス情報の詳細表示など、解析ツールとしての側面も強化していきたいです。
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


























