From bd5598f98f351c8ace70b3a90f625ffe673e407f Mon Sep 17 00:00:00 2001 From: Luke Hubmayer-Werner Date: Wed, 23 Aug 2023 17:26:16 +0930 Subject: [PATCH] Basic music playback --- project.godot | 3 +- scripts/MusicPlayer.gd | 235 +++++++++++++++++++++++++ scripts/loaders/RomLoader.gd | 21 +-- scripts/loaders/snes/music.gd | 278 ++++++++++++++++++++++++++++++ scripts/loaders/snes/music_ff5.gd | 56 ++++++ test/audio_system.gd | 67 ++++++- test/audio_system.tscn | 23 +++ test_scene.tscn | 2 +- 8 files changed, 670 insertions(+), 15 deletions(-) create mode 100644 scripts/MusicPlayer.gd create mode 100644 scripts/loaders/snes/music.gd create mode 100644 scripts/loaders/snes/music_ff5.gd diff --git a/project.godot b/project.godot index 6e86c62..03b72b3 100644 --- a/project.godot +++ b/project.godot @@ -34,6 +34,7 @@ StringLoader="*res://scripts/loaders/StringLoader.gd" [debug] +settings/fps/force_fps=60 gdscript/warnings/unused_variable=false gdscript/warnings/integer_division=false @@ -42,6 +43,7 @@ gdscript/warnings/integer_division=false window/size/width=384 window/size/height=240 window/dpi/allow_hidpi=true +window/vsync/use_vsync=false window/stretch/shrink=2.0 window/size/snap_to_integer=true @@ -61,7 +63,6 @@ texture={ [rendering] -quality/driver/driver_name="GLES2" 2d/snapping/use_gpu_pixel_snap=true environment/default_clear_color=Color( 0, 0, 0, 1 ) environment/default_environment="res://default_env.tres" diff --git a/scripts/MusicPlayer.gd b/scripts/MusicPlayer.gd new file mode 100644 index 0000000..46273d9 --- /dev/null +++ b/scripts/MusicPlayer.gd @@ -0,0 +1,235 @@ +#warning-ignore-all:shadowed_variable +extends Node +const music := preload('res://scripts/loaders/snes/music_ff5.gd') +const EventType := music.EventType +var MUSIC := music.new() + +var inst_map: Dictionary # int keys, AudioStreamSample values +var tracks: Array +var num_tracks: int +var players: Array +var tempo: float # Store as BPM +var seconds_per_pulse: float +var master_volume := 255 +var channel_pointer := PoolIntArray() +var channel_instrument_idx := PoolByteArray() +var channel_next_pulse := PoolIntArray() +var channel_current_note := PoolByteArray() +var channel_velocity := PoolByteArray() +var channel_pan := PoolByteArray() # Reversed from MIDI +var channel_octave := PoolByteArray() +var channel_transpose := PoolByteArray() +var channel_fine_tuning := PoolRealArray() +var channel_adsr_attack := PoolByteArray() +var channel_adsr_decay := PoolByteArray() +var channel_adsr_sustain := PoolByteArray() +var channel_adsr_release := PoolByteArray() +var channel_noise_freq := PoolByteArray() +var channel_noise_on := PoolByteArray() +var channel_pan_lfo_rate := PoolByteArray() +var channel_pan_lfo_depth := PoolByteArray() +var channel_pan_lfo_on := PoolByteArray() +var channel_tremolo_delay := PoolByteArray() +var channel_tremolo_rate := PoolByteArray() +var channel_tremolo_depth := PoolByteArray() +var channel_tremolo_on := PoolByteArray() +var channel_vibrato_delay := PoolByteArray() +var channel_vibrato_rate := PoolByteArray() +var channel_vibrato_depth := PoolByteArray() +var channel_vibrato_on := PoolByteArray() +var channel_pitchmod_on := PoolByteArray() +var channel_echo_on := PoolByteArray() +var channel_echo_volume := PoolByteArray() + +func set_tempo(tempo: float): + self.tempo = tempo + self.seconds_per_pulse = 60.0 / (tempo * music.PPQN) + +func _init(tracks: Array, instrument_map: Dictionary): + self.tracks = tracks + self.num_tracks = len(self.tracks) + self.inst_map = instrument_map + self.players = [] + self.set_tempo(120.0) + for i in num_tracks: + self.players.append(AudioStreamPlayer.new()) + add_child(self.players[-1]) + self.channel_pointer.append(0) + self.channel_instrument_idx.append(0) + self.channel_next_pulse.append(0) + self.channel_current_note.append(0) + self.channel_velocity.append(100) + self.channel_pan.append(0) + self.channel_octave.append(5) + self.channel_transpose.append(0) + self.channel_fine_tuning.append(1.0) + self.channel_adsr_attack.append(0) + self.channel_adsr_decay.append(0) + self.channel_adsr_sustain.append(0) + self.channel_adsr_release.append(0) + self.channel_noise_freq.append(0) + self.channel_noise_on.append(0) + self.channel_pan_lfo_rate.append(0) + self.channel_pan_lfo_depth.append(0) + self.channel_pan_lfo_on.append(0) + self.channel_tremolo_delay.append(0) + self.channel_tremolo_rate.append(0) + self.channel_tremolo_depth.append(0) + self.channel_tremolo_on.append(0) + self.channel_vibrato_delay.append(0) + self.channel_vibrato_rate.append(0) + self.channel_vibrato_depth.append(0) + self.channel_vibrato_on.append(0) + self.channel_pitchmod_on.append(0) + self.channel_echo_on.append(0) + self.channel_echo_volume.append(0) + +func play_channel(channel: int, time_offset: float = 0.0) -> int: + # Executes the track events until it hits a note/rest, in which case it returns the pulse count to the next action, or the end of the events, in which case it returns -1 + self.players[channel].stop() + var track: Array = self.tracks[channel] + var l := len(track) + var player: AudioStreamPlayer = self.players[channel] + while true: + var ptr: int = self.channel_pointer[channel] + if ptr >= l: + break + var event = track[ptr] + self.channel_pointer[channel] += 1 + match event[0]: # Control codes + EventType.NOTE: + var note = event[1] + var duration = event[2] + if note >= 0: # Don't shift or play rests + note += (12 * self.channel_octave[channel]) + self.channel_transpose[channel] + player.pitch_scale = pow(2.0, (note - MUSIC.REFERENCE_NOTE)/12.0) #* self.channel_fine_tuning[channel] + player.volume_db = linear2db((self.channel_velocity[channel]/255.0) * (self.master_volume/255.0)) + player.play(max(SoundLoader.PLAY_START - time_offset, 0.0)) + self.channel_current_note[channel] = note + else: + self.channel_current_note[channel] = -1 + # TODO: Confirm tempo scaling + return duration # Pulses to next instruction + EventType.VOLUME: + self.channel_velocity[channel] = event[1] + EventType.VOLUME_SLIDE: # TODO: implement slides + var slide_duration: int = event[1] + self.channel_velocity[channel] = event[2] + EventType.PAN: # TODO: implement slides + self.channel_pan[channel] = event[1] + EventType.PAN_SLIDE: # TODO: implement slides + var slide_duration: int = event[1] + self.channel_pan[channel] = event[2] + EventType.PITCH_SLIDE: # TODO: implement slides + var slide_duration: int = event[1] + var target_pitch: int = event[2] # Signed + EventType.VIBRATO_ON: + self.channel_vibrato_delay[channel] = event[1] + self.channel_vibrato_rate[channel] = event[2] + self.channel_vibrato_depth[channel] = event[3] + self.channel_vibrato_on[channel] = 1 + EventType.VIBRATO_OFF: + self.channel_vibrato_on[channel] = 0 + EventType.TREMOLO_ON: + self.channel_tremolo_delay[channel] = event[1] + self.channel_tremolo_rate[channel] = event[2] + self.channel_tremolo_depth[channel] = event[3] + self.channel_tremolo_on[channel] = 1 + EventType.TREMOLO_OFF: + self.channel_tremolo_on[channel] = 0 + EventType.PAN_LFO_ON: + self.channel_pan_lfo_depth[channel] = event[1] + self.channel_pan_lfo_rate[channel] = event[2] + self.channel_pan_lfo_on[channel] = 1 + EventType.PAN_LFO_OFF: + self.channel_pan_lfo_on[channel] = 0 + EventType.NOISE_FREQ: + self.channel_noise_freq[channel] = event[1] + EventType.NOISE_ON: + self.channel_noise_on[channel] = 1 + EventType.NOISE_OFF: + self.channel_noise_on[channel] = 0 + EventType.PITCHMOD_ON: + self.channel_pitchmod_on[channel] = 1 + EventType.PITCHMOD_OFF: + self.channel_pitchmod_on[channel] = 0 + EventType.ECHO_ON: + self.channel_echo_on[channel] = 1 + EventType.ECHO_OFF: + self.channel_echo_on[channel] = 0 + EventType.OCTAVE: + self.channel_octave[channel] = event[1] + EventType.OCTAVE_UP: + self.channel_octave[channel] += 1 + EventType.OCTAVE_DOWN: + self.channel_octave[channel] -= 1 + EventType.TRANSPOSE_ABS: + self.channel_transpose[channel] = event[1] + EventType.TRANSPOSE_REL: + self.channel_transpose[channel] += event[1] + EventType.TUNING: + var fine_tune: int = event[1] + var scale: float + if fine_tune < 0x80: + scale = 1.0 + fine_tune/255.0 + else: + scale = fine_tune/255.0 + self.channel_fine_tuning[channel] = scale + EventType.PROGCHANGE: + self.channel_instrument_idx[channel] = event[1] + player.stream = self.inst_map[self.channel_instrument_idx[channel]] + # TODO - grab instrument envelope + EventType.ADSR_ATTACK: + self.channel_adsr_attack[channel] = event[1] + EventType.ADSR_DECAY: + self.channel_adsr_decay[channel] = event[1] + EventType.ADSR_SUSTAIN: + self.channel_adsr_sustain[channel] = event[1] + EventType.ADSR_RELEASE: + self.channel_adsr_release[channel] = event[1] + EventType.ADSR_DEFAULT: # TODO - grab instrument envelope + pass + EventType.TEMPO: + self.set_tempo(music.tempo_to_bpm(event[1])) + EventType.TEMPO_SLIDE: + self.set_tempo(music.tempo_to_bpm(event[2])) + var slide_duration: int = event[1] + EventType.ECHO_VOLUME: + self.channel_echo_volume[channel] = event[1] + EventType.ECHO_VOLUME_SLIDE: # TODO: implement slides + self.channel_echo_volume[channel] = event[2] + var slide_duration: int = event[1] + EventType.ECHO_FEEDBACK_FIR: # TODO + var feedback: int = event[1] + var filterIndex: int = event[2] + EventType.MASTER_VOLUME: + self.master_volume = event[1] + EventType.GOTO: + self.channel_pointer[channel] = event[1] + EventType.END: + break + _: + break + return -1 # End of track + +func play_pulse(time_offset := 0.0) -> bool: # Return true if any channel played + var active_channels := 0 + for channel in self.num_tracks: + if self.channel_next_pulse[channel] < 0: + continue # Channel not playing + active_channels += 1 + if self.channel_next_pulse[channel] == 0: + self.channel_next_pulse[channel] = self.play_channel(channel, time_offset) + self.channel_next_pulse[channel] -= 1 + return active_channels > 0 + +var is_playing := false +var bgm_timestamp := 0.0 # Note this will be behind by the maximum delay +func _process(delta: float) -> void: + if self.is_playing: + bgm_timestamp += delta + while bgm_timestamp > seconds_per_pulse: + bgm_timestamp -= seconds_per_pulse + self.is_playing = play_pulse(bgm_timestamp) + if not self.is_playing: + print('BGM finished playing') diff --git a/scripts/loaders/RomLoader.gd b/scripts/loaders/RomLoader.gd index 7009a64..792f883 100644 --- a/scripts/loaders/RomLoader.gd +++ b/scripts/loaders/RomLoader.gd @@ -18,6 +18,7 @@ var GBA_filename := '2564 - Final Fantasy V Advance (U)(Independent).gba' var rom_snes := File.new() var snes_data := {} var snes_bytes: PoolByteArray +var snes_buffer: StreamPeerBuffer var thread := Thread.new() func load_snes_structs(buffer: StreamPeerBuffer) -> Dictionary: @@ -71,19 +72,19 @@ func load_snes_rom(filename: String): var rom_size := rom_snes.get_len() var bytes := rom_snes.get_buffer(rom_size) self.snes_bytes = bytes - var buffer = StreamPeerBuffer.new() - buffer.data_array = bytes + self.snes_buffer = StreamPeerBuffer.new() + self.snes_buffer.data_array = bytes - self.snes_data = load_snes_structs(buffer) + self.snes_data = load_snes_structs(self.snes_buffer) #print(snes_data.job_levels) - # Give this its own buffer, avoid file pointer conflicts - self.load_snes_audio_thread([self.snes_data, buffer.duplicate()]) - # var _thread_error = thread.start(self, 'load_snes_audio_thread', [self.snes_data, buffer.duplicate()]) - StringLoader.load_snes_rom(buffer, true) + # Give this its own buffer if threaded, avoid file pointer conflicts + self.load_snes_audio_thread([self.snes_data, self.snes_buffer]) + # var _thread_error = thread.start(self, 'load_snes_audio_thread', [self.snes_data, self.snes_buffer.duplicate()]) + StringLoader.load_snes_rom(self.snes_buffer, true) SpriteLoader.load_from_structs(self.snes_data) - SpriteLoader.load_enemy_battle_sprites(self.snes_data, buffer) - SpriteLoader.load_battle_bgs(self.snes_data, buffer) - MapLoader.load_snes_rom(buffer) + SpriteLoader.load_enemy_battle_sprites(self.snes_data, self.snes_buffer) + SpriteLoader.load_battle_bgs(self.snes_data, self.snes_buffer) + MapLoader.load_snes_rom(self.snes_buffer) func load_psx_folder(_dirname: String): pass diff --git a/scripts/loaders/snes/music.gd b/scripts/loaders/snes/music.gd new file mode 100644 index 0000000..63c296b --- /dev/null +++ b/scripts/loaders/snes/music.gd @@ -0,0 +1,278 @@ +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 diff --git a/scripts/loaders/snes/music_ff5.gd b/scripts/loaders/snes/music_ff5.gd new file mode 100644 index 0000000..5140131 --- /dev/null +++ b/scripts/loaders/snes/music_ff5.gd @@ -0,0 +1,56 @@ +extends 'res://scripts/loaders/snes/music.gd' + +# In real OOP these would be static or static const, but GDScript doesn't allow that +func _init() -> void: + # Durations are in pulses, 48 = 1 quarter note (crotchet) + self.NOTE_DURATIONS = PoolByteArray([192, 144, 96, 64, 72, 48, 32, 36, 24, 16, 12, 8, 6, 4, 3]) # See ROM 0x041D7E to 0x041D8C + self.REFERENCE_NOTE = 71 + + self.EVENT_MAP = { + 0xD2: EventType.VOLUME, + 0xD3: EventType.VOLUME_SLIDE, + 0xD4: EventType.PAN, + 0xD5: EventType.PAN_SLIDE, + 0xD6: EventType.PITCH_SLIDE, + 0xD7: EventType.VIBRATO_ON, + 0xD8: EventType.VIBRATO_OFF, + 0xD9: EventType.TREMOLO_ON, + 0xDA: EventType.TREMOLO_OFF, + 0xDB: EventType.PAN_LFO_ON, + 0xDC: EventType.PAN_LFO_OFF, + 0xDD: EventType.NOISE_FREQ, + 0xDE: EventType.NOISE_ON, + 0xDF: EventType.NOISE_OFF, + 0xE0: EventType.PITCHMOD_ON, + 0xE1: EventType.PITCHMOD_OFF, + 0xE2: EventType.ECHO_ON, + 0xE3: EventType.ECHO_OFF, + 0xE4: EventType.OCTAVE, + 0xE5: EventType.OCTAVE_UP, + 0xE6: EventType.OCTAVE_DOWN, + 0xE7: EventType.TRANSPOSE_ABS, + 0xE8: EventType.TRANSPOSE_REL, + 0xE9: EventType.TUNING, + 0xEA: EventType.PROGCHANGE, + 0xEB: EventType.ADSR_ATTACK, + 0xEC: EventType.ADSR_DECAY, + 0xED: EventType.ADSR_SUSTAIN, + 0xEE: EventType.ADSR_RELEASE, + 0xEF: EventType.ADSR_DEFAULT, + 0xF0: EventType.LOOP_START, + 0xF1: EventType.LOOP_END, + 0xF2: EventType.END, + 0xF3: EventType.TEMPO, + 0xF4: EventType.TEMPO_SLIDE, + 0xF5: EventType.ECHO_VOLUME, + 0xF6: EventType.ECHO_VOLUME_SLIDE, + 0xF7: EventType.ECHO_FEEDBACK_FIR, + 0xF8: EventType.MASTER_VOLUME, + 0xF9: EventType.LOOP_BREAK, + 0xFA: EventType.GOTO, + 0xFB: EventType.CPU_CONTROLLED_JUMP, + 0xFC: EventType.END, + 0xFD: EventType.END, + 0xFE: EventType.END, + 0xFF: EventType.END, + } diff --git a/test/audio_system.gd b/test/audio_system.gd index b8cb8a9..083a022 100644 --- a/test/audio_system.gd +++ b/test/audio_system.gd @@ -1,5 +1,6 @@ extends Node2D - +const MusicPlayer := preload('res://scripts/MusicPlayer.gd') +var MusicLoader := preload('res://scripts/loaders/snes/music_ff5.gd').new() var inst_buttons = [] var sfx_buttons = [] @@ -19,7 +20,7 @@ func _create_sfx_buttons(): var btn = Button.new() btn.text = 'SFX #%02X' % i btn.align = Button.ALIGN_CENTER - btn.set_position(Vector2((i%4)*50, 156 + (i/4)*24)) + btn.set_position(Vector2((i%4)*50, 130 + (i/4)*24)) btn.rect_min_size.x = 48 add_child(btn) btn.connect('pressed', SoundLoader, 'play_sfx', [i]) @@ -46,6 +47,66 @@ func _enable_inst_button(id: int): for i in id+1: inst_buttons[i].disabled = false +const NUM_CHANNELS := 8 +var music_player = null +var inst_sample_map := {} +var bgm_tracksets := {} + +func evaluate_bgm(id: int): + var buffer: StreamPeerBuffer = RomLoader.snes_buffer.duplicate() + var bgm_song_ptr: int = RomLoader.snes_data.bgm_song_pointers[id] & 0x3FFFFF + var bank_offset: int = bgm_song_ptr & 0x3F0000 + buffer.seek(bgm_song_ptr) + var length := buffer.get_u16() + var rom_address_base := buffer.get_u16() + var track_ptrs := PoolIntArray() + for i in NUM_CHANNELS: + var track_ptr := buffer.get_u16() + bank_offset + if track_ptr < bgm_song_ptr: + track_ptr += 0x010000 # next bank + track_ptrs.append(track_ptr) + var end_ptr := buffer.get_u16() + bank_offset + if end_ptr < bgm_song_ptr: + end_ptr += 0x010000 # next bank + var tracks := [] + for i in NUM_CHANNELS: + var track_ptr := track_ptrs[i] + # var channel: MusicChannel = self.channels[i] + # print('Unrolling BGM track %02d:%02d at 0x%06X:0x%06X:0x%06X' % [id, i, bgm_song_ptr, track_ptr, end_ptr]) + tracks.append(MusicLoader.unroll_track(buffer.duplicate(), bgm_song_ptr, track_ptr, end_ptr, '%02d:%02d'%[id, i])) + bgm_tracksets[id] = tracks + +func play_bgm(id: int) -> void: + var inst_indices = RomLoader.snes_data.bgm_instrument_indices[id] + for i in 16: + var inst_idx: int = inst_indices[i]-1 + if inst_idx < 0: + self.inst_sample_map[i + 0x20] = null + else: + self.inst_sample_map[i + 0x20] = SoundLoader.instrument_samples[inst_idx] + if self.music_player: + remove_child(music_player) + self.music_player = MusicPlayer.new(bgm_tracksets[id], self.inst_sample_map) + add_child(self.music_player) + self.music_player.is_playing = true + print('Playing BGM%02d' % id) + +func _play_bgm() -> void: + self.play_bgm($sb_bgm.value) + +func _create_bgm_playback() -> void: + $sb_bgm.max_value = SoundLoader.BGM_NUM + $btn_bgm.connect('pressed', self, '_play_bgm') + for i in SoundLoader.SFX_NUM: + self.inst_sample_map[i] = SoundLoader.sfx_samples[i] + for i in SoundLoader.BGM_NUM: + evaluate_bgm(i) + + # Called when the node enters the scene tree for the first time. func _ready() -> void: - _create_sfx_buttons() + self._create_sfx_buttons() + self._create_bgm_playback() + for i in len(RomLoader.snes_data.bgm_song_pointers): + var pointer = RomLoader.snes_data.bgm_song_pointers[i] + print('BGM 0x%02X (%02d) at 0x%06X' % [i, i, pointer]) diff --git a/test/audio_system.tscn b/test/audio_system.tscn index 2c8bc93..5e55f25 100644 --- a/test/audio_system.tscn +++ b/test/audio_system.tscn @@ -4,3 +4,26 @@ [node name="audio_system" type="Node2D"] script = ExtResource( 1 ) + +[node name="inst_buttons" type="ReferenceRect" parent="."] +margin_right = 348.0 +margin_bottom = 118.0 + +[node name="sfx_buttons" type="ReferenceRect" parent="."] +margin_top = 130.0 +margin_right = 198.0 +margin_bottom = 176.0 + +[node name="sb_bgm" type="SpinBox" parent="."] +margin_top = 192.0 +margin_right = 23.0 +margin_bottom = 214.0 +rect_min_size = Vector2( 38, 0 ) +align = 2 + +[node name="btn_bgm" type="Button" parent="."] +margin_left = 40.0 +margin_top = 192.0 +margin_right = 102.0 +margin_bottom = 214.0 +text = "Play BGM" diff --git a/test_scene.tscn b/test_scene.tscn index a515688..c4369d0 100644 --- a/test_scene.tscn +++ b/test_scene.tscn @@ -15,7 +15,6 @@ theme = ExtResource( 6 ) script = ExtResource( 3 ) [node name="audio_system" parent="." instance=ExtResource( 5 )] -visible = false position = Vector2( 0, 160 ) [node name="worldmap_system" parent="." instance=ExtResource( 2 )] @@ -30,3 +29,4 @@ visible = false margin_right = 320.0 [node name="BattleScene" parent="." instance=ExtResource( 7 )] +visible = false