n65/utils/midi/midi_to_nes.rb

205 lines
5.2 KiB
Ruby
Executable File

#!/usr/bin/env ruby
require 'yaml'
class MidiToNES
#### Custom Exceptions
class MidiFormatNotSupported < StandardError; end
#### Some Constants
NoteOff = 0x8
NoteOn = 0x9
#### A440 Tuning, and NES CPU speed in hz
Tuning = 440.0
CPU = 1789773.0
#### LSB Address registers of the APU, MSB is always 0x40
Pulse1Control = 0x00
Pulse1FT = 0x2
Pulse1CT = 0x3
####
## Initialize from a yaml file
def self.init_from_file(filename, bpm)
self.new(File.read(filename), bpm)
end
####
## Initialize with a yaml string
def initialize(yaml_string, bpm)
@bpm = bpm.to_f
@midi_data = YAML.load(yaml_string)[:midi_file]
unless @midi_data[:format].zero?
fail(MidiFormatNotSupported, "Currently only supports format 0 Midi Files")
end
@ticks_per_quarter_note = @midi_data[:ticks_per_quarter_note]
end
####
## Write to binary file
def write_binary(filename)
binary = convert
File.open(filename, 'wb') do |fp|
fp.write(binary)
end
end
####
## For now assume one track
def convert
tick_count = 1
events = []
track = @midi_data[:tracks].first
track[:events].each do |event|
delta, status, note, velocity = event.values_at(:delta, :status, :parameter1, :parameter2)
## The status byte contains both the Midi message type, and channel.
type = (status & 0b11110000) >> 4
channel = status & 0b00001111
## We only care about note on and off, and only care about channel 0 for now.
next unless type == NoteOn || type == NoteOff
next unless channel.zero?
## Update the total time
tick_count += delta
## Ok this is a note either turning on or off
if type == NoteOff || velocity.zero?
#event = {:start => tick_count, :note => note, :velocity => 0}
#events << event
else
event = {:start => tick_count, :note => note, :velocity => velocity}
events << event
end
end
## Finally sort event list by start time
events.sort! do |a, b|
a[:start] <=> b[:start]
end
## Now convert these events to a bytestream for our NES sound engine
events_to_byte_stream(events)
end
####
## This converts a list of note events into a byte stream for updating NES APU registers
def events_to_byte_stream(events)
last_tick = 1
byte_stream = []
events.each do |event|
## Work out the delta again
delta = event[:start] - last_tick
byte_stream << midi_tick_to_vblank(delta) # Delta
byte_stream << pulse_control_value(event) # Value
if event[:velocity].zero?
#byte_stream << 0 # Off with 0 frequency timer
#byte_stream << 0
else
byte_stream << pulse_ft_value(event) # Value
byte_stream << pulse_ct_value(event) # Value
end
last_tick += delta
end
byte_stream.pack('C*')
end
####
## Given an event, produce a value for register nes.apu.pulse1.control
## DDLC VVVV
## Duty (D), envelope loop / length counter halt (L), constant volume (C), volume/envelope (V)
def pulse_control_value(event)
## Start with 50% duty cycle, length counter halt is on
## Constant volume is On, and volume is determined by bit-reducing the event velocity to 4-bit
value = 0b10000111
#four_bit_max = (2**4 - 1)
#seven_bit_max = (2**7 - 1)
#volume_float = event[:velocity] / seven_bit_max.to_f
#volume_4_bit = (volume_float * four_bit_max).round & 0b00001111
#value | volume_4_bit
end
####
## Given an event, produce a value for register nes.apu.pulse1.ft
## TTTT TTTT
## This is the low byte of the timer, the higher few bits being in pulse1.ct
def pulse_ft_value(event)
midi_note_to_nes_timer(event[:note]) & 0xff
end
####
## Given an event, produce a value for register nes.apu.pulse1.ct
## LLLL LTTT
## This has the higher 3 bits of the timer, and L is the length counter.
## For now let's just use duration as the length counter.
def pulse_ct_value(event)
value = 0b11111000
## We will grab the high 3 bits of the 11-bit timer value now
timer_high_3bit = midi_note_to_nes_timer(event[:note]) & 0b11100000000
value | (timer_high_3bit >> 8)
end
####
## Midi note to NES timer
def midi_note_to_nes_timer(midi_note)
frequency = Tuning * 2**((midi_note - 69) / 12.0)
timer = (CPU / (16 * frequency)) - 1
if timer > (2**11 - 1)
fail("midi note #{midi_note} is too big at #{timer}")
end
timer.round
end
####
## Convert a MIDI tick delta to an NES vblank delta.
def midi_tick_to_vblank(midi_tick)
quarter_note_in_seconds = 60 / @bpm
vblanks_per_quarter_note = quarter_note_in_seconds / (1/60.0)
tick_normalized = midi_tick / @ticks_per_quarter_note.to_f
vblanks = tick_normalized * vblanks_per_quarter_note
vblanks.round
end
end
if __FILE__ == $0
unless ARGV.size == 2
STDERR.puts("Usage #{$0} <bpm> <music.mid>")
exit(1)
end
bpm, midi_file = ARGV
## Run the midi file through my converter written in C++
IO.popen("./convert #{midi_file}") do |io|
midi_to_nes = MidiToNES.new(io.read, bpm.to_i)
midi_to_nes.write_binary('../../data.mus')
end
end