TheDarBear

midi_player

Aug 20th, 2025 (edited)
93
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Lua 6.03 KB | None | 0 0
  1. -- midi_player.lua
  2. -- Usage:
  3. --   midi_player <tuple_file> [speaker_side_or_name] [volume]
  4. -- Example:
  5. --   midi_player tuples_test.txt right 2.0
  6. --
  7. -- Tuple file format (one note per line):
  8. --   timestamp_ms, pitch, instrument
  9. --   - timestamp_ms: integer milliseconds from time 0 (e.g., 0, 250, 500, ...)
  10. --   - pitch: integer 0..24 (Minecraft note block semitone index)
  11. --   - instrument: one of the note block instruments, e.g.:
  12. --       harp, bass, basedrum, snare, hat, bell, chime, flute, guitar, xylophone,
  13. --       iron_xylophone, cow_bell, didgeridoo, bit, banjo, pling
  14. -- Lines may allow spaces or parentheses, e.g. "(500, 12, harp)". Comments start with '#'.
  15.  
  16. local args = { ... }
  17. if #args < 1 then
  18.   print("Usage: midi_player <tuple_file> [speaker_side_or_name] [volume]")
  19.   return
  20. end
  21.  
  22. local TUPLE_FILE = args[1]
  23. local SPEAKER_ID = args[2] -- optional: side ("right") or peripheral name
  24. local VOLUME     = tonumber(args[3]) or 2.0  -- default loud-ish but not max (0.0..3.0)
  25.  
  26. -- Validate/resolve speaker peripheral
  27. local speaker
  28. local function resolveSpeaker()
  29.   if SPEAKER_ID and peripheral.isPresent(SPEAKER_ID) and peripheral.getType(SPEAKER_ID) == "speaker" then
  30.     return peripheral.wrap(SPEAKER_ID)
  31.   end
  32.   -- Fallback: find first attached speaker
  33.   local found = { peripheral.find("speaker") }
  34.   if #found > 0 then return found[1] end
  35.   return nil
  36. end
  37.  
  38. speaker = resolveSpeaker()
  39. if not speaker then
  40.   print("Error: No speaker peripheral found. Attach one or provide side/name.")
  41.   return
  42. end
  43.  
  44. -- Helpers
  45. local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end
  46.  
  47. -- Accept instruments list for basic validation (not strictly required)
  48. local validInstruments = {
  49.   harp=true, bass=true, basedrum=true, snare=true, hat=true,
  50.   bell=true, chime=true, flute=true, guitar=true, xylophone=true,
  51.   iron_xylophone=true, cow_bell=true, didgeridoo=true, bit=true,
  52.   banjo=true, pling=true
  53. }
  54.  
  55. -- Parse a single line into {t=<ms>, p=<pitch>, i=<instrument>} or nil
  56. -- Accepts formats like:
  57. --   500,12,harp
  58. --   500 12 harp
  59. --   (500, 12, harp)
  60. --   500 , 12 , "harp"
  61. local function parseTupleLine(line, lineNo)
  62.   -- strip comments
  63.   local commentStart = line:find("#", 1, true)
  64.   if commentStart then line = line:sub(1, commentStart - 1) end
  65.   line = trim(line)
  66.   if line == "" then return nil end
  67.  
  68.   -- Remove optional wrapping parentheses
  69.   if line:match("^%s*%b()$") then
  70.     line = line:gsub("^%s*%(", ""):gsub("%)%s*$", "")
  71.     line = trim(line)
  72.   end
  73.  
  74.   -- Try CSV first
  75.   local t_ms, pitch, inst = line:match("^%s*(%-?%d+)%s*[,;]%s*(%-?%d+)%s*[,;]%s*([%w_%-]+)%s*$")
  76.   if not t_ms then
  77.     -- Try space separated
  78.     t_ms, pitch, inst = line:match("^%s*(%-?%d+)%s+(%-?%d+)%s+([%w_%-]+)%s*$")
  79.   end
  80.   if not t_ms then
  81.     -- Try CSV with quoted instrument
  82.     local t1, p1, qinst = line:match('^%s*(%-?%d+)%s*[,;]%s*(%-?%d+)%s*[,;]%s*"(.-)"%s*$')
  83.     if t1 and p1 and qinst then
  84.       t_ms, pitch, inst = t1, p1, qinst
  85.     end
  86.   end
  87.  
  88.   if not t_ms then
  89.     print(("Warning: line %d unrecognized format: %q"):format(lineNo, line))
  90.     return nil
  91.   end
  92.  
  93.   local t = tonumber(t_ms)
  94.   local p = tonumber(pitch)
  95.   local i = trim(inst)
  96.  
  97.   if not t or not p or not i or i == "" then
  98.     print(("Warning: line %d has missing fields: %q"):format(lineNo, line))
  99.     return nil
  100.   end
  101.  
  102.   -- Clamp pitch to 0..24 (MC note block range)
  103.   if p < 0 then p = 0 end
  104.   if p > 24 then p = 24 end
  105.  
  106.   -- Normalize instrument to lower case
  107.   i = i:lower()
  108.  
  109.   if not validInstruments[i] then
  110.     -- Still allow it (MC will just try the sound), but warn
  111.     print(("Note: line %d uses instrument %q (not in common list)"):format(lineNo, i))
  112.   end
  113.  
  114.   return { t = t, p = p, i = i }
  115. end
  116.  
  117. -- Load tuples
  118. local function loadTuples(path)
  119.   if not fs.exists(path) then
  120.     error("Tuple file not found: " .. path)
  121.   end
  122.   local fh = fs.open(path, "r")
  123.   if not fh then error("Failed to open " .. path) end
  124.   local out = {}
  125.   local lineNo = 0
  126.   while true do
  127.     local line = fh.readLine()
  128.     if not line then break end
  129.     lineNo = lineNo + 1
  130.     local tup = parseTupleLine(line, lineNo)
  131.     if tup then table.insert(out, tup) end
  132.   end
  133.   fh.close()
  134.  
  135.   -- Sort by time, then by pitch/instrument for stable order
  136.   table.sort(out, function(a, b)
  137.     if a.t ~= b.t then return a.t < b.t end
  138.     if a.i ~= b.i then return a.i < b.i end
  139.     return a.p < b.p
  140.   end)
  141.  
  142.   return out
  143. end
  144.  
  145. -- Playback engine: schedules by wall clock in milliseconds using os.epoch("utc")
  146. local function playTuples(tuples, baseVolume)
  147.   if #tuples == 0 then
  148.     print("No notes to play.")
  149.     return
  150.   end
  151.  
  152.   print(("Loaded %d notes. Starting playback..."):format(#tuples))
  153.   local startEpoch = os.epoch("utc")
  154.   local idx = 1
  155.   while idx <= #tuples do
  156.     local nowMs = os.epoch("utc") - startEpoch
  157.     local due = tuples[idx].t
  158.     if nowMs + 1 >= due then
  159.       -- Play all notes that are now due (allow small 1ms tolerance)
  160.       while idx <= #tuples and tuples[idx].t <= (os.epoch("utc") - startEpoch) + 1 do
  161.         local n = tuples[idx]
  162.         -- Play the note; volume baseVolume, pitch semitone
  163.         -- speaker.playNote(instrument, volume, pitch)
  164.         local ok, err = pcall(speaker.playNote, n.i, baseVolume, n.p)
  165.         if not ok then
  166.           print(("Play error at t=%dms, inst=%s, pitch=%d: %s"):format(n.t, n.i, n.p, tostring(err)))
  167.         end
  168.         idx = idx + 1
  169.       end
  170.     else
  171.       -- Sleep a little until the next note (use small slices for timing accuracy)
  172.       local waitMs = math.max(0, due - nowMs)
  173.       -- Don't sleep the entire gap at once; cap at 0.05s (~1 tick) for responsiveness
  174.       local sleepSec = math.min(waitMs / 1000, 0.05)
  175.       if sleepSec > 0 then sleep(sleepSec) else os.queueEvent("yield"); os.pullEvent() end
  176.     end
  177.   end
  178.   print("Playback finished.")
  179. end
  180.  
  181. -- Main
  182. local tuples
  183. local ok, err = pcall(function() tuples = loadTuples(TUPLE_FILE) end)
  184. if not ok then
  185.   print("Error loading tuples: " .. tostring(err))
  186.   return
  187. end
  188.  
  189. playTuples(tuples, VOLUME)
  190.  
Advertisement
Add Comment
Please, Sign In to add comment