← 回到轉換器

# MID → FEM 高保真轉換規格

對象:SEGA TOYS Grand Pianist(2007,88 鍵電磁閥自動演奏迷你鋼琴)專用 `.fem` 檔。
對應實作:`converter/mid2fem_hifi.py`。本文件逐條描述該實作的行為。

## 核心前提

**Grand Pianist 裝置本身可播放一般的 format-0 單通道標準 MIDI 檔。** 逆向驗證(見 `FEM_CONVERSION_SPEC.md`)顯示官方 `.fem` 一律是 120 BPM、PPQ 48、音符時值固定 32 tick——但這些是**原廠轉換工具自己加的限制**,不是裝置的硬體需求。裝置播放時只是照 SMF 的 tempo map 與 delta-time 驅動電磁閥;它不在乎 PPQ 是不是 48、tempo 是不是固定 120。

因此本規格的目標是:**在裝置物理限制(88 鍵、琴鍵機構復位時間、電磁閥驅動能力、無踏板輸入、無打擊樂)之內,最大化保留來源音樂檔的表現**——速度變化、音符長短、力度層次、踏板效果盡量不丟。

輸出容器:format-0、單音軌、全部事件在 channel 0、結尾 EOT(`FF 2F 00`)。PPQ 直接沿用來源檔的 division。

## 轉換規則

### 1. 時間軸零損失:保留來源 PPQ 與完整 tempo map

- 不做任何 tick 重採樣:來源 PPQ(division)原樣寫入輸出標頭,所有事件 tick 原值保留。
- 收集來源所有軌的 tempo meta(`FF 51`),以 tick 為鍵去重(同 tick 取先出現者),全部寫回輸出;同 tick 排序時 tempo 事件排在音符之前(priority 0 < note-off 1 < note-on 2)。
- 來源完全沒有 tempo 事件時,補一個預設 500000 µs/拍(120 BPM),確保檔案合法。
- **為什麼**:裝置依 SMF 標準解讀 delta-time 與 tempo,PPQ 與 tempo map 不影響機構動作。轉 120 BPM 反而會把漸快/漸慢(rit./accel.)全部抹平——那是工具限制,不是裝置限制。

### 2. 併軌:所有軌/所有 channel 合併到 ch0,丟 ch9 鼓

- 逐一掃描所有音軌,除 ch9 外的 note-on/off 全部收進同一個音符池,輸出統一寫 channel 0。
- Channel 9(GM 鼓通道)的 note-on/off 直接略過。
- 若丟掉鼓之後一個音符都不剩,`convert()` 拋出 `ValueError: no playable notes after removing drums`。
- **為什麼**:裝置只有一架 88 鍵鋼琴,沒有打擊樂機構;format-0 單通道是它實際播放的形式,多 channel 資訊對琴鍵動作沒有意義。

### 3. 音域 21–108(88 鍵),越界八度折疊而非丟棄

- 低於 21 的音以 +12 反覆升八度、高於 108 的音以 −12 反覆降八度,直到落入 21–108。
- 統計折疊數量(`stats["folded"]`)。
- **為什麼**:實體琴只有 88 支電磁閥,21–108 以外的鍵不存在;但直接丟棄會讓旋律線出洞,折疊八度至少保住節奏與輪廓。

### 4. 同 onset 同音高去重:取最大力度+最長時值

- 音符池依 (onset, pitch) 排序後,相鄰且 (onset, pitch) 相同者合併為一個:力度取 `max`、note-off 取最晚(時值最長)。
- **為什麼**:同一瞬間同一支電磁閥本來就只能吸合一次,重複音符在硬體上無意義;取最大力度對應「敲重一點」、取最長時值避免過早放鍵,是最不損失資訊的合併方式。

### 5. 同鍵重觸發:前一個 note-off 提前 gap = PPQ // 24 tick

- 依音高分組,若前一音的 note-off 晚於「後一音 onset − gap」,就把前一音截短到 `max(onset+1, 後一音onset − gap)`。
- `gap = max(1, ppq // 24)`,約等於 120 BPM 下的 20 ms(隨 PPQ 縮放,所以實際秒數隨 tempo 正確變化)。
- **為什麼**:電磁閥放開琴鍵後,琴鍵機構需要一小段時間復位才能再次擊弦;不重疊才能保證第二次觸鍵真的發聲,否則快速同音反覆會吃音。

### 6. CC64 踏板烘焙進音符時值(可用 `--no-pedal` 關閉)

- 逐 channel 追蹤 CC64(值 ≥ 64 視為踩下)。踏板踩下時到來的 note-off 不立即生效,音符改掛進「待放音」清單,持續響到該 channel 下一次 pedal-up(CC64 < 64)的 tick 才關閉。
- 軌尾仍開著的音符與未放開的踏板音,一律關閉在該軌最後一個事件的 tick。
- **為什麼**:裝置沒有延音踏板機構,鋼琴譜上靠踏板延長的殘響若照原 note-off 播放會變成乾癟的斷奏;把踏板「彈性延長」直接烘焙進音符長度,是唯一能在這台琴上重現踏板效果的方法。

