Files
rix2midi/rix2midi.cpp
2026-08-22 05:52:55 +00:00

449 lines
21 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// rix2midi.cpp —— 「大富翁 2」RIX 音樂 → MIDI 轉換器
// ============================================================================
//
// 【這是什麼】
// 把 DOS 遊戲《大富翁 2》裡的 .RIX 音樂檔,轉成標準 .MIDI 樂譜。
// 同時可一次處理一整個資料夾、或單一檔。
//
// 【RIX 是什麼、為什麼不能直接轉】
// RIX 不是「存音符」的格式,而是 **AdLib OPL 晶片** 的「機器碼」:
// 它是一小段「時鐘節拍tick」驅動的下載程式碼每個 tick 告訴
// OPL 晶片要寫哪些「暫存器」frequency、key-on、音量……
// 所以 RIX 裡面沒有現成的音符,只有「要讓硬體發出什麼」的指令。
//
// 【我們的策略:劫持 OPL 的暫存器寫入】
// 我們不自己寫 RIX 解碼器那很複雜AdPlug 已經寫得好)。
// 而是:
// 1. 用 AdPlug 載入 RIX、執行每 tick 的「樂曲排程」。
// 2. AdPlug 內部透過一個「OPL 晶片模擬器」CEmuopl是 Copl 的子類)
// 來真的寫 OPL 暫存器。我們「繼承」它、攔截 write(),就能拿到
// 每個 tick、每個聲道的 A / B 暫存器值。
// 3. 由 A / B 暫存器還原出「頻率」→ 換算成 MIDI 音符。
// 4. 把「開/關、音高、時間」寫成標準 MIDI 檔。
//
// 【OPL 暫存器 → 音符 的數學(核心原理)】
// OPL 的頻率由「F-number (10 bits)」加上「block (3 bits)」決定的:
// F-number 低 8 位 放在 A 暫存器 (0xA0~0xA8)
// F-number 高 2 位 + block + key-on 放在 B 暫存器 (0xB0~0xB8)
// · B bit0-1 = F-number 高 2 位
// · B bit2-4 = block半音倍頻、決定八度
// · B bit5 = Key-on1=開聲0=關聲)
// 實際頻率(單位 Hz
// freq = F-number × 2^block × 晶振頻率 / (72 × 2^21)
// 其中 AdLib OPL 晶振 ≈ 3579545 Hz。
// 最後用十二平均律把 Hz 換成 MIDI 音符60 = 中音 C
// midi = round( 69 + 12 × log2( freq / 440 ) )
//
// 【音高 vs 時值(時間)】
// RIX 是「每 tick 一執行節」的排程。《大富翁 2》的 RIX 用固定時基。
// 我們量出RIX 一個 tick = 1/70 秒(用 RICHU 驗證:
// 8051 ticks ÷ 70 = 115 秒,正好等於 adplug 產出的 WAV 長度)。
// 為了寫進 MIDI我們用「96 ticks / beat四分音符並把 MIDI 速度
// 設成 tempo = (1e6 ÷ 70) × 96 微秒/四分音符,
// 這樣「1 個 MIDI tick = 1 個 RIX tick」節奏才和原曲一致。
//
// 【樂器GM Program怎麼選】
// OPL 只有 8~9 個合成聲道、不分旋律/和聲,所以由我們「猜」:
// 看每個聲道用的音高中位數——偏低 → 指派貝斯;其餘 → 指撥/方波 Lead。
// (這是「聽起來像原曲」的務實選擇,不是精確編曲。)
//
// 【如何編譯】
// 需要 AdPlugRIX 解碼 + OPL 模擬)。請先執行同目錄的 build.sh
// ./build.sh
// 它會自動找到/安裝 AdPlug 與 C++ 庫,再編譯出 rix2midi 執行檔。
//
// 【如何使用】
// ./rix2midi 某個.RIX # 轉单个檔 → 同名 .mid
// ./rix2midi 某個.RIX 輸出.mid # 指定輸出檔
// ./rix2midi 整個資料夾/ [輸出/]/ # 批次轉該資料夾所有 .RIX
//
// ============================================================================
#include <cstdio>
#include <cstdlib>
#include <cstdint>
#include <cstring>
#include <cmath>
#include <string>
#include <vector>
#include <map>
#include <tuple>
#include <algorithm>
#include <fstream>
#include <sys/stat.h>
#include <dirent.h>
// ── AdPlugRIX 解碼 + OPL 晶片模擬)──────────────────────────────
#include "opl.h" // Copl 基類OPL 晶片寫入介面)
#include "emuopl.h" // CEmuopl真正的 OPL 模擬實作
#include "rix.h" // CrixPlayerRIX 檔案的「排程器」
#include "fprovide.h" // CProvider_Filesystem從硬碟讀檔
#include "player.h" // CPlayer 基類、通用介面
#include "database.h" // CAdPlugDatabaseAdPlug 需要的資料庫單例)
// AdPlug 內部有一個「全域單例」CAdPlugDatabase需要外部定義其位址。
// AdPlug 的 .so 期待一個 CAdPlug::database 符號;我們提供它。)
class CAdPlug { public: static class CAdPlugDatabase *database; };
CAdPlugDatabase *CAdPlug::database = nullptr;
// ── 常數 ──
// AdLib OPL 晶振頻率Hz。不同機器略有差異這裡用標準 AdLib 值。
static const int OPL_CLK = 3579545;
// OPL 頻率的「除數」= 72 × 2^21 × 8把 2^21 換成 2^24 的 8 倍寫法,見下)。
// freq = Fnum × 2^block × OPL_CLK / (72 × 2^24) × 8 → 整理成一個常數 K
static const double K = (double)OPL_CLK * 8.0 / (72.0 * 16777216.0);
// MIDI 節奏:一個 RIX tick 對應的 MIDI 拍數用 9696 ticks/beat
static const int MIDI_DIV = 96;
// RIX 每 70 tick = 1 秒用量表得出8051 ticks ÷ 70 = 115 秒WAV 長度)。
static const double RIX_TICKS_PER_SEC = 70.0;
// ============================================================================
// 1. 攔截 OPL 暫存器寫入
// ============================================================================
//
// CEmuopl 是「真正的 OPL 晶片模擬器」(負責發出聲音)。
// 我們繼承它、只多寫一個東西:把每個聲道的 A / B 暫存器「也記下來」。
//
// 為什麼可行Copl 有一个「純虛」的 write(reg,val)CEmuopl 實作它來驅動
// 晶片。我們再覆寫它先「記下」A/B 暫存器,再交給原本的去發聲,兩不誤。
//
struct OPLTap : CEmuopl
{
// 每個聲道0~8的 A 暫存器F-number 低 8 位)與 B 暫存器(高 2 位+block+key
int a0[9] = {0,0,0,0,0,0,0,0,0};
int b0[9] = {0,0,0,0,0,0,0,0,0};
OPLTap(int sample_rate) : CEmuopl(sample_rate, /*16bit=*/true, /*stereo=*/false) {}
// Copl/CEmuopl 的 write(reg, val)
// reg = OPL 暫存器位址0..255val = 要寫入的值。
// 聲道 = reg 的低 4 位元reg & 0x0F
void write(int reg, int val) override
{
int r = reg & 0xFF; // 暫存器位址
int v = val & 0xFF; // 寫入值
int ch = r & 0x0F; // 第幾個 OPL 聲道
if (ch <= 8) { // OPL2 / AdLib 前 9 個聲道RIX 用到 0~8
if (r >= 0xA0 && r <= 0xA8) a0[ch] = v; // A 暫存器 → F-number 低位
else if(r >= 0xB0 && r <= 0xB8) b0[ch] = v; // B 暫存器 → 高位/block/key
}
// 照樣交給原本的 OPL 模擬器去「發聲」(我們只是順手記一筆)。
CEmuopl::write(reg, val);
}
};
// ============================================================================
// 2. 由 A / B 暫存器還原「頻率」與「開/關」
// ============================================================================
// 拼出完整的 F-number10 位低位)+ block高幾位方便統一取位。
// value = A (8 位低位) | (B 低 5 位 << 8)
// 之後:
// F-number(10位) = value 的低 10 位 → (value & 0x3FF)
// block(3位) = value 的第 10~12 位 → (value >> 10) & 7
// key-on = B 的 bit5 → (b0 >> 5) & 1
static inline unsigned pack_freq(OPLTap *o, int ch)
{
return (unsigned)o->a0[ch] | ((unsigned)(o->b0[ch] & 0x1F) << 8);
}
// 由暫存器算出「實際頻率 Hz」。
static double freq_of(OPLTap *o, int ch)
{
unsigned raw = pack_freq(o, ch);
unsigned fnum = raw & 0x3FF; // F-number10 位)
int blk = (raw >> 10) & 7; // block3 位,決定八度倍頻)
return K * (double)fnum * (double)(1u << blk); // = fnum×2^blk × 晶振 / (72×2^24)×8
}
// 目前這個聲道「有沒有在發聲」Key-on 旗標在 B 的 bit5
static inline bool key_on(OPLTap *o, int ch)
{
return (o->b0[ch] >> 5) & 1;
}
// 把頻率 Hz 換成 MIDI 音符69=A4=440Hz每半音 ×2^(1/12))。
static int freq_to_midi(double hz)
{
if (hz <= 0.0) return 0;
int m = (int)std::lround(69.0 + 12.0 * std::log2(hz / 440.0));
if (m < 0) m = 0;
if (m > 127) m = 127;
return m;
}
// ============================================================================
// 3. 寫 MIDI 的「多組定長變數VLQ」與樂器指派
// ============================================================================
// MIDI 用「變長位元組」表示 delta-time
// 把數字拆成 7 一組;**高組在前**、每組若還有更高位就在該位元組最高位bit7
// 加 1 表示「後面還有」;最後一組(最低組)不加。
// (這跟某些人的「低位在前」理解相反,寫反了整首曲都會跑调跑速。)
static void write_vlq(std::vector<char> &buf, int n)
{
unsigned x = (unsigned)n;
unsigned char g[8]; int cnt = 0;
while (x) { g[cnt++] = (unsigned char)(x & 0x7F); x >>= 7; }
if (cnt == 0) { buf.push_back(0); return; } // 特例0 就一個 0x00
for (int i = cnt - 1; i >= 0; --i) // 高組在前
buf.push_back((char)(g[i] | ((i > 0) ? 0x80 : 0)));// 「不是最後一組」就加 0x80
}
// 依每個聲道使用的「音高中位數」選 GM Program31=方波Lead、32=鋸齒貝斯、35=方波2
static int pick_instrument(const std::vector<int> &midi_notes)
{
if (midi_notes.empty()) return 31;
std::vector<int> v = midi_notes;
std::sort(v.begin(), v.end());
int median = v[v.size() / 2];
if (median < 37) return 32; // 音域偏低 → 當貝斯
else if (median < 49) return 35; // 中低音 → 另一種 Lead
else return 31; // 其餘 → 方波 Lead
}
// ============================================================================
// 4. 核心:讀 RIX → 抓事件 → 寫 MIDI
// ============================================================================
struct NoteEv { int ch; int midi; unsigned tick_on; unsigned tick_off; };
// 把一個 .RIX 檔轉成 .MIDI。回傳 0 = 成功。
static int convert_one(const std::string &in_path, const std::string &out_path,
bool verbose, int &out_note_count)
{
OPLTap opl(44100); // 用 44.1kHz 的 OPL 模擬器(只借它的暫存器邏輯)
int prev_key[9] = {0,0,0,0,0,0,0,0,0}; // 上一 tick 每個聲道的 key 狀態
int cur_note[9] = {-1,-1,-1,-1,-1,-1,-1,-1,-1}; // 目前該聲道「正開著」的音符
std::vector<NoteEv> notes;
unsigned t = 0; // 目前 tick放在外層好讓 verbose 訊息也能用)
{
CProvider_Filesystem fs; // 從檔案系統讀 RIX
CPlayer *player = CrixPlayer::factory(&opl); // ★ 把我們的 Tap 傳進去
if (!player) { if (verbose) fprintf(stderr, " [!] factory 回傳空\n"); return 1; }
if (!player->load(in_path, fs)){ if (verbose) fprintf(stderr, " [!] 載入失敗\n"); return 2; }
// 一個 tick 一 tick「播放」在每個 tick 檢查 9 個聲道的 key/音高變化:
const unsigned MAX_TICK = 2000000; // 安全上限,防止無窮迴圈
for (; t < MAX_TICK && player->update(); ++t) {
for (int ch = 0; ch < 9; ++ch) {
bool on = key_on(&opl, ch);
int mid = on ? freq_to_midi(freq_of(&opl, ch)) : -1;
if (prev_key[ch] == 0 && on) {
// 0 → 1新開一個音符
notes.push_back({ch, mid, t, (unsigned)-1});
cur_note[ch] = mid;
}
else if (prev_key[ch] == 1 && on && mid != cur_note[ch]) {
// 一直開著、但音高變了:先「結束」舊音、再「開」新音
// 這樣不會產生一長串同音MIDI 會比較乾淨)
for (auto &ev : notes)
if (ev.ch == ch && ev.tick_off == (unsigned)-1) { ev.tick_off = t; break; }
notes.push_back({ch, mid, t, (unsigned)-1});
cur_note[ch] = mid;
}
else if (prev_key[ch] == 1 && !on) {
// 1 → 0關閉該聲道 → 給「還沒結束」的那個音一個結束時間
for (auto &ev : notes)
if (ev.ch == ch && ev.tick_off == (unsigned)-1) { ev.tick_off = t; break; }
}
prev_key[ch] = on ? 1 : 0;
}
}
// 到結尾時還沒關的音,一律補上結束時間。
for (auto &ev : notes) if (ev.tick_off == (unsigned)-1) ev.tick_off = t;
}
out_note_count = (int)notes.size();
if (notes.empty()) return 3; // 什麼都沒抓到
// ── 把「開/關」攤平成按時間排序的事件流 ──
using Tev = std::tuple<int, int, int, int>; // (tick, 0=on/1=off, ch, note)
std::vector<Tev> stream;
stream.reserve(notes.size() * 2);
for (auto &ev : notes) {
stream.push_back({(int)ev.tick_on, 0, ev.ch, ev.midi});
stream.push_back({(int)ev.tick_off, 1, ev.ch, ev.midi});
}
std::sort(stream.begin(), stream.end(),
[](const Tev &a, const Tev &b){
if (std::get<0>(a) != std::get<0>(b)) return std::get<0>(a) < std::get<0>(b);
return std::get<1>(a) < std::get<1>(b); // 同一 tick 先 on 後 off
});
// ── 為每個用到的聲道挑樂器GM program──
std::vector<int> used[9]; // 每聲道用過哪些音符
bool active[9] = {0,0,0,0,0,0,0,0,0};
for (auto &ev : notes) { if (ev.tick_off > ev.tick_on) { used[ev.ch].push_back(ev.midi); active[ev.ch] = 1; } }
int prog[9]; for (int c = 0; c < 9; ++c) prog[c] = pick_instrument(used[c]);
// ── 組 MIDI「音軌」位元組 ──
// MIDI 速度(微秒/四分音符讓「1 RIX tick = 1 MIDI tick」節奏就和原曲一致。
// 推導MIDI 每秒 tick 數 = (1e6/tempo)×96設它RIX 每秒 tick 數70
// 得 tempo = 1e6 × 96 ÷ 70 ≈ 1,371,428。
std::vector<char> track;
int tempo = (int)std::lround(1000000.0 * MIDI_DIV / RIX_TICKS_PER_SEC);
write_vlq(track, 0); // 開頭 delta=0
track.push_back(0xFF); track.push_back(0x51); track.push_back(0x03); // set_tempometa
track.push_back((char)((tempo >> 16) & 0xFF));
track.push_back((char)((tempo >> 8) & 0xFF));
track.push_back((char)(tempo & 0xFF));
for (int c = 0; c < 9; ++c) { // 各聲道的樂器(每個前補 delta=0避免 running-status 歧義)
if (!active[c]) continue;
track.push_back(0x00);
track.push_back((char)(0xC0 + c)); // program change聲道 c
track.push_back(prog[c]);
}
int last_tick = 0;
for (auto &tv : stream) {
int tick = std::get<0>(tv);
int delta = tick - last_tick; last_tick = tick;
write_vlq(track, delta);
int ch = std::get<2>(tv), note = std::get<3>(tv);
if (std::get<1>(tv) == 0) { // note on
track.push_back((char)(0x90 + ch)); track.push_back(note); track.push_back(110);
} else { // note offvelocity=0
track.push_back((char)(0x80 + ch)); track.push_back(note); track.push_back(0);
}
}
write_vlq(track, 0);
track.push_back(0xFF); track.push_back(0x2F); track.push_back(0x00); // End Of Track
// ── 寫 MIDI 檔MThd 標頭 + MTrk 音軌)──
FILE *fp = fopen(out_path.c_str(), "wb");
if (!fp) { if (verbose) fprintf(stderr, " [!] 打不開輸出檔\n"); return 4; }
auto W16 = [](FILE *f, int v){ char b[2]; b[0]=(v>>8)&0xFF; b[1]=v&0xFF; fwrite(b,1,2,f); };
auto W32 = [](FILE *f, int v){ char b[4]; b[0]=(v>>24)&0xFF; b[1]=(v>>16)&0xFF; b[2]=(v>>8)&0xFF; b[3]=v&0xFF; fwrite(b,1,4,f); };
fwrite("MThd", 1, 4, fp);
W32(fp, 6); // 標頭長度固定 6
W16(fp, 0); // format 0單一音軌
W16(fp, 1); // 音軌數 = 1
W16(fp, MIDI_DIV); // 96 ticks/beat
fwrite("MTrk", 1, 4, fp);
W32(fp, (int)track.size()); // 音軌位元組數
fwrite(track.data(), 1, track.size(), fp);
fclose(fp);
if (verbose) fprintf(stderr, " ticks=%u\n", t);
return 0;
}
// ============================================================================
// 5. 批次處理 / 主程式
// ============================================================================
static int process_file(const std::string &in, const std::string &outdir,
const std::string &out_path_override,
bool verbose, int &note_count, std::string &err)
{
// 先從路徑取「檔名」(去掉資料夾),再去掉 .RIX/.rix 副檔名。
std::string base = in;
auto slash = base.find_last_of('/');
if (slash != std::string::npos) base = base.substr(slash + 1);
if (base.size() > 4 && base.compare(base.size()-4, 4, ".RIX") == 0) base.erase(base.size()-4);
if (base.size() > 4 && base.compare(base.size()-4, 4, ".rix") == 0) base.erase(base.size()-4);
std::string out_path = out_path_override.empty() ? (outdir + "/" + base + ".mid") : out_path_override;
// 建輸出目錄(若不存在)
if (!out_path_override.empty()) {
::mkdir(outdir.c_str(), 0755); // 忽略「已存在」的回錯
}
int rc = convert_one(in, out_path, verbose, note_count);
if (rc == 0)
printf(" [OK] %s -> %s (%d 音符)\n", in.c_str(), out_path.c_str(), note_count);
else if (rc == 3)
printf(" [空] %s (抓到 0 個音符;可能太短或純效果音)\n", in.c_str());
else {
err = out_path;
printf(" [失敗 rc=%d] %s\n", rc, in.c_str());
}
return rc;
}
static void print_usage(const char *prog)
{
printf(
"rix2midi —— 把《大富翁 2》RIX 音樂轉成 MIDI\n"
"\n"
"用法:\n"
" %s <檔案.RIX> [輸出.mid] 轉換單一個 .RIX\n"
" %s <資料夾>/ [輸出資料夾]/ 批次轉換資料夾裡所有 .RIX\n"
" %s -v <…> 顯示詳細訊息tick 數等)\n"
"\n"
"範例:\n"
" %s RICHU.RIX # → RICHU.mid\n"
" %s ./rix ./mid # 批次 → ./mid/*.mid\n"
"\n", prog, prog, prog, prog, prog);
}
int main(int argc, char **argv)
{
bool verbose = false;
std::vector<std::string> args;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "-v" || a == "--verbose") verbose = true;
else if (a == "-h" || a == "--help") { print_usage(argv[0]); return 0; }
else args.push_back(a);
}
if (args.empty()) { print_usage(argv[0]); return 2; }
std::string first = args[0];
while (first.size() > 1 && first.back() == '/') first.pop_back(); // 去掉結尾多餘的 /
std::string out = (args.size() > 1) ? args[1] : "";
if (first.size() > 4 &&
(first.compare(first.size()-4, 4, ".RIX") == 0 || first.compare(first.size()-4, 4, ".rix") == 0)) {
// 單一檔
int n = 0; std::string err;
std::string outpath = out.empty() ? "" : out;
std::string base = first;
if (base.size() > 4) base.erase(base.size()-4);
if (base.size() > 4) base.erase(base.size()-4);
if (out.empty()) outpath = base + ".mid";
int rc = convert_one(first, outpath, verbose, n);
if (rc == 0) printf(" [OK] %-20s -> %s (%d 音符)\n", first.c_str(), outpath.c_str(), n);
else if (rc == 3) printf(" [空] %s\n", first.c_str());
else { printf(" [失敗 rc=%d] %s\n", rc, first.c_str()); return rc; }
return (rc == 0 || rc == 3) ? 0 : rc;
}
// 批次:把 first 當資料夾
struct dirent **list; int n = ::scandir(first.c_str(), &list, 0, 0);
if (n <= 0) { printf("[!] 資料夾不存在:%s\n", first.c_str()); return 4; }
std::string outdir = out.empty() ? (first + "-mid") : out;
::mkdir(outdir.c_str(), 0755);
int ok = 0, empty = 0, fail = 0;
// 依檔名排序,好讀
std::vector<std::string> files;
for (int i = 0; i < n; ++i) {
std::string fn = list[i]->d_name;
if (fn.size() > 4 && (fn.compare(fn.size()-4, 4, ".RIX") == 0 || fn.compare(fn.size()-4, 4, ".rix") == 0))
files.push_back(first + "/" + fn);
}
std::sort(files.begin(), files.end());
for (auto &f : files) {
int rcn = 0; std::string err;
int rc = process_file(f, outdir, "", verbose, rcn, err);
if (rc == 0) ++ok; else if (rc == 3) ++empty; else ++fail;
}
printf("批次完成:成功 %d空樂曲 %d失敗 %d → 輸出到 %s/\n", ok, empty, fail, outdir.c_str());
return fail ? 1 : 0;
}