Project: sid2midi & mod2midi — Rust CLI converters (design)

# sid2midi & mod2midi — Rust CLI Converters

Design & feasibility document. Two related CLI tools that extract musical scores to MIDI:
- **sid2midi**: `.sid` (PSID/RSID, Commodore 64) → MIDI
- **mod2midi**: `.mod/.s3m/.xm/.it` (trackers) → MIDI + instrument samples as WAV

---

## TL;DR

- **mod2midi first** — easy, well-foundational, and a real gap on Linux today.
- **sid2midi second** — harder (requires C64 emulation), validated against the existing reference tool.
- Share a **common musical-intelligence core**: arpeggio/vibrato/portamento render modes + MIDI timing model + midly writer.

---

## 1. SID → MIDI

### 1.1 The fundamental challenge
A `.sid` file contains **6502 machine code + a player routine**, NOT notes. The player programs the SID chip registers (`$D400–$D41F`) each video interrupt (50 Hz PAL / 60 Hz NTSC). Extraction requires:
1. Emulating a C64 (6502/6510 core + CIA timers; RSID needs more KERNAL/hardware fidelity than PSID).
2. **Logging SID register writes** over time.
3. Interpreting register state → notes.

> Key simplification: for *note extraction* you only need the **CPU + register-write logging**, NOT full SID audio synthesis. Audio (reSID) is optional (for reference WAV).

### 1.2 SID register model (3 voices)
| Register(s) | Meaning | MIDI mapping |
|---|---|---|
| Freq LO/HI (×3) | 16-bit osc frequency | `F_hz = freq * clock / 2^24` → MIDI note + pitch bend |
| Control (gate bit + waveform) | note on/off + shape | gate on→NoteOn, off→NoteOff |
| ADSR | envelope | note length / velocity |
| PulseWidth, Filter, Volume | shaping | mostly unmappable |

### 1.3 Reference tool: Michael Schwendt's `sid2midi`
- Origin 1995 (CL), GUI wrapper 2001 (v0.17.8). Shareware; many features locked to "registered".
- **Runs on Linux via Wine** (small Win32 apps — near-certain to work). Use as **golden reference/oracle**.
- **`-2txt` dumps** (register state over time) = ground truth for validating a Rust port.

Schwendt's algorithm (revealed by his flags):
| Flag | Reveals |
|---|---|
| `-ps` (pitch-bend sensitivity, default 96 semitones) | Uses **MIDI pitch bend to encode sub-semitone SID frequency** (the fidelity trick) |
| `-p2n` / `-pt` (threshold 1 semitone) | New NoteOn only when frequency crosses a semitone → held-note vs slide |
| `-if` (inhibit sustaining freq changes) | Held note despite minor jitter |
| `-env` (envelope emulation) | ADSR-aware note length optimization |
| `-noise` | Noise waveform → MIDI notes at a level |
| `-sam` | Digidrum (volume-register PCM) capture on a chosen channel |
| `-bpm`/`-ppq`/`-sc` | Tempo/timebase/speed control |

### 1.4 How to match, then beat Schwendt
- **Match**: register-logging core + pitch-bend fidelity (`-ps`) + NoteOn thresholding (`-pt`) + ADSR-aware lengths (`-env`) + noise/digidrum handling.
- **Beat**: NOT raw pitch accuracy (he nailed it) but **higher-level musical intelligence** + modern workflow:
  - **Arpeggio detection + `--arp {raw,chord,trill,hold}` switch** (see §4). SID "arpeggio" = a voice cycling a freq through a chord table every frame. Schwendt emits 50 NoteOns/sec (machine-gun, unmusical).
  - **Vibrato detection** → center note + single pitch-bend modulation (or omit).
  - **Slide/portamento detection** → single bent note / portamento CC instead of dozens of micro-NoteOns.
  - Pitch-bend range cleanup (96-semitone range confuses players).
  - Native/scriptable CLI, batch HVSC, first-class subtunes (`--subtune N` / `--all`), named tracks per voice + metadata track, optional WAV render via reSID.

### 1.5 Rust building blocks
- **6502/6510 core**: `mos6502` crate, or FFI to `libsidplay`/`reSID` (avoid reimplementing emulator).
- **SID chip**: register-state model (no audio needed); optional `reSID` for WAV.
- **PSID/RSID parsing**: small header spec (Dag Lem / SIDPlay).
- **MIDI writing**: `midly`.

---

## 2. MOD → MIDI (+ WAV samples)

