initial commit!
This commit is contained in:
905
audio/convert_midi.py
Normal file
905
audio/convert_midi.py
Normal file
@@ -0,0 +1,905 @@
|
||||
"""
|
||||
convert_midi.py
|
||||
|
||||
Convert standard MIDI files to Virtual Boy 4-channel music data.
|
||||
|
||||
Output per song (C header):
|
||||
- int prefix_melody[N] : (vel << 12) | (midi_note << 4) for ch0
|
||||
- int prefix_bass[N] : (vel << 12) | (midi_note << 4) for ch1
|
||||
- int prefix_chords[N] : (vel << 12) | (midi_note << 4) for ch3
|
||||
- int prefix_drums[N] : SFX ID (doom_sfx.h) for PCM drum samples (0=silence)
|
||||
- u16 prefix_timing[N] : shared duration in milliseconds per step
|
||||
- u8 prefix_arp[N] : arpeggio offsets (optional, 0=none)
|
||||
- #define PREFIX_NOTE_COUNT N
|
||||
|
||||
All four channels share a single timing array (lock-step).
|
||||
Timing values are in milliseconds for rate-independent playback.
|
||||
|
||||
Tonal channels (melody/bass/chords) encode MIDI note number:
|
||||
bits 15-12 = velocity (0-15, where 0 means silence/rest)
|
||||
bits 10-4 = MIDI note number (0-127)
|
||||
bits 11, 3-0 = unused (0)
|
||||
The VB player uses a lookup table to convert MIDI note -> VB freq register.
|
||||
|
||||
Arpeggio array (tracker-style 0xy effect):
|
||||
bits 7-4 = semitone offset for 2nd note (0-15)
|
||||
bits 3-0 = semitone offset for 3rd note (0-15)
|
||||
0x00 = no arpeggio (hold base note)
|
||||
Player cycles: base, base+hi_nib, base+lo_nib at ~50Hz frame rate.
|
||||
|
||||
Drums are encoded differently in bits 14-12 and 11-0:
|
||||
bits 14-12 = noise tap register (0-7, controls timbre)
|
||||
bits 11-0 = noise frequency register
|
||||
Value 0 = silence (rest)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import mido
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
MUSIC_DIR = os.path.join(SCRIPT_DIR, "music")
|
||||
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "src", "vbdoom", "assets", "audio")
|
||||
|
||||
# VB frequency register values for MIDI note numbers 0-127.
|
||||
# Taken directly from dph9.h (the proven VB note table).
|
||||
MIDI_TO_VB_FREQ = [
|
||||
# 0-11: C-1 to B-1 (inaudible)
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
# 12-23: C0 to B0 (inaudible)
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
# 24-35: C1 to B1 (inaudible)
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
# 36-39: C2 to D#2 (inaudible)
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
# 40: E2 41: F2 42: F#2 43: G2 44: G#2 45: A2 46: A#2 47: B2
|
||||
0x02C, 0x09C, 0x106, 0x16B, 0x1C9, 0x223, 0x277, 0x2C6,
|
||||
# 48: C3 49: C#3 50: D3 51: D#3 52: E3 53: F3 54: F#3 55: G3
|
||||
0x312, 0x356, 0x39B, 0x3DA, 0x416, 0x44E, 0x483, 0x4B5,
|
||||
# 56: G#3 57: A3 58: A#3 59: B3 60: C4 61: C#4 62: D4 63: D#4
|
||||
0x4E5, 0x511, 0x53B, 0x563, 0x589, 0x5AC, 0x5CE, 0x5ED,
|
||||
# 64: E4 65: F4 66: F#4 67: G4 68: G#4 69: A4 70: A#4 71: B4
|
||||
0x60A, 0x627, 0x642, 0x65B, 0x672, 0x689, 0x69E, 0x6B2,
|
||||
# 72: C5 73: C#5 74: D5 75: D#5 76: E5 77: F5 78: F#5 79: G5
|
||||
0x6C4, 0x6D6, 0x6E7, 0x6F7, 0x706, 0x714, 0x721, 0x72D,
|
||||
# 80: G#5 81: A5 82: A#5 83: B5 84: C6 85: C#6 86: D6 87: D#6
|
||||
0x739, 0x744, 0x74F, 0x759, 0x762, 0x76B, 0x773, 0x77B,
|
||||
# 88: E6 89: F6 90: F#6 91: G6 92: G#6 93: A6 94: A#6 95: B6
|
||||
0x783, 0x78A, 0x790, 0x797, 0x79D, 0x7A2, 0x7A7, 0x7AC,
|
||||
# 96: C7 97: C#7 98: D7 99: D#7 100: E7 101: F7 102: F#7 103: G7
|
||||
0x7B1, 0x7B6, 0x7BA, 0x7BE, 0x7C1, 0x7C4, 0x7C8, 0x7CB,
|
||||
# 104: G#7 105: A7 106: A#7 107: B7 108: C8 109: C#8 110: D8 111: D#8
|
||||
0x7CE, 0x7D1, 0x7D4, 0x7D6, 0x7D9, 0x7DB, 0x7DD, 0x7DF,
|
||||
# 112-119: E8+ (above VB audible range)
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
# 120-127: way above range
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]
|
||||
|
||||
PAU = 0x000 # Silence
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# General MIDI drum note to PCM SFX ID mapping
|
||||
#
|
||||
# All drums are now played as PCM samples on the player/enemy
|
||||
# channels (ch4/ch2), not the noise channel. Each entry maps a
|
||||
# GM drum note number to an SFX_DRUM_* ID from doom_sfx.h.
|
||||
# The value stored in the music header is the SFX ID directly;
|
||||
# 0 = silence.
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
GM_DRUM_MAP = {
|
||||
# Kick drums (MIDI 35-36)
|
||||
35: 28, # Acoustic Bass Drum -> SFX_DRUM_KICK
|
||||
36: 28, # Bass Drum 1 -> SFX_DRUM_KICK
|
||||
|
||||
# Snare drums (MIDI 37-40)
|
||||
37: 35, # Side Stick -> SFX_DRUM_SNARE_SIDEHIT
|
||||
38: 29, # Acoustic Snare -> SFX_DRUM_SNARE
|
||||
39: 34, # Hand Clap -> SFX_DRUM_CLAP
|
||||
40: 36, # Electric Snare -> SFX_DRUM_SNARE2
|
||||
|
||||
# Toms (MIDI 41-50)
|
||||
41: 32, # Low Floor Tom -> SFX_DRUM_TOM_LOW
|
||||
43: 32, # High Floor Tom -> SFX_DRUM_TOM_LOW
|
||||
45: 32, # Low Tom -> SFX_DRUM_TOM_LOW
|
||||
47: 33, # Low-Mid Tom -> SFX_DRUM_TOM_BRIGHT
|
||||
48: 33, # Hi-Mid Tom -> SFX_DRUM_TOM_BRIGHT
|
||||
50: 33, # High Tom -> SFX_DRUM_TOM_BRIGHT
|
||||
|
||||
# Hi-hat (MIDI 42, 44, 46)
|
||||
42: 30, # Closed Hi-Hat -> SFX_DRUM_HIHAT
|
||||
44: 30, # Pedal Hi-Hat -> SFX_DRUM_HIHAT
|
||||
46: 30, # Open Hi-Hat -> SFX_DRUM_HIHAT
|
||||
|
||||
# Cymbals (MIDI 49-59)
|
||||
49: 31, # Crash Cymbal 1 -> SFX_DRUM_CRASH
|
||||
51: 31, # Ride Cymbal 1 -> SFX_DRUM_CRASH
|
||||
52: 31, # Chinese Cymbal -> SFX_DRUM_CRASH
|
||||
55: 31, # Splash Cymbal -> SFX_DRUM_CRASH
|
||||
57: 31, # Crash Cymbal 2 -> SFX_DRUM_CRASH
|
||||
59: 31, # Ride Cymbal 2 -> SFX_DRUM_CRASH
|
||||
|
||||
# Miscellaneous
|
||||
53: 30, # Ride Bell -> SFX_DRUM_HIHAT
|
||||
54: 34, # Tambourine -> SFX_DRUM_CLAP
|
||||
56: 34, # Cowbell -> SFX_DRUM_CLAP
|
||||
}
|
||||
|
||||
# Default fallback for unmapped GM drum notes
|
||||
GM_DRUM_DEFAULT = 29 # SFX_DRUM_SNARE
|
||||
|
||||
|
||||
def gm_drum_to_packed(midi_note):
|
||||
"""Convert a GM drum note number to its PCM SFX ID (0 = silence)."""
|
||||
return GM_DRUM_MAP.get(midi_note, GM_DRUM_DEFAULT)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Utility
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def build_tempo_map(mid):
|
||||
"""Build a sorted list of (tick, tempo_us) from all tracks."""
|
||||
tempo_map = []
|
||||
for track in mid.tracks:
|
||||
t = 0
|
||||
for msg in track:
|
||||
t += msg.time
|
||||
if msg.type == 'set_tempo':
|
||||
tempo_map.append((t, msg.tempo))
|
||||
if not tempo_map:
|
||||
tempo_map = [(0, 500000)] # default 120 BPM only if no set_tempo found
|
||||
tempo_map.sort()
|
||||
return tempo_map
|
||||
|
||||
|
||||
def get_tempo_at(tempo_map, tick):
|
||||
"""Return the tempo (in us/beat) active at the given tick."""
|
||||
t = 500000
|
||||
for tt, tp in tempo_map:
|
||||
if tt <= tick:
|
||||
t = tp
|
||||
else:
|
||||
break
|
||||
return t
|
||||
|
||||
|
||||
def ticks_to_ms(ticks, tpb, tempo_us):
|
||||
"""Convert MIDI ticks to milliseconds."""
|
||||
if ticks <= 0:
|
||||
return 0
|
||||
ms = (ticks * tempo_us) / (tpb * 1000)
|
||||
return max(1, round(ms))
|
||||
|
||||
|
||||
def note_to_freq(midi_note):
|
||||
"""Convert MIDI note number to VB frequency register value."""
|
||||
if 0 <= midi_note < 128:
|
||||
return MIDI_TO_VB_FREQ[midi_note]
|
||||
return 0
|
||||
|
||||
|
||||
def velocity_to_4bit(velocity):
|
||||
"""Convert MIDI velocity (0-127) to 4-bit (1-15). 0 velocity = rest."""
|
||||
if velocity <= 0:
|
||||
return 0
|
||||
# Map 1-127 to 1-15
|
||||
return max(1, min(15, (velocity * 15 + 63) // 127))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Note span extraction (monophonic per channel)
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def extract_note_spans(mid, track_idx, ch_filter, transpose=0):
|
||||
"""
|
||||
Extract note spans from specified track(s) and channels.
|
||||
Returns a sorted list of (start_tick, end_tick, midi_note, velocity).
|
||||
Monophonic: new note-on cuts any previous note.
|
||||
|
||||
track_idx: int (specific track) or None (scan all tracks).
|
||||
ch_filter: set of allowed MIDI channel numbers.
|
||||
transpose: semitones to shift notes (e.g., +24 = up 2 octaves).
|
||||
"""
|
||||
if track_idx is not None:
|
||||
tracks = [(track_idx, mid.tracks[track_idx])]
|
||||
else:
|
||||
tracks = list(enumerate(mid.tracks))
|
||||
|
||||
# Collect raw events: (abs_tick, type, midi_note, velocity)
|
||||
events = []
|
||||
for ti, track in tracks:
|
||||
abs_t = 0
|
||||
for msg in track:
|
||||
abs_t += msg.time
|
||||
if not hasattr(msg, 'channel') or msg.channel not in ch_filter:
|
||||
continue
|
||||
if msg.type == 'note_on' and msg.velocity > 0:
|
||||
note = min(127, max(0, msg.note + transpose))
|
||||
events.append((abs_t, 1, note, msg.velocity)) # 1 = on
|
||||
elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
|
||||
note = min(127, max(0, msg.note + transpose))
|
||||
events.append((abs_t, 0, note, 0)) # 0 = off
|
||||
|
||||
events.sort(key=lambda e: (e[0], e[1])) # off before on at same tick
|
||||
|
||||
# Convert to non-overlapping spans (monophonic)
|
||||
spans = []
|
||||
current_note = -1
|
||||
current_vel = 0
|
||||
note_start = 0
|
||||
|
||||
for abs_t, etype, midi_note, vel in events:
|
||||
if etype == 1: # note on
|
||||
if current_note >= 0 and abs_t > note_start:
|
||||
spans.append((note_start, abs_t, current_note, current_vel))
|
||||
current_note = midi_note
|
||||
current_vel = vel
|
||||
note_start = abs_t
|
||||
elif etype == 0 and midi_note == current_note: # note off
|
||||
if abs_t > note_start:
|
||||
spans.append((note_start, abs_t, current_note, current_vel))
|
||||
current_note = -1
|
||||
|
||||
return spans
|
||||
|
||||
|
||||
def extract_drum_spans(mid, track_idx=None):
|
||||
"""
|
||||
Extract drum hits from MIDI channel 9 (zero-indexed).
|
||||
Returns a sorted list of (start_tick, end_tick, midi_note, velocity).
|
||||
Each hit is treated as a short burst; the end_tick is set to the
|
||||
next drum hit or start_tick + reasonable duration.
|
||||
"""
|
||||
drum_ch = {9} # GM drums are always channel 9 (zero-indexed)
|
||||
|
||||
if track_idx is not None:
|
||||
tracks = [(track_idx, mid.tracks[track_idx])]
|
||||
else:
|
||||
tracks = list(enumerate(mid.tracks))
|
||||
|
||||
# Collect note-on events for drum channel
|
||||
hits = []
|
||||
for ti, track in tracks:
|
||||
abs_t = 0
|
||||
for msg in track:
|
||||
abs_t += msg.time
|
||||
if not hasattr(msg, 'channel') or msg.channel not in drum_ch:
|
||||
continue
|
||||
if msg.type == 'note_on' and msg.velocity > 0:
|
||||
hits.append((abs_t, msg.note, msg.velocity))
|
||||
|
||||
hits.sort(key=lambda h: h[0])
|
||||
|
||||
if not hits:
|
||||
return []
|
||||
|
||||
# Convert hits to spans: each hit lasts until the next hit
|
||||
spans = []
|
||||
for i in range(len(hits)):
|
||||
start = hits[i][0]
|
||||
note = hits[i][1]
|
||||
vel = hits[i][2]
|
||||
# End at next hit or start + small offset
|
||||
if i + 1 < len(hits):
|
||||
end = hits[i + 1][0]
|
||||
if end == start:
|
||||
end = start + 1
|
||||
else:
|
||||
end = start + 120 # ~1 beat at 120bpm/480tpb
|
||||
spans.append((start, end, note, vel))
|
||||
|
||||
return spans
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Drum priority for filtering simultaneous hits
|
||||
# Lower number = higher priority
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
DRUM_PRIORITY = {}
|
||||
for _n in (35, 36): DRUM_PRIORITY[_n] = 1 # Kick
|
||||
for _n in (37, 38, 39, 40): DRUM_PRIORITY[_n] = 2 # Snare
|
||||
for _n in (41, 43, 45, 47, 48, 50): DRUM_PRIORITY[_n] = 3 # Toms
|
||||
for _n in (53, 54, 56): DRUM_PRIORITY[_n] = 4 # Misc perc
|
||||
for _n in (49, 51, 52, 55, 57, 59): DRUM_PRIORITY[_n] = 5 # Cymbals
|
||||
for _n in (42, 44, 46): DRUM_PRIORITY[_n] = 6 # Hihat
|
||||
|
||||
|
||||
def extract_drum_spans_prioritized(mid, track_idx=None):
|
||||
"""
|
||||
Extract drum hits with priority filtering.
|
||||
When multiple hits occur at the same tick, keep only the highest priority.
|
||||
Priority: kick > snare > toms > misc > cymbals > hihat.
|
||||
"""
|
||||
drum_ch = {9}
|
||||
|
||||
if track_idx is not None:
|
||||
tracks = [(track_idx, mid.tracks[track_idx])]
|
||||
else:
|
||||
tracks = list(enumerate(mid.tracks))
|
||||
|
||||
hits = []
|
||||
for ti, track in tracks:
|
||||
abs_t = 0
|
||||
for msg in track:
|
||||
abs_t += msg.time
|
||||
if not hasattr(msg, 'channel') or msg.channel not in drum_ch:
|
||||
continue
|
||||
if msg.type == 'note_on' and msg.velocity > 0:
|
||||
hits.append((abs_t, msg.note, msg.velocity))
|
||||
|
||||
hits.sort(key=lambda h: h[0])
|
||||
|
||||
if not hits:
|
||||
return []
|
||||
|
||||
# Filter simultaneous hits by priority (keep highest priority per tick)
|
||||
filtered = []
|
||||
i = 0
|
||||
while i < len(hits):
|
||||
j = i
|
||||
while j < len(hits) and hits[j][0] == hits[i][0]:
|
||||
j += 1
|
||||
group = hits[i:j]
|
||||
best = min(group, key=lambda h: DRUM_PRIORITY.get(h[1], 99))
|
||||
filtered.append(best)
|
||||
i = j
|
||||
hits = filtered
|
||||
|
||||
# Convert hits to spans
|
||||
spans = []
|
||||
for i in range(len(hits)):
|
||||
start = hits[i][0]
|
||||
note = hits[i][1]
|
||||
vel = hits[i][2]
|
||||
if i + 1 < len(hits):
|
||||
end = hits[i + 1][0]
|
||||
if end == start:
|
||||
end = start + 1
|
||||
else:
|
||||
end = start + 120
|
||||
spans.append((start, end, note, vel))
|
||||
|
||||
return spans
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Tracker-style arpeggio: compact data, runtime cycling
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def build_arpeggio_for_merge(mid, track_configs):
|
||||
"""
|
||||
Build merged monophonic spans and arpeggio offset map from multiple tracks.
|
||||
No time subdivision -- arpeggios are handled at runtime by the VB player.
|
||||
|
||||
track_configs: list of (track_idx, channel_set, transpose)
|
||||
|
||||
Returns: (base_spans, arp_intervals)
|
||||
base_spans: sorted list of (start_tick, end_tick, midi_note, velocity)
|
||||
using the lowest active note as the base.
|
||||
arp_intervals: sorted list of (start_tick, end_tick, offset1, offset2)
|
||||
semitone offsets for the 2nd and 3rd arp notes (0-15 each).
|
||||
"""
|
||||
# Extract spans from each track independently
|
||||
per_track = []
|
||||
for track_idx, channels, transpose in track_configs:
|
||||
spans = extract_note_spans(mid, track_idx, channels, transpose)
|
||||
per_track.append(spans)
|
||||
|
||||
# Collect all tick boundaries from all tracks
|
||||
tick_set = set()
|
||||
for spans in per_track:
|
||||
for start, end, _, _ in spans:
|
||||
tick_set.add(start)
|
||||
tick_set.add(end)
|
||||
|
||||
if not tick_set:
|
||||
return [], []
|
||||
|
||||
sorted_ticks = sorted(tick_set)
|
||||
base_spans = []
|
||||
arp_intervals = []
|
||||
|
||||
# Index pointers for each track's span list (advance monotonically)
|
||||
ptrs = [0] * len(per_track)
|
||||
|
||||
for i in range(len(sorted_ticks) - 1):
|
||||
t_start = sorted_ticks[i]
|
||||
t_end = sorted_ticks[i + 1]
|
||||
|
||||
# Find active notes from each track at t_start
|
||||
active = []
|
||||
for ti, spans in enumerate(per_track):
|
||||
while ptrs[ti] < len(spans) and spans[ptrs[ti]][1] <= t_start:
|
||||
ptrs[ti] += 1
|
||||
idx = ptrs[ti]
|
||||
if idx < len(spans) and spans[idx][0] <= t_start < spans[idx][1]:
|
||||
active.append((spans[idx][2], spans[idx][3]))
|
||||
|
||||
if not active:
|
||||
continue # silence -- merge_channels will fill with 0
|
||||
|
||||
# Sort by pitch ascending for rising arpeggio pattern
|
||||
active.sort(key=lambda x: x[0])
|
||||
|
||||
base_note = active[0][0]
|
||||
base_vel = active[0][1]
|
||||
base_spans.append((t_start, t_end, base_note, base_vel))
|
||||
|
||||
# Compute semitone offsets for 2nd and 3rd notes (clamped to 4-bit 0-15)
|
||||
off1 = min(15, active[1][0] - base_note) if len(active) > 1 else 0
|
||||
off2 = min(15, active[2][0] - base_note) if len(active) > 2 else 0
|
||||
|
||||
if off1 > 0 or off2 > 0:
|
||||
arp_intervals.append((t_start, t_end, off1, off2))
|
||||
|
||||
return base_spans, arp_intervals
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Auto-detect channels for type-0 MIDIs
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def detect_channels_type0(mid):
|
||||
"""
|
||||
For type-0 MIDIs, group notes by MIDI channel and classify by avg pitch.
|
||||
Returns (melody_channels, bass_channels, chord_channels) as sets.
|
||||
"""
|
||||
ch_pitches = {}
|
||||
for track in mid.tracks:
|
||||
abs_t = 0
|
||||
for msg in track:
|
||||
abs_t += msg.time
|
||||
if msg.type == 'note_on' and msg.velocity > 0 and hasattr(msg, 'channel'):
|
||||
if msg.channel == 9: # skip drums
|
||||
continue
|
||||
ch_pitches.setdefault(msg.channel, []).append(msg.note)
|
||||
|
||||
if not ch_pitches:
|
||||
return set(), set(), set()
|
||||
|
||||
# Sort channels by average pitch (ascending)
|
||||
ch_avg = sorted(
|
||||
[(ch, sum(p)/len(p), len(p)) for ch, p in ch_pitches.items()],
|
||||
key=lambda x: x[1]
|
||||
)
|
||||
|
||||
print(f" Channels by avg pitch: {[(ch, f'{avg:.1f}', cnt) for ch, avg, cnt in ch_avg]}")
|
||||
|
||||
if len(ch_avg) == 1:
|
||||
# Single channel: use it for melody, silence bass/chords
|
||||
return {ch_avg[0][0]}, set(), set()
|
||||
elif len(ch_avg) == 2:
|
||||
return {ch_avg[1][0]}, {ch_avg[0][0]}, set()
|
||||
else:
|
||||
# Lowest = bass, highest = melody, everything in between = chords
|
||||
bass_ch = {ch_avg[0][0]}
|
||||
melody_ch = {ch_avg[-1][0]}
|
||||
chord_chs = {ch for ch, _, _ in ch_avg[1:-1]}
|
||||
return melody_ch, bass_ch, chord_chs
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Merge 4 channels onto a shared timeline
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def merge_channels(span_lists, tpb, tempo_map, vel_scales=None,
|
||||
arp_intervals=None, arp_ch=None):
|
||||
"""
|
||||
Merge 4 channels' note spans onto a shared timeline.
|
||||
span_lists: [melody_spans, bass_spans, chord_spans, drum_spans]
|
||||
vel_scales: optional list of velocity scale factors per tonal channel
|
||||
(e.g. [1.0, 1.0, 1.8] to boost chords). Default all 1.0.
|
||||
arp_intervals: optional sorted list of (start_tick, end_tick, off1, off2)
|
||||
for tracker-style arpeggio on one tonal channel.
|
||||
arp_ch: which channel index (0-2) the arp applies to (None = no arp).
|
||||
|
||||
Returns: (melody[], bass[], chords[], drums[], timing[], arp[])
|
||||
All arrays are the same length.
|
||||
Consecutive identical entries are merged to save space.
|
||||
|
||||
For melody/bass/chords: packed = (vel4 << 12) | (midi_note << 4)
|
||||
For drums: packed = SFX ID from doom_sfx.h, or 0 for silence
|
||||
arp[]: u8 per step, (off1 << 4) | off2. All zeros if no arpeggio.
|
||||
"""
|
||||
if vel_scales is None:
|
||||
vel_scales = [1.0, 1.0, 1.0]
|
||||
num_ch = len(span_lists)
|
||||
has_arp = arp_intervals is not None and arp_ch is not None and len(arp_intervals) > 0
|
||||
|
||||
# Collect all unique tick boundaries
|
||||
tick_set = set()
|
||||
for spans in span_lists:
|
||||
for start, end, _, _ in spans:
|
||||
tick_set.add(start)
|
||||
tick_set.add(end)
|
||||
|
||||
if not tick_set:
|
||||
return [], [], [], [], [], []
|
||||
|
||||
sorted_ticks = sorted(tick_set)
|
||||
|
||||
# Build raw interval data
|
||||
raw_packed = [[] for _ in range(num_ch)]
|
||||
raw_timing = []
|
||||
raw_arp = []
|
||||
|
||||
# Use index pointers for efficient span lookup (spans are sorted)
|
||||
ch_idx = [0] * num_ch
|
||||
arp_ptr = 0
|
||||
|
||||
for i in range(len(sorted_ticks) - 1):
|
||||
t_start = sorted_ticks[i]
|
||||
t_end = sorted_ticks[i + 1]
|
||||
|
||||
dur_ms = ticks_to_ms(t_end - t_start, tpb, get_tempo_at(tempo_map, t_start))
|
||||
if dur_ms <= 0:
|
||||
continue
|
||||
|
||||
for ch in range(num_ch):
|
||||
spans = span_lists[ch]
|
||||
# Advance past expired spans
|
||||
while ch_idx[ch] < len(spans) and spans[ch_idx[ch]][1] <= t_start:
|
||||
ch_idx[ch] += 1
|
||||
|
||||
# Check if current span covers t_start
|
||||
idx = ch_idx[ch]
|
||||
if idx < len(spans) and spans[idx][0] <= t_start < spans[idx][1]:
|
||||
midi_note = spans[idx][2]
|
||||
velocity = spans[idx][3]
|
||||
if ch < 3:
|
||||
# Tonal: pack velocity + MIDI note number
|
||||
if note_to_freq(midi_note) == 0:
|
||||
raw_packed[ch].append(PAU)
|
||||
else:
|
||||
vel4 = velocity_to_4bit(velocity)
|
||||
vel4 = min(15, max(1, int(vel4 * vel_scales[ch])))
|
||||
raw_packed[ch].append((vel4 << 12) | (midi_note << 4))
|
||||
else:
|
||||
# Drums: pack tap + noise freq (unchanged)
|
||||
raw_packed[ch].append(gm_drum_to_packed(midi_note))
|
||||
else:
|
||||
raw_packed[ch].append(PAU)
|
||||
|
||||
# Build arp byte for this step
|
||||
arp_byte = 0
|
||||
if has_arp:
|
||||
while arp_ptr < len(arp_intervals) and arp_intervals[arp_ptr][1] <= t_start:
|
||||
arp_ptr += 1
|
||||
if (arp_ptr < len(arp_intervals) and
|
||||
arp_intervals[arp_ptr][0] <= t_start < arp_intervals[arp_ptr][1]):
|
||||
off1 = arp_intervals[arp_ptr][2]
|
||||
off2 = arp_intervals[arp_ptr][3]
|
||||
arp_byte = (off1 << 4) | off2
|
||||
raw_arp.append(arp_byte)
|
||||
raw_timing.append(dur_ms)
|
||||
|
||||
if not raw_timing:
|
||||
return [], [], [], [], [], []
|
||||
|
||||
# Merge consecutive identical entries
|
||||
merged_packed = [[raw_packed[ch][0]] for ch in range(num_ch)]
|
||||
merged_timing = [raw_timing[0]]
|
||||
merged_arp = [raw_arp[0]]
|
||||
|
||||
for i in range(1, len(raw_timing)):
|
||||
same = (all(raw_packed[ch][i] == merged_packed[ch][-1] for ch in range(num_ch))
|
||||
and raw_arp[i] == merged_arp[-1])
|
||||
if same:
|
||||
merged_timing[-1] += raw_timing[i]
|
||||
else:
|
||||
for ch in range(num_ch):
|
||||
merged_packed[ch].append(raw_packed[ch][i])
|
||||
merged_timing.append(raw_timing[i])
|
||||
merged_arp.append(raw_arp[i])
|
||||
|
||||
return (merged_packed[0], merged_packed[1], merged_packed[2],
|
||||
merged_packed[3], merged_timing, merged_arp)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Write C header
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def write_header(prefix, melody, bass, chords, drums, timing, arp, output_path):
|
||||
"""Write a C header file with 4-channel music data + optional arp array."""
|
||||
n = len(timing)
|
||||
assert len(melody) == n and len(bass) == n and len(chords) == n and len(drums) == n
|
||||
assert arp is None or len(arp) == n
|
||||
|
||||
has_arp = arp is not None and any(a != 0 for a in arp)
|
||||
|
||||
with open(output_path, 'w') as f:
|
||||
f.write(f"/* {os.path.basename(output_path)} -- auto-generated by convert_midi.py */\n")
|
||||
f.write(f"#ifndef __{prefix.upper()}_H__\n")
|
||||
f.write(f"#define __{prefix.upper()}_H__\n\n")
|
||||
f.write(f"#define {prefix.upper()}_NOTE_COUNT {n}\n")
|
||||
f.write(f"#define {prefix.upper()}_HAS_ARP {1 if has_arp else 0}\n\n")
|
||||
|
||||
# Melody (ch0) - (vel << 12) | (midi_note << 4)
|
||||
f.write(f"static const int {prefix}_melody[{n}] = {{\n")
|
||||
for i in range(0, n, 12):
|
||||
chunk = melody[i:i+12]
|
||||
f.write("\t" + ",".join(f"0x{v:04X}" for v in chunk))
|
||||
f.write(",\n" if i + 12 < n else "\n")
|
||||
f.write("};\n\n")
|
||||
|
||||
# Bass (ch1) - (vel << 12) | (midi_note << 4)
|
||||
f.write(f"static const int {prefix}_bass[{n}] = {{\n")
|
||||
for i in range(0, n, 12):
|
||||
chunk = bass[i:i+12]
|
||||
f.write("\t" + ",".join(f"0x{v:04X}" for v in chunk))
|
||||
f.write(",\n" if i + 12 < n else "\n")
|
||||
f.write("};\n\n")
|
||||
|
||||
# Chords (ch3) - (vel << 12) | (midi_note << 4)
|
||||
f.write(f"static const int {prefix}_chords[{n}] = {{\n")
|
||||
for i in range(0, n, 12):
|
||||
chunk = chords[i:i+12]
|
||||
f.write("\t" + ",".join(f"0x{v:04X}" for v in chunk))
|
||||
f.write(",\n" if i + 12 < n else "\n")
|
||||
f.write("};\n\n")
|
||||
|
||||
# Drums - PCM SFX IDs (0 = silence)
|
||||
f.write(f"static const int {prefix}_drums[{n}] = {{\n")
|
||||
for i in range(0, n, 12):
|
||||
chunk = drums[i:i+12]
|
||||
f.write("\t" + ",".join(f"0x{v:04X}" for v in chunk))
|
||||
f.write(",\n" if i + 12 < n else "\n")
|
||||
f.write("};\n\n")
|
||||
|
||||
# Shared timing (ms)
|
||||
f.write(f"static const unsigned short {prefix}_timing[{n}] = {{\n")
|
||||
for i in range(0, n, 12):
|
||||
chunk = timing[i:i+12]
|
||||
f.write("\t" + ",".join(f"{v:5d}" for v in chunk))
|
||||
f.write(",\n" if i + 12 < n else "\n")
|
||||
f.write("};\n\n")
|
||||
|
||||
# Arpeggio offsets (u8 per step, tracker-style 0xy)
|
||||
if has_arp:
|
||||
f.write(f"static const unsigned char {prefix}_arp[{n}] = {{\n")
|
||||
for i in range(0, n, 16):
|
||||
chunk = arp[i:i+16]
|
||||
f.write("\t" + ",".join(f"0x{v:02X}" for v in chunk))
|
||||
f.write(",\n" if i + 16 < n else "\n")
|
||||
f.write("};\n\n")
|
||||
|
||||
f.write("#endif\n")
|
||||
|
||||
data_bytes = n * 4 * 4 + n * 2 + (n if has_arp else 0)
|
||||
arp_info = f", arp={sum(1 for a in arp if a != 0)} active" if has_arp else ""
|
||||
print(f" Written: {output_path} ({n} steps, "
|
||||
f"{data_bytes} bytes data{arp_info})")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Song configurations
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
SONGS = [
|
||||
# title_screen: type-0 MIDI, channels mapped per user Reaper analysis:
|
||||
# Reaper Track 2 (Ch 1) = main melody (French Horn, pitch 41-62)
|
||||
# Reaper Track 7 (Ch 6) = bassline / contrabass (pitch 29-36, needs +12)
|
||||
# Reaper Tracks 3+4+5 (Ch 2+3+4) = strings (Violin/Viola/Cello) → C64 arpeggio
|
||||
{
|
||||
'midi': 'title_screen.mid',
|
||||
'prefix': 'music_title',
|
||||
'header': 'music_title.h',
|
||||
'type': 'channels',
|
||||
'melody': {'track': None, 'channels': {1}},
|
||||
'bass': {'track': None, 'channels': {6}, 'transpose': 12},
|
||||
'chords': {
|
||||
'arpeggio': True,
|
||||
'tracks': [
|
||||
{'track': None, 'channels': {2}}, # Violin (pitch 56-68)
|
||||
{'track': None, 'channels': {3}}, # Viola (pitch 65-72)
|
||||
{'track': None, 'channels': {4}}, # Cello (pitch 59-75)
|
||||
],
|
||||
'arp_ticks': 8, # 8 ticks ≈ 18ms at 140BPM/192TPB → C64-style
|
||||
},
|
||||
'drums': 'auto',
|
||||
'vel_scales': [1.0, 1.0, 2.0], # boost strings (vel 29-68 are quiet)
|
||||
'max_seconds': 120, # song is ~1:08, no truncation needed
|
||||
},
|
||||
# e1m1: Tcstage1.mid — replaces rott_018
|
||||
{
|
||||
'midi': 'maybes/Tcstage1.mid',
|
||||
'prefix': 'music_e1m1',
|
||||
'header': 'music_e1m1.h',
|
||||
'type': 'auto',
|
||||
'drums': 'auto',
|
||||
'vel_scales': [1.0, 1.0, 1.8],
|
||||
'max_seconds': 180,
|
||||
},
|
||||
# e1m2: type-0 -- ch1=melody(46-70), ch3=bass(32-50), ch2=chords(44-62)
|
||||
{
|
||||
'midi': 'e1m2.mid',
|
||||
'prefix': 'music_e1m2',
|
||||
'header': 'music_e1m2.h',
|
||||
'type': 'channels',
|
||||
'melody': {'track': None, 'channels': {1}},
|
||||
'bass': {'track': None, 'channels': {3}},
|
||||
'chords': {'track': None, 'channels': {2}},
|
||||
'drums': 'auto',
|
||||
'vel_scales': [1.0, 1.0, 1.8], # boost chords to be audible
|
||||
'max_seconds': 60,
|
||||
},
|
||||
# e1m3: metalgr2.mid
|
||||
{
|
||||
'midi': 'maybes/metalgr2.mid',
|
||||
'prefix': 'music_e1m3',
|
||||
'header': 'music_e1m3.h',
|
||||
'type': 'auto',
|
||||
'drums': 'auto',
|
||||
'vel_scales': [1.0, 1.0, 1.8],
|
||||
'max_seconds': 120,
|
||||
},
|
||||
# e1m4: Castlevania SotN - Dracula's Castle
|
||||
{
|
||||
'midi': 'maybes/G_Castlevania_SotN_DraculasCastle.mid',
|
||||
'prefix': 'music_e1m4',
|
||||
'header': 'music_e1m4.h',
|
||||
'type': 'auto',
|
||||
'drums': 'auto',
|
||||
'vel_scales': [1.0, 1.0, 1.8],
|
||||
'max_seconds': 120,
|
||||
},
|
||||
# e1m5: King of the Mountain
|
||||
{
|
||||
'midi': 'maybes/King_of_the_Mountain.mid',
|
||||
'prefix': 'music_e1m5',
|
||||
'header': 'music_e1m5.h',
|
||||
'type': 'auto',
|
||||
'drums': 'auto',
|
||||
'vel_scales': [1.0, 1.0, 1.8],
|
||||
'max_seconds': 120,
|
||||
},
|
||||
# e1m6: TF4 Stage 8
|
||||
{
|
||||
'midi': 'maybes/TF4Stage8.mid',
|
||||
'prefix': 'music_e1m6',
|
||||
'header': 'music_e1m6.h',
|
||||
'type': 'auto',
|
||||
'drums': 'auto',
|
||||
'vel_scales': [1.0, 1.0, 1.8],
|
||||
'max_seconds': 120,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Main
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def truncate_to_duration(melody, bass, chords, drums, timing, arp, max_ms):
|
||||
"""Truncate arrays so total duration does not exceed max_ms."""
|
||||
total = 0
|
||||
for i, dur in enumerate(timing):
|
||||
total += dur
|
||||
if total >= max_ms:
|
||||
cut = i + 1
|
||||
return (melody[:cut], bass[:cut], chords[:cut], drums[:cut],
|
||||
timing[:cut], arp[:cut] if arp else None)
|
||||
return melody, bass, chords, drums, timing, arp
|
||||
|
||||
|
||||
def convert_song(song_cfg):
|
||||
midi_path = os.path.join(MUSIC_DIR, song_cfg['midi'])
|
||||
if not os.path.isfile(midi_path):
|
||||
print(f"WARNING: {midi_path} not found, skipping")
|
||||
return
|
||||
|
||||
print(f"\nConverting {song_cfg['midi']}:")
|
||||
mid = mido.MidiFile(midi_path)
|
||||
tpb = mid.ticks_per_beat
|
||||
tempo_map = build_tempo_map(mid)
|
||||
|
||||
print(f" Type {mid.type}, {len(mid.tracks)} tracks, tpb={tpb}")
|
||||
|
||||
arp_intervals = None
|
||||
arp_ch = None
|
||||
|
||||
if song_cfg['type'] in ('tracks', 'channels'):
|
||||
# Explicit track/channel assignments (with optional transpose and arpeggio)
|
||||
span_lists = []
|
||||
role_idx = 0
|
||||
for role in ('melody', 'bass', 'chords'):
|
||||
cfg = song_cfg[role]
|
||||
|
||||
if isinstance(cfg, dict) and cfg.get('arpeggio'):
|
||||
# Tracker-style arpeggio from multiple tracks (no time subdivision)
|
||||
arp_configs = [
|
||||
(tc['track'], tc['channels'], tc.get('transpose', 0))
|
||||
for tc in cfg['tracks']
|
||||
]
|
||||
spans, arp_ivs = build_arpeggio_for_merge(mid, arp_configs)
|
||||
arp_intervals = arp_ivs
|
||||
arp_ch = role_idx
|
||||
track_ids = [tc['track'] for tc in cfg['tracks']]
|
||||
print(f" {role}: ARPEGGIO tracks {track_ids}, "
|
||||
f"{len(spans)} base spans, {len(arp_ivs)} arp intervals")
|
||||
span_lists.append(spans)
|
||||
else:
|
||||
track_idx = cfg.get('track')
|
||||
channels = cfg['channels']
|
||||
transpose = cfg.get('transpose', 0)
|
||||
spans = extract_note_spans(mid, track_idx, channels, transpose)
|
||||
label = ''
|
||||
if track_idx is not None:
|
||||
tn = mid.tracks[track_idx].name if mid.tracks[track_idx].name else 'unnamed'
|
||||
label = f"track {track_idx} ({tn}), "
|
||||
xp = f", transpose={transpose:+d}" if transpose else ""
|
||||
print(f" {role}: {label}ch {channels}, {len(spans)} spans{xp}")
|
||||
span_lists.append(spans)
|
||||
role_idx += 1
|
||||
|
||||
elif song_cfg['type'] == 'auto':
|
||||
# Auto-detect channels by pitch
|
||||
melody_chs, bass_chs, chord_chs = detect_channels_type0(mid)
|
||||
print(f" Auto-assigned: melody={melody_chs}, bass={bass_chs}, chords={chord_chs}")
|
||||
|
||||
span_lists = []
|
||||
for role, chs in [('melody', melody_chs), ('bass', bass_chs), ('chords', chord_chs)]:
|
||||
if chs:
|
||||
spans = extract_note_spans(mid, None, chs)
|
||||
else:
|
||||
spans = []
|
||||
print(f" {role}: {len(spans)} note spans")
|
||||
span_lists.append(spans)
|
||||
|
||||
# Extract drum track
|
||||
drums_cfg = song_cfg.get('drums', 'auto')
|
||||
if drums_cfg == 'auto':
|
||||
drum_spans = extract_drum_spans(mid)
|
||||
print(f" drums: {len(drum_spans)} hits (channel 9)")
|
||||
elif drums_cfg == 'none':
|
||||
drum_spans = []
|
||||
print(f" drums: none")
|
||||
elif isinstance(drums_cfg, dict):
|
||||
track = drums_cfg.get('track')
|
||||
if drums_cfg.get('priority', False):
|
||||
drum_spans = extract_drum_spans_prioritized(mid, track)
|
||||
print(f" drums: {len(drum_spans)} hits (prioritized: kick>snare>hihat)")
|
||||
else:
|
||||
drum_spans = extract_drum_spans(mid, track)
|
||||
print(f" drums: {len(drum_spans)} hits (channel 9)")
|
||||
else:
|
||||
drum_spans = extract_drum_spans(mid)
|
||||
print(f" drums: {len(drum_spans)} hits (channel 9)")
|
||||
span_lists.append(drum_spans)
|
||||
|
||||
# Merge onto shared timeline (now 4 channels + arp)
|
||||
vel_scales = song_cfg.get('vel_scales', [1.0, 1.0, 1.0])
|
||||
melody, bass, chords, drums, timing, arp = merge_channels(
|
||||
span_lists, tpb, tempo_map, vel_scales, arp_intervals, arp_ch)
|
||||
|
||||
total_ms = sum(timing) if timing else 0
|
||||
print(f" Merged: {len(timing)} steps, {total_ms/1000:.1f}s total")
|
||||
|
||||
# Truncate if needed
|
||||
max_s = song_cfg.get('max_seconds', 120)
|
||||
if total_ms > max_s * 1000:
|
||||
melody, bass, chords, drums, timing, arp = truncate_to_duration(
|
||||
melody, bass, chords, drums, timing, arp, max_s * 1000)
|
||||
print(f" Truncated to {sum(timing)/1000:.1f}s ({len(timing)} steps)")
|
||||
|
||||
if not timing:
|
||||
print(" No data extracted, skipping")
|
||||
return
|
||||
|
||||
output_path = os.path.join(OUTPUT_DIR, song_cfg['header'])
|
||||
write_header(song_cfg['prefix'], melody, bass, chords, drums, timing, arp, output_path)
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
for song_cfg in SONGS:
|
||||
convert_song(song_cfg)
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user