上传文件至 /

This commit is contained in:
2026-01-28 03:48:19 +08:00
commit 73c6848247
2 changed files with 645 additions and 0 deletions

395
midi_key_queuer.lua Normal file
View File

@@ -0,0 +1,395 @@
-- 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 <file.mid> -- play immediately")
print(" lua midi_key_queuer.lua -- interactive mode")
print("\nCommands (interactive):")
print(" play <file.mid>")
print(" stop <sessionId>")
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 <sessionId>")
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.")