### 2.1 Linux landscape — the gap is real
| Tool | Native Linux? | Renders WAV? | Exports MIDI? |
|---|---|---|---|
| OpenMPT (app) | ❌ Wine | ✅ | ✅ (reference) |
| libopenmpt / openmpt123 | ✅ | ✅ | ❌ (playback API only, no static note enumeration) |
| xmp / libxmp | ✅ | ✅ | ❌ |
| Schism Tracker | ✅ | ✅ | ⚠️ partial (saves IT, no clean MIDI) |
| MilkyTracker | ✅ (XM) | ✅ | ❌ |
| Renoise | ✅ | ✅ | ✅ but commercial DAW, not CLI |

**No clean native CLI module→MIDI extractor exists on Linux.** Rendering audio is trivial; MIDI score extraction is the missing piece. The notes are explicit in the file — just nobody wired a MIDI emitter to a parser in a cross-platform CLI.

### 2.2 Note encoding per format
| Format | Note encoding | Conversion |
|---|---|---|
| MOD | Amiga *periods* | period → note lookup + finetune |
| S3M | note numbers, C-4 = middle | near-direct |
| XM | note numbers (1-based) | direct-ish |
| IT | note numbers | direct-ish |

### 2.3 Rust building blocks (most of the work is done)
| Crate | Role | Covers |
|---|---|---|
| **xmrs** | Parse module → normalized notes/patterns/instruments | MOD, S3M, XM, IT (+more) |
| **xmodits** | Rip samples → WAV (already a working CLI/lib) | MOD, S3M, XM, IT |
| **midly** | Write MIDI file | SMF 0/1, all events |
| **hound** | WAV r/w | — |

> `xmodits` IS the "instruments as WAV" half. `xmrs` normalizes the four formats into one representation. The remaining work = translate rows/notes/effects/timing → `midly` events. **Difficulty: low–medium. No emulation.**

### 2.4 Beating OpenMPT's MIDI export
- **Match**: channel→track mapping, note translation, tempo/speed → MIDI tempo + PPQN.
- **Beat**: effect-translation fidelity (long tail), and the **`0xy` arpeggio effect** (`note, note+x, note+y` cycle) → the SAME `--arp` switch from SID (cleaner here: tick rate is explicit).
- **Beat workflow**: batch folders, per-track-named MIDI, WAV samples named to match instrument numbers referenced in the MIDI, static binary, no Wine.

### 2.5 Scope
- Difficulty: low–medium. Risk: effect-translation edge cases + tempo/tick math (formats define "tempo" slightly differently). All testable vs OpenMPT/Wine oracle.
- **One module = one song** (no subtunes like SID); optionally honor loop/restart points.

---

## 3. Shared architecture

Build **mod2midi first**, then **sid2midi**, sharing a common musical-intelligence core:

```
mod2midi  ──feeds notes──┐
                         ├──►  shared core  ──►  midly .mid
sid2midi  ──feeds notes──┘     • --arp render modes (raw/chord/trill/hold)
        (via register-log)      • vibrato/portamento classifiers
                                • MIDI timing model (PPQN)
                                • WAV sample dump
```

`mod2midi` de-risks the shared core on clean, explicit data before the harder SID tool consumes register-events on the same core.

---

## 4. Tracker→MIDI timing model

MIDI uses a clock in **PPQN** (pulses per quarter note) + tempo (µs per quarter). Trackers use **speed** (ticks per row) + **tempo** (rows… approx BPM). Mapping per format:

| Format | Speed | Tempo | BPM approx |
|---|---|---|---|
| MOD | `speed` (ticks/row) | via BPM table (`tempo` 0–1Fh, PAL: 125×tempo/2) | `BPM = 125 * tempo / 2` (PAL) |
| S3M | `speed` (ticks/row) | `tempo` (BPM directly) | `BPM = tempo` |
| XM | `speed` (ticks/row) | `tempo` (BPM) + `ticks_per_row=speed` | `BPM = tempo = 24*ticklen` where `ticklen = 2.5/tempo` s; rows/min = `tempo*24/(speed*6)` |
| IT | `speed`/`tempo` | as XM, plus row highlight | as XM |

**Conversion approach** (MIDI PPQN, e.g. 480):
- `ticks_per_row = PPQN * tempo / (24 * rows_per_beat)` — derive from format's row/beat definition.
- Each row = one event slot; advance MIDI clock by `ticks_per_row` per row; speed/tempo changes recompute.
- Prefer **MIDI tempo meta-events** to track tempo changes (Set Tempo A/B, XM/S3M tempo effects) rather than stretching the grid.

```
row_duration_sec = (speed * 0.025) / (tempo / 125)   # XM/S3M style
midi_ticks_per_row = PPQN * row_duration_sec * (BPM / 60)
```

---

