Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- -- midi_player.lua
- -- Usage:
- -- midi_player <tuple_file> [speaker_side_or_name] [volume]
- -- Example:
- -- midi_player tuples_test.txt right 2.0
- --
- -- Tuple file format (one note per line):
- -- timestamp_ms, pitch, instrument
- -- - timestamp_ms: integer milliseconds from time 0 (e.g., 0, 250, 500, ...)
- -- - pitch: integer 0..24 (Minecraft note block semitone index)
- -- - instrument: one of the note block instruments, e.g.:
- -- harp, bass, basedrum, snare, hat, bell, chime, flute, guitar, xylophone,
- -- iron_xylophone, cow_bell, didgeridoo, bit, banjo, pling
- -- Lines may allow spaces or parentheses, e.g. "(500, 12, harp)". Comments start with '#'.
- local args = { ... }
- if #args < 1 then
- print("Usage: midi_player <tuple_file> [speaker_side_or_name] [volume]")
- return
- end
- local TUPLE_FILE = args[1]
- local SPEAKER_ID = args[2] -- optional: side ("right") or peripheral name
- local VOLUME = tonumber(args[3]) or 2.0 -- default loud-ish but not max (0.0..3.0)
- -- Validate/resolve speaker peripheral
- local speaker
- local function resolveSpeaker()
- if SPEAKER_ID and peripheral.isPresent(SPEAKER_ID) and peripheral.getType(SPEAKER_ID) == "speaker" then
- return peripheral.wrap(SPEAKER_ID)
- end
- -- Fallback: find first attached speaker
- local found = { peripheral.find("speaker") }
- if #found > 0 then return found[1] end
- return nil
- end
- speaker = resolveSpeaker()
- if not speaker then
- print("Error: No speaker peripheral found. Attach one or provide side/name.")
- return
- end
- -- Helpers
- local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end
- -- Accept instruments list for basic validation (not strictly required)
- local validInstruments = {
- harp=true, bass=true, basedrum=true, snare=true, hat=true,
- bell=true, chime=true, flute=true, guitar=true, xylophone=true,
- iron_xylophone=true, cow_bell=true, didgeridoo=true, bit=true,
- banjo=true, pling=true
- }
- -- Parse a single line into {t=<ms>, p=<pitch>, i=<instrument>} or nil
- -- Accepts formats like:
- -- 500,12,harp
- -- 500 12 harp
- -- (500, 12, harp)
- -- 500 , 12 , "harp"
- local function parseTupleLine(line, lineNo)
- -- strip comments
- local commentStart = line:find("#", 1, true)
- if commentStart then line = line:sub(1, commentStart - 1) end
- line = trim(line)
- if line == "" then return nil end
- -- Remove optional wrapping parentheses
- if line:match("^%s*%b()$") then
- line = line:gsub("^%s*%(", ""):gsub("%)%s*$", "")
- line = trim(line)
- end
- -- Try CSV first
- local t_ms, pitch, inst = line:match("^%s*(%-?%d+)%s*[,;]%s*(%-?%d+)%s*[,;]%s*([%w_%-]+)%s*$")
- if not t_ms then
- -- Try space separated
- t_ms, pitch, inst = line:match("^%s*(%-?%d+)%s+(%-?%d+)%s+([%w_%-]+)%s*$")
- end
- if not t_ms then
- -- Try CSV with quoted instrument
- local t1, p1, qinst = line:match('^%s*(%-?%d+)%s*[,;]%s*(%-?%d+)%s*[,;]%s*"(.-)"%s*$')
- if t1 and p1 and qinst then
- t_ms, pitch, inst = t1, p1, qinst
- end
- end
- if not t_ms then
- print(("Warning: line %d unrecognized format: %q"):format(lineNo, line))
- return nil
- end
- local t = tonumber(t_ms)
- local p = tonumber(pitch)
- local i = trim(inst)
- if not t or not p or not i or i == "" then
- print(("Warning: line %d has missing fields: %q"):format(lineNo, line))
- return nil
- end
- -- Clamp pitch to 0..24 (MC note block range)
- if p < 0 then p = 0 end
- if p > 24 then p = 24 end
- -- Normalize instrument to lower case
- i = i:lower()
- if not validInstruments[i] then
- -- Still allow it (MC will just try the sound), but warn
- print(("Note: line %d uses instrument %q (not in common list)"):format(lineNo, i))
- end
- return { t = t, p = p, i = i }
- end
- -- Load tuples
- local function loadTuples(path)
- if not fs.exists(path) then
- error("Tuple file not found: " .. path)
- end
- local fh = fs.open(path, "r")
- if not fh then error("Failed to open " .. path) end
- local out = {}
- local lineNo = 0
- while true do
- local line = fh.readLine()
- if not line then break end
- lineNo = lineNo + 1
- local tup = parseTupleLine(line, lineNo)
- if tup then table.insert(out, tup) end
- end
- fh.close()
- -- Sort by time, then by pitch/instrument for stable order
- table.sort(out, function(a, b)
- if a.t ~= b.t then return a.t < b.t end
- if a.i ~= b.i then return a.i < b.i end
- return a.p < b.p
- end)
- return out
- end
- -- Playback engine: schedules by wall clock in milliseconds using os.epoch("utc")
- local function playTuples(tuples, baseVolume)
- if #tuples == 0 then
- print("No notes to play.")
- return
- end
- print(("Loaded %d notes. Starting playback..."):format(#tuples))
- local startEpoch = os.epoch("utc")
- local idx = 1
- while idx <= #tuples do
- local nowMs = os.epoch("utc") - startEpoch
- local due = tuples[idx].t
- if nowMs + 1 >= due then
- -- Play all notes that are now due (allow small 1ms tolerance)
- while idx <= #tuples and tuples[idx].t <= (os.epoch("utc") - startEpoch) + 1 do
- local n = tuples[idx]
- -- Play the note; volume baseVolume, pitch semitone
- -- speaker.playNote(instrument, volume, pitch)
- local ok, err = pcall(speaker.playNote, n.i, baseVolume, n.p)
- if not ok then
- print(("Play error at t=%dms, inst=%s, pitch=%d: %s"):format(n.t, n.i, n.p, tostring(err)))
- end
- idx = idx + 1
- end
- else
- -- Sleep a little until the next note (use small slices for timing accuracy)
- local waitMs = math.max(0, due - nowMs)
- -- Don't sleep the entire gap at once; cap at 0.05s (~1 tick) for responsiveness
- local sleepSec = math.min(waitMs / 1000, 0.05)
- if sleepSec > 0 then sleep(sleepSec) else os.queueEvent("yield"); os.pullEvent() end
- end
- end
- print("Playback finished.")
- end
- -- Main
- local tuples
- local ok, err = pcall(function() tuples = loadTuples(TUPLE_FILE) end)
- if not ok then
- print("Error loading tuples: " .. tostring(err))
- return
- end
- playTuples(tuples, VOLUME)
Advertisement
Add Comment
Please, Sign In to add comment