local component = require("component") local computer = require("computer") local shell = require("shell") local keyboard = require("keyboard") local note = require("note") local args, options = shell.parse(...) if #args < 1 then print("Usage: midi [-i] [track1 [track2 [...]]]") print("Where the tracks are the numbers of the tracks to actually play.") print(" -i: only parse and show track info, don't play.") return end -- Implements the essentials for MIDI file parsing, references: -- http://www.recordingblogs.com/sa/tabid/88/Default.aspx?topic=Musical+Instrument+Digital+Interface+(MIDI) -- http://www.sonicspot.com/guide/midifiles.html local enabledTracks = {n=0} for i = 2, #args do enabledTracks[tonumber(args[i])] = true enabledTracks.n = enabledTracks.n + 1 end local instruments = {} for address in component.list("note_block") do table.insert(instruments, function(note) -- 60 = C in MIDI, 6 = C in Minecraft component.invoke(address, "trigger", (note + 6 - 60) % 24 + 1) end) end if #instruments == 0 then local function beepableFrequency(midiCode) local freq = note.freq(midiCode) if freq <= 0 then error("Nonpositive frequency") end -- shift it by octaves so we at least get the right pitch while freq < 20 do freq = freq * 2 end while freq > 2000 do freq = freq / 2 end return freq end if component.isAvailable("beep") then print("No note blocks found, falling back to beep card.") local notes = {} instruments[1] = function(note, duration) notes[beepableFrequency(note)] = duration or 0.05 end instruments.flush = function() component.beep.beep(notes) for k, v in pairs(notes) do notes[k] = nil end end else print("No note blocks or beep card found, falling back to built-in speaker.") instruments[1] = function(note, duration) pcall(computer.beep, beepableFrequency(note), duration or 0.05) return true -- only one event per tick end end else print("Using " .. #instruments .. " note blocks.") end local filename = shell.resolve(args[1]) local f, reason = io.open(filename, "rb") if not f then print(reason) return end local function parseVarInt(s, bits) -- parses multiple bytes as an integer if not s then error("error parsing file") end bits = bits or 8 local mask = bit32.rshift(0xFF, 8 - bits) local num = 0 for i = 1, s:len() do num = num + bit32.lshift(bit32.band(s:byte(i), mask), (s:len() - i) * bits) end return num end local function readChunkInfo() -- reads chunk header info local id = f:read(4) if not id then return end return id, parseVarInt(f:read(4)) end -- Read the file header and with if file information. local id, size = readChunkInfo() if id ~= "MThd" or size ~= 6 then print("error parsing header (" .. id .. "/" .. size .. ")") return end local format = parseVarInt(f:read(2)) local tracks = parseVarInt(f:read(2)) local delta = parseVarInt(f:read(2)) if format < 0 or format > 2 then print("unknown format") return end local formatName = ({"single", "synchronous", "asynchronous"})[format + 1] print(string.format("Found %d %s tracks.", tracks, formatName)) if format == 2 then print("Sorry, asynchronous tracks are not supported.") return end -- Figure out our time system and prepare accordingly. local time = {division = bit32.band(0x8000, delta) == 0 and "tpb" or "fps"} if time.division == "tpb" then time.tpb = bit32.band(0x7FFF, delta) time.mspb = 500000 function time.tick() return time.mspb / time.tpb end print(string.format("Time division is in %d ticks per beat.", time.tpb)) else time.fps = bit32.band(0x7F00, delta) time.tpf = bit32.band(0x00FF, delta) function time.tick() return 1000000 / (time.fps * time.tpf) end print(string.format("Time division is in %d frames per second with %d ticks per frame.", time.fps, time.tpf)) end function time.calcDelay(later, earlier) return (later - earlier) * time.tick() / 1000000 end -- Parse all track chunks. local totalOffset = 0 local totalLength = 0 local tracks = {} while true do local id, size = readChunkInfo() if not id then break end if id == "MTrk" then local track = {} local cursor = 0 local start, offset = f:seek(), 0 local inSysEx = false local running = 0 local function read(n) n = n or 1 if n > 0 then offset = offset + n return f:read(n) end end local function readVariableLength() local total = "" for i = 1, math.huge do local part = read() total = total .. part if bit32.band(0x80, part:byte(1)) == 0 then return parseVarInt(total, 7) end end end local function parseVoiceMessage(event) local channel = bit32.band(0xF, event) local note = parseVarInt(read()) local velocity = parseVarInt(read()) return channel, note, velocity end local currentNoteEvents = {} local function noteOn(cursor, channel, note, velocity) track[cursor] = {channel, note, velocity} if not currentNoteEvents[channel] then currentNoteEvents[channel] = {} end currentNoteEvents[channel][note] = {event=track[cursor], tick=cursor} end local function noteOff(cursor, channel, note, velocity) if not (currentNoteEvents[channel] and currentNoteEvents[channel][note]) then return end table.insert(currentNoteEvents[channel][note].event , time.calcDelay(cursor, currentNoteEvents[channel][note].tick)) currentNoteEvents[channel][note] = nil end while offset < size do cursor = cursor + readVariableLength() totalLength = math.max(totalLength, cursor) local test = parseVarInt(read()) if inSysEx and test ~= 0xF7 then error("corrupt file: could not find continuation of divided sysex event") end local event if bit32.band(test, 0x80) == 0 then if running == 0 then error("corrupt file: invalid running status") end f.bufferRead = string.char(test) .. f.bufferRead offset = offset - 1 event = running else event = test if test < 0xF0 then running = test end end local status = bit32.band(0xF0, event) if status == 0x80 then -- Note off. local channel, note, velocity = parseVoiceMessage(event) noteOff(cursor, channel, note, velocity) elseif status == 0x90 then -- Note on. local channel, note, velocity = parseVoiceMessage(event) if velocity == 0 then noteOff(cursor, channel, note, velocity) else noteOn(cursor, channel, note, velocity) end elseif status == 0xA0 then -- Aftertouch / key pressure parseVoiceMessage(event) -- not handled elseif status == 0xB0 then -- Controller parseVoiceMessage(event) -- not handled elseif status == 0xC0 then -- Program change parseVarInt(read()) -- not handled elseif status == 0xD0 then -- Channel pressure parseVarInt(read()) -- not handled elseif status == 0xE0 then -- Pitch / modulation wheel parseVarInt(read(2), 7) -- not handled elseif event == 0xF0 then -- System exclusive event local length = readVariableLength() if length > 0 then read(length - 1) inSysEx = read(1):byte(1) ~= 0xF7 end elseif event == 0xF1 then -- MIDI time code quarter frame parseVarInt(read()) -- not handled elseif event == 0xF2 then -- Song position pointer parseVarInt(read(2), 7) -- not handled elseif event == 0xF3 then -- Song select parseVarInt(read(2), 7) -- not handled elseif event == 0xF7 then -- Divided system exclusive event local length = readVariableLength() if length > 0 then read(length - 1) inSysEx = read(1):byte(1) ~= 0xF7 else inSysEx = false end elseif event >= 0xF8 and event <= 0xFE then -- System real-time event -- not handled elseif event == 0xFF then -- Meta message. local metaType = parseVarInt(read()) local length = parseVarInt(read()) local data = read(length) if metaType == 0x00 then -- Sequence number track.sequence = parseVarInt(data) elseif metaType == 0x01 then -- Text event elseif metaType == 0x02 then -- Copyright notice elseif metaType == 0x03 then -- Sequence / track name track.name = data elseif metaType == 0x04 then -- Instrument name track.instrument = data elseif metaType == 0x05 then -- Lyric text elseif metaType == 0x06 then -- Marker text elseif metaType == 0x07 then -- Cue point elseif metaType == 0x20 then -- Channel prefix assignment elseif metaType == 0x2F then -- End of track track.eot = cursor elseif metaType == 0x51 then -- Tempo setting track[cursor] = parseVarInt(data) elseif metaType == 0x54 then -- SMPTE offset elseif metaType == 0x58 then -- Time signature elseif metaType == 0x59 then -- Key signature elseif metaType == 0x7F then -- Sequencer specific event end else f:seek("cur", -9) local area = f:read(16) local dump = "" for i = 1, area:len() do dump = dump .. string.format(" %02X", area:byte(i)) if i % 4 == 0 then dump = dump .. "\n" end end error(string.format("midi file contains unhandled event types:\n0x%X at offset %d/%d\ndump of the surrounding area:\n%s", event, offset, size, dump)) end end -- turn off any remaining notes for iChannel, iNotes in pairs(currentNoteEvents) do for iNote, iEntry in pairs(currentNoteEvents[iChannel]) do noteOff(cursor, iChannel, iNote) end end local delta = size - offset if delta ~= 0 then f:seek("cur", delta) end totalOffset = totalOffset + size table.insert(tracks, track) else print(string.format("Encountered unknown chunk type %s, skipping.", id)) f:seek("cur", size) end end f:close() if options.i then print(string.format("Found %d tracks, total length is %d ticks.", #tracks, totalLength)) for i, track in ipairs(tracks) do if track.name then print(string.format("#%d: %s", i, track.name)) end end return end local removed = 0 if enabledTracks.n > 0 then for i = #tracks, 1, -1 do if not enabledTracks[i] then table.remove(tracks, i) removed = removed + 1 end end end print("Playing " .. #tracks .. " tracks:") for _, track in ipairs(tracks) do if track.name then print(string.format("%s", track.name)) end end local channels = {n=0} local lastTick, lastTime = 0, computer.uptime() print("Press Ctrl+C to exit.") for tick = 1, totalLength do local hasEvent = false for _, track in ipairs(tracks) do if track[tick] then hasEvent = true break end end if hasEvent then local delay = time.calcDelay(tick, lastTick) -- delay % 0.05 == 0 doesn't seem to work if math.floor(delay * 100 + 0.5) % 5 == 0 then os.sleep(delay) else -- Busy idle otherwise, because a sleep will take up to 50ms. local begin = os.clock() while os.clock() - begin < delay do end end lastTick = tick lastTime = computer.uptime() for _, track in ipairs(tracks) do local event = track[tick] if event then if type(event) == "number" then time.mspb = event elseif type(event) == "table" then local channel, note, velocity, duration = table.unpack(event) local instrument if not channels[channel] then channels.n = channels.n + 1 channels[channel] = instruments[1 + (channels.n % #instruments)] end if channels[channel](note, duration) then break end end end end if instruments.flush then instruments.flush() end end if keyboard.isKeyDown(keyboard.keys.c) and keyboard.isControlDown() then os.exit() end end