## 5. Arpeggio render state machine (`--arp`, shared by both tools)

**Detection** (one window per voice, sliding over freq/note stream):
1. Find period P: smallest P such that `freq[t] ≈ freq[t+P]` ∀t in window.
2. Collect distinct quantized notes within one period → chord table {n1..nk}.
3. If pattern repeats stably ≥ M periods → classify as arpeggio.

**Render modes**:
| Mode | Behavior |
|---|---|
| `raw` | Emit frame-by-frame (Schwendt / tracker-native behavior; accurate but noisy) |
| `chord` | Sustain the note SET as a simultaneous chord (best for fast/1-frame arps) |
| `trill` | Render as ornamented melody at musical rate (best for multi-frame figures) |
| `hold` | Take most-frequent (root) value, ignore the rest |

**Rate-adaptive default**: period = 1 frame → ornament → default `chord`/`hold`; period = several frames → melodic figure → default `trill`/`raw`.

- **SID**: period inferred from register writes (50/60 Hz frame).
- **Modules**: period explicit = tick rate × `speed` (ticks/row); `0xy` cycles note/note+x/note+y each tick.

---

## 6. Proposed Cargo layout

```
retromidi/                          (workspace)
├── Cargo.toml                      [workspace]
├── crates/
│   ├── midi-core/                  # shared musical-intelligence core
│   │   ├── arpeggio                # detection + render modes (--arp)
│   │   ├── effects                 # vibrato/portamento/noise classifiers
│   │   ├── timing                  # tracker↔MIDI PPQN timing model
│   │   └── writer                  # midly-based SMF emitter, track naming
│   ├── sid2midi/
│   │   ├── parser                  # PSID/RSID header
│   │   ├── emulate                 # 6502 core + register-log (or FFI sidplay/reSID)
│   │   ├── interpret               # register events → note stream
│   │   └── bin/sid2midi            # --subtune/--all/--arp/--render-wav
│   └── mod2midi/
│       ├── parse                   # via xmrs
│       ├── samples                 # via xmodits/hound
│       └── bin/mod2midi            # --arp/--format/...
└── tests/                          # HVSC + module corpus, oracle comparison
```

**Dependencies**
- `midi-core`: `midly`, `serde` (config), `thiserror`.
- `sid2midi`: `mos6502` (or `sidplay`/`reSID` FFI), optional `hound` for WAV render.
- `mod2midi`: `xmrs`, `xmodits` (or `hound`), `clap`.

---

## 7. Build order & milestones

1. **Wine oracle**: run Schwendt + OpenMPT on a sample corpus; save `.mid` + `-2txt`/dumps as reference.
2. **mod2midi MVP**: `xmrs` parse → notes + tempo → `midly` out; `xmodits` sample dump. Compare to OpenMPT.
3. **midi-core `--arp`**: detection + render modes (validate on modules — clean data).
4. **midi-core effects**: vibrato/portamento/noise translation.
5. **sid2midi MVP**: emulator + register-log → naive round-to-note MIDI. Compare to Schwendt raw.
6. **sid2midi parity**: pitch-bend fidelity + thresholding + ADSR lengths.
7. **sid2midi beat**: plug into midi-core (`--arp`, vibrato/slide). Subtunes, batch, metadata tracks.

---

## 8. Key references
- Schwendt sid2midi (reference): https://www.vgmpf.com/Wiki/index.php/SID_to_MIDI , .../SID_to_MIDI_(Windows)
- PSID/RSID spec (Dag Lem / SIDPlay)
- HVSC — test corpus for SID
- `xmrs` (module parser), `xmodits` (sample ripper), `midly` (MIDI), `hound` (WAV)
- OpenMPT (MIDI export reference), `xmp`/libxmp, Schism (native Linux playback)

*Created 2026-06-25 — design/feasibility.*

id: 05cf4f1286554c7f8f9d214991bb1063
parent_id: eba769e65bbc4a989a25494a364bf4c6
created_time: 2026-06-25T14:05:47.320Z
updated_time: 2026-06-25T14:05:47.320Z
is_conflict: 0
latitude: 0.00000000
longitude: 0.00000000
altitude: 0.0000
author: 
source_url: 
is_todo: 0
todo_due: 0
todo_completed: 0
source: joplin-desktop
source_application: net.cozic.joplin-desktop
application_data: 
order: 1782396347320
user_created_time: 2026-06-25T14:05:47.320Z
user_updated_time: 2026-06-25T14:05:47.320Z
encryption_cipher_text: 
encryption_applied: 0
markup_language: 1
is_shared: 0
share_id: 
conflict_original_id: 
master_key_id: 
user_data: 
deleted_time: 0
type_: 1