-- midi_key_queuer.lua -- MIDI -> key-event queuer for ComputerCraft / CC:Tweaked -- Parses .mid and injects os.queueEvent("key", keycode) to drive piano_speaker_manager.lua -- -- Assumptions: -- - piano_speaker_manager.lua uses the EXACT key names below. -- - Minecraft note = 0 is F#3 (MIDI 54), so MIDI -> CC: cc = midi - 54 -- - Only notes in range [54, 78] (F#3 to F#5) are playable (cc 0..24) local fs = fs local os = os local term = term local keys = keys local print = print local tonumber = tonumber local math = math -- ============== Key mapping: keyname -> Minecraft note (0=F#3, 24=F#5) ============== local KEY_TO_CC = { eight = 0, -- F#3 (MIDI 54) nine = 1, -- G3 (MIDI 55) zero = 2, -- G#3 (MIDI 56) minus = 3, -- A3 (MIDI 57) equals = 4, -- A#3 (MIDI 58) q = 5, -- B3 (MIDI 59) w = 6, -- C4 (MIDI 60) e = 7, -- C#4 (MIDI 61) r = 8, -- D4 (MIDI 62) t = 9, -- D#4 (MIDI 63) y = 10, -- E4 (MIDI 64) u = 11, -- F4 (MIDI 65) i = 12, -- F#4 (MIDI 66) o = 13, -- G4 (MIDI 67) p = 14, -- G#4 (MIDI 68) leftBracket = 15, -- A4 (MIDI 69) rightBracket = 16, -- A#4 (MIDI 70) a = 17, -- B4 (MIDI 71) s = 18, -- C5 (MIDI 72) d = 19, -- C#5 (MIDI 73) f = 20, -- D5 (MIDI 74) g = 21, -- D#5 (MIDI 75) h = 22, -- E5 (MIDI 76) j = 23, -- F5 (MIDI 77) k = 24, -- F#5 (MIDI 78) } local CC_TO_KEY = {} for k, cc in pairs(KEY_TO_CC) do CC_TO_KEY[cc] = k end -- MIDI base: Minecraft note = 0 corresponds to MIDI 54 (F#3) local MIDI_BASE = 54 -- NOT 48! -- ============== Build keyname -> keycode lookup ============== local function buildKeycodeLookup() local lookup = {} for code = 0, 255 do local name = keys.getName(code) if name then lookup[name] = code end end return lookup end -- ============== MIDI Parser (no bitwise ops) ============== local function parseMidiFileToEvents(path) if not fs.exists(path) then return nil, "file not found: " .. tostring(path) end local f = fs.open(path, "rb") if not f then return nil, "cannot open file" end local data = f.readAll() f.close() local bytes = {} for i = 1, #data do bytes[i] = string.byte(data, i) end local pos = 1 local function eof() return pos > #bytes end local function readByte() if eof() then return nil end local b = bytes[pos] pos = pos + 1 return b end local function read(n) local t = {} for i = 1, n do t[#t + 1] = readByte() end return t end local function readUint16() local b1 = readByte() local b2 = readByte() return b1 * 256 + b2 end local function readUint32() local b1 = readByte() local b2 = readByte() local b3 = readByte() local b4 = readByte() return ((b1 * 256 + b2) * 256 + b3) * 256 + b4 end local function bytesToString(arr) local s = "" for i = 1, #arr do s = s .. string.char(arr[i]) end return s end local function readVarLen() local value = 0 while true do local b = readByte() if not b then return nil, "Unexpected EOF in varlen" end value = value * 128 + (b % 128) if b < 128 then break end end return value end local function readChunkHeader() local idBytes = read(4) if #idBytes < 4 then return nil, nil end local id = bytesToString(idBytes) local len = readUint32() return id, len end -- Header local id, hlen = readChunkHeader() if id ~= "MThd" then return nil, "not a MIDI file (no MThd)" end if hlen < 6 then return nil, "invalid header length" end local format = readUint16() local ntrks = readUint16() local division = readUint16() if division >= 0x8000 then return nil, "SMPTE time division not supported" end local ticksPerQuarter = division local tempoEvents = { { tick = 0, usPerQuarter = 500000 } } local collectedNotes = {} for tr = 1, ntrks do local cid, clen = readChunkHeader() if cid ~= "MTrk" then return nil, "expected MTrk" end local trackEnd = pos + clen local absTick = 0 local runningStatus = nil local activeNotes = {} -- activeNotes[channel][note] = onTick while pos < trackEnd do local delta, derr = readVarLen() if not delta then return nil, derr end absTick = absTick + delta local status = bytes[pos] if status == 0xFF then pos = pos + 1 local metaType = readByte() local l = readVarLen() local meta = read(l) if metaType == 0x51 and #meta == 3 then local us = meta[1] * 65536 + meta[2] * 256 + meta[3] tempoEvents[#tempoEvents + 1] = { tick = absTick, usPerQuarter = us } end runningStatus = nil elseif status == 0xF0 or status == 0xF7 then pos = pos + 1 local l = readVarLen() read(l) runningStatus = nil else if status < 0x80 then if not runningStatus then return nil, "running status without previous" end status = runningStatus else pos = pos + 1 runningStatus = status end local eventType = math.floor(status / 16) local channel = status % 16 local param1 = readByte() local param2 = nil if eventType ~= 0xC and eventType ~= 0xD then param2 = readByte() end if eventType == 0x9 then -- Note On local note = param1 local vel = param2 or 0 if vel > 0 then activeNotes[channel] = activeNotes[channel] or {} activeNotes[channel][note] = absTick else if activeNotes[channel] and activeNotes[channel][note] then local onTick = activeNotes[channel][note] collectedNotes[#collectedNotes + 1] = { note = note, onTick = onTick, offTick = absTick } activeNotes[channel][note] = nil end end elseif eventType == 0x8 then -- Note Off local note = param1 if activeNotes[channel] and activeNotes[channel][note] then local onTick = activeNotes[channel][note] collectedNotes[#collectedNotes + 1] = { note = note, onTick = onTick, offTick = absTick } activeNotes[channel][note] = nil end end end end pos = trackEnd end -- Convert ticks to seconds table.sort(tempoEvents, function(a, b) return a.tick < b.tick end) local function tickToSeconds(targetTick) local sec = 0 local prevTick = 0 local prevUs = tempoEvents[1].usPerQuarter local i = 1 while i <= #tempoEvents and tempoEvents[i].tick <= targetTick do local te = tempoEvents[i] sec = sec + (te.tick - prevTick) * (prevUs / ticksPerQuarter) / 1e6 prevTick = te.tick prevUs = te.usPerQuarter i = i + 1 end sec = sec + (targetTick - prevTick) * (prevUs / ticksPerQuarter) / 1e6 return sec end local events = {} for _, n in ipairs(collectedNotes) do local startS = tickToSeconds(n.onTick) local endS = tickToSeconds(n.offTick) local dur = math.max(0.01, endS - startS) local cc = n.note - MIDI_BASE -- MIDI -> Minecraft note if cc < 0 then cc = 0 end if cc > 24 then cc = 24 end events[#events + 1] = { time = startS, cc = cc, duration = dur } end table.sort(events, function(a, b) return a.time < b.time end) return events end -- ============== Scheduler ============== local scheduledTimers = {} local sessionCounter = 0 local sessions = {} local function scheduleEventsAsKeypresses(events, keycodeLookup) sessionCounter = sessionCounter + 1 local sid = sessionCounter sessions[sid] = true for _, ev in ipairs(events) do local delay = ev.time if delay < 0 then delay = 0 end local timerId = os.startTimer(delay) local keyname = CC_TO_KEY[ev.cc] local keycode = keycodeLookup[keyname] scheduledTimers[timerId] = { session = sid, keycode = keycode, cc = ev.cc, keyname = keyname, duration = ev.duration } end return sid end local function stopSession(sid) if sessions[sid] == nil then return false, "no such session" end sessions[sid] = false return true end -- ============== UI / CLI ============== local args = { ... } local keycodeLookup = buildKeycodeLookup() -- Warn about missing keys local missing = {} for k in pairs(KEY_TO_CC) do if not keycodeLookup[k] then missing[#missing + 1] = k end end if #missing > 0 then print("[WARN] Missing keycodes on this computer:") for _, k in ipairs(missing) do print(" - " .. k) end print("[WARN] Events for these keys will be skipped.") end local function printMapping() print("Key mapping (keyname -> MC note -> MIDI):") for k, cc in pairs(KEY_TO_CC) do local midi = MIDI_BASE + cc print(string.format(" %-14s -> %2d -> %3d", k, cc, midi)) end end local function usage() print("Usage:") print(" lua midi_key_queuer.lua -- play immediately") print(" lua midi_key_queuer.lua -- interactive mode") print("\nCommands (interactive):") print(" play ") print(" stop ") print(" mapping") print(" exit") end local function startPlayback(path) print("[info] Parsing MIDI:", path) local events, err = parseMidiFileToEvents(path) if not events then print("[error] Parse failed:", err) return nil, err end print(("[info] Parsed %d note events"):format(#events)) local sid = scheduleEventsAsKeypresses(events, keycodeLookup) print(("[info] Scheduled session %d"):format(sid)) return sid end -- Auto-play if arg given local stdinMode = (#args == 0) if #args >= 1 and args[1] ~= "" then startPlayback(args[1]) print("Running... Press Ctrl+T to terminate.") else print("Interactive mode. Type 'help' for commands.") end -- Main loop local running = true while running do local ev = { os.pullEvent() } local et = ev[1] if et == "timer" then local tid = ev[2] local rec = scheduledTimers[tid] scheduledTimers[tid] = nil if rec and sessions[rec.session] then if rec.keycode then os.queueEvent("key", rec.keycode) else print(string.format("[warn] No keycode for CC %d (%s)", rec.cc, tostring(rec.keyname))) end end elseif et == "key" and stdinMode then term.write("> ") local line = term.read() if line then local cmd, arg = line:match("^(%S+)%s*(.*) $ ") cmd = cmd and cmd:lower() if cmd == "play" and arg ~= "" then startPlayback(arg) elseif cmd == "stop" and arg ~= "" then local id = tonumber(arg) if id then local ok, err = stopSession(id) if ok then print(("Session %d stopped"):format(id)) else print("[error]", err) end else print("Usage: stop ") end elseif cmd == "mapping" then printMapping() elseif cmd == "help" or cmd == "usage" then usage() elseif cmd == "exit" or cmd == "quit" then running = false else print("Unknown command:", cmd or "") end end elseif et == "terminate" then break end end print("[info] Exiting midi_key_queuer.")