Whiskee icon

OpenComputers Midi Player

Whiskee | PRO | 04/07/17 05:54:35 PM UTC | 0 ⭐ | 278 👁️ | Never ⏰ | []
Lua |

12.38 KB

|

None

|

0 👍

/

0 👎

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] <filename> [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

Comments