### 7. 複音上限(預設 20,可用 `--cap` 調整或設 0 關閉)

- 依 onset 順序掃描,維護「目前正發聲」清單(note-off 晚於當前 onset 者)。
- 爆滿時,在「發聲中音符+新音」池中找出音高最高與最低者保護起來,其餘候選中丟掉力度最小的一個;被丟的可能是新音,也可能是已在響的內聲部(後者從輸出移除)。
- 統計丟棄數(`stats["dropped_cap"]`)。
- **為什麼**:密集和絃會讓大量電磁閥同時吸合,超過電源/機構負荷會造成漏音、抖動甚至當機重啟(密集檔實測會 stutter/reset);20 是經驗安全值。保護最高/最低音是因為旋律與低音線聽感上最顯著,犧牲內聲部最不易察覺——這同時也是對原廠工具「和絃削減」啟發式(見 `FEM_CONVERSION_SPEC.md` 第 5 節)的近似。

### 8. 力度預設保留,可選 `--vol-cc` 把 CC7/CC11 折入

- 預設:note-on 力度原樣輸出。
- 加 `--vol-cc` 時,逐 channel 追蹤 CC7(volume)與 CC11(expression),每個 note-on 的力度改為 `max(1, round(vel × cc7/127 × cc11/127))`(以 note-on 當下最新的 CC 值計)。
- **為什麼**:電磁閥擊弦強度由 note-on 力度決定,channel volume/expression 在裝置上沒有對應機制;折進力度能保留 MIDI 裡靠 CC 做出的強弱層次(常見於多 channel 配器檔),代價是力度不再等於原譜記譜值,所以做成可選。

### 9. 其餘事件全刪;note-off 一律 `0x80`、力度 64

- 刪除:Program Change、所有 CC(處理完踏板/音量用途後不輸出)、Pitch Bend、Aftertouch、SysEx、歌詞/文字/拍號/調號等所有 meta(tempo 除外)。
- 輸出檔只含:`FF 51` tempo、`0x90` note-on、`0x80` note-off(力度固定 64)、`FF 2F` EOT。
- **為什麼**:裝置只認琴鍵動作與速度,其餘事件不是被忽略就是可能造成非預期行為;刪乾淨讓檔案最小、行為最可預測。note-off 用 `0x80` vel 64 是原廠 `.fem` 的一貫寫法(逆向實測),照用確保相容。

## 與原廠工具規格(FEM_CONVERSION_SPEC.md)對照

| 項目 | 原廠工具 | 高保真(本規格) |
|---|---|---|
| PPQ | 固定 48 | 沿用來源 |
| Tempo | 固定 500000(120 BPM),tempo map 捨棄 | 完整保留來源 tempo map |
| 時間軸 | tick × 48/PPQ 四捨五入,平移到首音 onset=0 | 不重採樣、不平移,零損失 |
| 音符時值 | 一律固定 32 tick | 保留來源長短(可選 `--max-dur` 設上限) |
| 越界音 | 無樣本可驗證 | 八度折疊進 21–108 |
| 同 onset 同音高去重 | 取最大力度 | 取最大力度+最長時值 |
| 同鍵重觸發 | 不明(併入未破解的削減啟發式) | 前一音提前 PPQ//24 tick 放鍵 |
| CC64 踏板 | 刪除 | 烘焙進音符時值(可關閉) |
| CC7/CC11 | 刪除 | 預設刪除,`--vol-cc` 可折入力度 |
| 和絃/複音削減 | 啟發式未破解(約 1–10%) | 明確的複音上限(預設 20),丟最弱內聲部、保護最高/最低音 |
| ch9 鼓 | 丟棄 | 丟棄(相同) |
| 其餘事件 | 全刪 | 全刪(相同) |
| Note-off 寫法 | `0x80` vel 64 | `0x80` vel 64(相同) |
| 輸出容器 | Format 0 / 單軌 / ch0 | Format 0 / 單軌 / ch0(相同) |

## CLI 參數

```
python mid2fem_hifi.py <input.mid> [output.fem] [--cap N] [--no-pedal] [--vol-cc] [--max-dur SEC]
```

- `input.mid`:來源 MIDI 檔(任意 format / PPQ / 多軌皆可)。
- `output.fem`:輸出路徑;省略時為來源檔名換副檔名 `.fem`。
- `--cap N`:同時發聲音符上限,預設 20。爆滿時丟最低力度內聲部(保護最高/最低音)。`--cap 0` 關閉上限。
- `--no-pedal`:關閉 CC64 踏板烘焙,音符完全照原 note-off 時值。
- `--vol-cc`:把 channel 的 CC7(音量)× CC11(表情)折入 note-on 力度;不加則力度原樣保留。
- `--max-dur SEC`:所有音符時值上限(秒)。內部以 120 BPM 參考換算為 `int(SEC × 2 × PPQ)` tick 截斷——因為裝置對過長的壓鍵本來就不理會,截斷可避免無意義的長 note-off 佔位。不給則無上限。

執行後輸出統計:最終音符數、八度折疊數、複音上限丟棄數、PPQ 與檔案大小。