ChocolateBird/scripts/loaders/snes/music.gd

279 lines
10 KiB
GDScript3
Raw Normal View History

2023-08-23 17:26:16 +09:30
const MAX_TRACKS := 8
const MAX_LOOP_DEPTH := 8 # Apparently 4, but eh whatever
enum EventType {
ADSR_ATTACK,
ADSR_DECAY,
ADSR_DEFAULT,
ADSR_RELEASE,
ADSR_SUSTAIN,
CPU_CONTROLLED_JUMP,
ECHO_FEEDBACK_FIR,
ECHO_OFF,
ECHO_ON,
ECHO_VOLUME_SLIDE,
ECHO_VOLUME,
END,
GOTO,
LOOP_BREAK,
LOOP_END,
LOOP_RESTART,
LOOP_START,
MASTER_VOLUME,
NOISE_FREQ,
NOISE_OFF,
NOISE_ON,
NONE,
NOTE,
OCTAVE_DOWN,
OCTAVE_UP,
OCTAVE,
PAN_SLIDE,
PAN_LFO_OFF,
PAN_LFO_ON,
PAN,
PITCH_SLIDE,
PITCHMOD_OFF,
PITCHMOD_ON,
PROGCHANGE,
TEMPO_SLIDE,
TEMPO,
TRANSPOSE_ABS,
TRANSPOSE_REL,
TREMOLO_OFF,
TREMOLO_ON,
TUNING,
VIBRATO_OFF,
VIBRATO_ON,
VOLUME_SLIDE,
VOLUME,
}
enum OperandType {
U8,
U16,
S8,
}
const OPERAND_MAP := {
EventType.ADSR_ATTACK: [OperandType.U8],
EventType.ADSR_DECAY: [OperandType.U8],
EventType.ADSR_DEFAULT: [],
EventType.ADSR_RELEASE: [OperandType.U8],
EventType.ADSR_SUSTAIN: [OperandType.U8],
EventType.CPU_CONTROLLED_JUMP: [OperandType.U16],
EventType.ECHO_FEEDBACK_FIR: [OperandType.U8, OperandType.U8],
EventType.ECHO_OFF: [],
EventType.ECHO_ON: [],
EventType.ECHO_VOLUME_SLIDE: [OperandType.U8, OperandType.U8],
EventType.ECHO_VOLUME: [OperandType.U8],
EventType.END: [],
EventType.GOTO: [OperandType.U16],
EventType.LOOP_BREAK: [OperandType.U8, OperandType.U16],
EventType.LOOP_END: [],
EventType.LOOP_START: [OperandType.U8],
EventType.MASTER_VOLUME: [OperandType.U8],
EventType.NOISE_FREQ: [OperandType.U8],
EventType.NOISE_OFF: [],
EventType.NOISE_ON: [],
EventType.OCTAVE_DOWN: [],
EventType.OCTAVE_UP: [],
EventType.OCTAVE: [OperandType.U8],
EventType.PAN_LFO_OFF: [],
EventType.PAN_LFO_ON: [OperandType.U8, OperandType.U8],
EventType.PAN_SLIDE: [OperandType.U8, OperandType.U8],
EventType.PAN: [OperandType.U8],
EventType.PITCH_SLIDE: [OperandType.U8, OperandType.S8],
EventType.PITCHMOD_OFF: [],
EventType.PITCHMOD_ON: [],
EventType.PROGCHANGE: [OperandType.U8],
EventType.TEMPO_SLIDE: [OperandType.U8, OperandType.U8],
EventType.TEMPO: [OperandType.U8],
EventType.TRANSPOSE_ABS: [OperandType.U8],
EventType.TRANSPOSE_REL: [OperandType.U8],
EventType.TREMOLO_OFF: [],
EventType.TREMOLO_ON: [OperandType.U8, OperandType.U8, OperandType.U8],
EventType.TUNING: [OperandType.U8],
EventType.VIBRATO_OFF: [],
EventType.VIBRATO_ON: [OperandType.U8, OperandType.U8, OperandType.U8],
EventType.VOLUME_SLIDE: [OperandType.U8, OperandType.U8],
EventType.VOLUME: [OperandType.U8],
}
const PPQN := 48 # Pulses Per Quarter Note. All the games this project cares about use 48 (allows fast triplets). Some other SNES games I don't care about use 12/24/32.
# const TIMER0_FREQUENCY := 36 # 0x24
# return 60000000.0 / (125 * PPQN * TIMER0_FREQUENCY)) * (tempo / 256.0);
static func tempo_to_bpm(tempo_byte: int) -> float:
if tempo_byte < 1:
return 1.0
return (tempo_byte / 255.0) * 60000000.0 / 216000.0 # VGMTrans uses /256.0 but I don't trust that
static func get_int_array(size: int) -> PoolIntArray:
var array := PoolIntArray()
array.resize(size)
array.fill(0)
return array
# In real OOP these would be static or static const, but GDScript doesn't allow that
# These need to be defined in subclasses for methods to work
var EVENT_MAP: Dictionary
var NOTE_DURATIONS: PoolByteArray # This might need to be made untyped if a future addition uses PoolIntArray instead
var REFERENCE_NOTE: int
# These would be static methods if NOTE_DURATIONS and EVENT_MAP could be static
func translate_instruction(buffer: StreamPeer) -> Array:
var instruction := buffer.get_u8()
if instruction < 0xD2:
var duration = self.NOTE_DURATIONS[instruction % 15]
var note = instruction / 15
if note >= 12: # 12 and 13 are rests
note = -1
return [EventType.NOTE, note, duration]
else:
# Control codes
var cc = self.EVENT_MAP[instruction]
var operand_list = OPERAND_MAP[cc]
var operands = [cc]
for o in operand_list:
match o:
OperandType.U8:
operands.append(buffer.get_u8())
OperandType.U16:
operands.append(buffer.get_u16())
OperandType.S8:
operands.append(buffer.get_8())
return operands
func unroll_track(buffer: StreamPeerBuffer, bgm_start_pos: int, track_start_pos: int, bgm_end_pos: int, bgm_id='N/A') -> Array:
# There are three ways a track's data can end:
# 1) Explicit End instruction
# 2) Instruction pointer out of bounds (one FFV track starts out of bounds to indicate an unused channel)
# 3) Infinite loop
# 3.1) unconditional GOTO to a previous address
# 3.2) LOOP_END with a prior [LOOP_START, 0]
# Detecting 1), 2) and 3.1) is trivial and stateless, but 3.2 requires loop tracking
# We may as well unroll the loops here and convert a final infinite loop to a GOTO
# While it is possible that GOTO may point forwards, we'll just treat it as an end for now
var start_bank := bgm_start_pos & 0x3F0000
var events := []
var event_idx := 0
var rom_address_to_event_index := {}
var loop_level: int = -1
var loop_positions := get_int_array(MAX_LOOP_DEPTH)
var loop_counts := get_int_array(MAX_LOOP_DEPTH)
var loop_counters := get_int_array(MAX_LOOP_DEPTH)
var loop_break_positions := get_int_array(MAX_LOOP_DEPTH)
var loop_break_targets := get_int_array(MAX_LOOP_DEPTH)
buffer.seek(track_start_pos)
while true:
var pos = buffer.get_position()
if pos >= bgm_end_pos: # Outside of the chunk that would be copied to the SPC
break
rom_address_to_event_index[pos] = event_idx
var event = translate_instruction(buffer)
match event[0]:
EventType.LOOP_START:
loop_level += 1
var count = event[1]
if count > 0:
count += 1
loop_counts[loop_level] = count
loop_positions[loop_level] = pos
EventType.LOOP_END:
# Check if the loop contains a break
var loop_count := loop_counts[loop_level]
if loop_break_positions[loop_level] > 0:
# Copy the appropriate amount of loops, a partial loop up to the break, then seek to the break's target
var break_pos := loop_break_positions[loop_level]
var jump_pos := loop_break_targets[loop_level]
if jump_pos - pos != 1: # This happens once on track 68:06 FFV SNES BGM
print('LOOP_END at 0x%06X would break instead from 0x%06X to 0x%06X (%d past end)' % [pos, break_pos, jump_pos, jump_pos-pos])
buffer.seek(jump_pos)
var loop_events := events.slice(rom_address_to_event_index[loop_positions[loop_level]], -1)
var partial_loop_events := events.slice(rom_address_to_event_index[loop_positions[loop_level]], rom_address_to_event_index[break_pos]-1)
for loop in loop_count-2: # The first loop is already present, and the last is partial
events.append_array(loop_events)
events.append_array(partial_loop_events)
event_idx += (len(loop_events) * (loop_count-2)) + len(partial_loop_events)
# Clean up loop break vars
loop_break_positions[loop_level] = 0
loop_break_targets[loop_level] = 0
loop_level -= 1
elif loop_count > 0:
# No break within the loop, just copy the loop contents the appropriate number of times and proceed
var loop_events := events.slice(rom_address_to_event_index[loop_positions[loop_level]], -1)
for loop in loop_count-1: # The first loop is already present
events.append_array(loop_events)
event_idx += len(loop_events) * (loop_count-1)
# Clean up loop vars
loop_level -= 1
else:
# Infinite loop, convert it to a GOTO and return
print('ended with infinite loop')
events.append([EventType.GOTO, rom_address_to_event_index[loop_break_targets[loop_level]]])
loop_level -= 1
break
EventType.LOOP_BREAK:
# All FFV loop breaks are the same as the loop count
var on_loop: int = event[1]
# Just treat the rest as a GOTO
var target_pos: int = event[2] + start_bank
if target_pos < bgm_start_pos:
target_pos += 0x010000
loop_break_positions[loop_level] = pos
loop_break_targets[loop_level] = target_pos
if on_loop != loop_counts[loop_level]:
print('LOOP_BREAK found in track %s at 0x%06X:0x%06X:0x%06X: 0x%06X to 0x%06X on loop %d of %d' % [bgm_id, bgm_start_pos, track_start_pos, bgm_end_pos, pos, target_pos, on_loop, loop_counts[loop_level]])
loop_counts[loop_level] = on_loop
else:
print('LOOP_BREAK found in track %s at 0x%06X:0x%06X:0x%06X: 0x%06X to 0x%06X on last loop' % [bgm_id, bgm_start_pos, track_start_pos, bgm_end_pos, pos, target_pos])
if target_pos in rom_address_to_event_index:
print_debug('LOOP_BREAK is a past address, this is either malformed or an infinite loop')
break
EventType.END:
break
EventType.GOTO:
# GOTO pointers reference the ROM position
# BGM 04 (Pirates Ahoy!) crosses a ROM bank so correct bank wrapping is required
var target_pos: int = event[1] + start_bank
if target_pos < bgm_start_pos:
target_pos += 0x010000
var event_num = rom_address_to_event_index.get(target_pos, -1)
if event_num >= 0:
print('Infinite GOTO found in track %s at 0x%06X:0x%06X:0x%06X: 0x%06X to 0x%06X (event number %d)' % [bgm_id, bgm_start_pos, track_start_pos, bgm_end_pos, pos, target_pos, event_num])
events.append([EventType.GOTO, rom_address_to_event_index[target_pos]])
break
else:
print_debug('forward GOTO found in track %s at 0x%06X:0x%06X:0x%06X: 0x%06X to 0x%06X' % [bgm_id, bgm_start_pos, track_start_pos, bgm_end_pos, pos, target_pos])
buffer.seek(target_pos)
_:
events.append(event)
event_idx += 1
# DEBUG: Report if track doesn't end all the contained loops
if loop_level >= 0:
print('track %s ended on loop_level %d (0x%06X)' % [bgm_id, loop_level, loop_positions[loop_level]])
# DEBUG: Report total note duration and repeat note duration
if events:
print('%d events' % len(events))
var total_note_dur := 0
var total_notes := 0
var total_rests := 0
for e in events:
if e[0] == EventType.NOTE:
total_note_dur += e[2]
if e[1] >= 0:
total_notes += 1
else:
total_rests += 1
if events[-1][0] == EventType.GOTO:
var repeat_note_dur := 0
for e in events.slice(events[-1][1], -1):
if e[0] == EventType.NOTE:
repeat_note_dur += e[2]
print('track %s has duration %d and repeat duration %d (intro %d) - %d notes %d rests' % [bgm_id, total_note_dur, repeat_note_dur, total_note_dur-repeat_note_dur, total_notes, total_rests])
else:
print('track %s has duration %d - %d notes %d rests' % [bgm_id, total_note_dur, total_notes, total_rests])
return events