From 73c6848247cf2fd60eff176828cb37a3a38cf0de Mon Sep 17 00:00:00 2001 From: xingluo Date: Wed, 28 Jan 2026 03:48:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6=E8=87=B3?= =?UTF-8?q?=20/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- midi_key_queuer.lua | 395 ++++++++++++++++++++++++++++++++++++++ piano_speaker_manager.lua | 250 ++++++++++++++++++++++++ 2 files changed, 645 insertions(+) create mode 100644 midi_key_queuer.lua create mode 100644 piano_speaker_manager.lua diff --git a/midi_key_queuer.lua b/midi_key_queuer.lua new file mode 100644 index 0000000..2732ef5 --- /dev/null +++ b/midi_key_queuer.lua @@ -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 -- 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.") \ No newline at end of file diff --git a/piano_speaker_manager.lua b/piano_speaker_manager.lua new file mode 100644 index 0000000..c6a687b --- /dev/null +++ b/piano_speaker_manager.lua @@ -0,0 +1,250 @@ +-- Piano speaker manager for ComputerCraft / CC:Tweaked +-- 功能: +-- - 自动发现所有 speaker 周边设备并形成池 +-- - 将播放请求分配给未锁定的 speaker 并锁定,播放结束后解锁(使用 timer) +-- - 检测 peripheral attach/detach,动态更新池 +-- - 当需要播放的音数 > 未锁定 speaker 数量时,在屏幕上显示警告 +-- - 使用 peripheral.call(speakerName, "playNote", instrument, note) 播放原版音 + +local keys = keys or require("keys") -- 在 CC:Tweaked 中 keys 库存在;若不存在请直接移除这一行 +local term = term +local peripheral = peripheral +local os = os +local print = print + +-- 配置区 ----------------------------------------------------------------- +local DEFAULT_INSTRUMENT = "harp" -- 你可以改为 "bassdrum", "snare", 等支持的 instrument +local DEFAULT_NOTE_DURATION = 0.5 -- 单位:秒,播放结束后解锁 speaker 所用的估计时长(可按需调整或为每个 note 指定) +-- 键位到音符的映射(示例):键名 -> {instrument, note, duration} +-- note 采用整数(0..24 等,根据你使用的 playNote 接口),duration 可选覆盖默认时长 +local KEY_NOTE_MAP = { + eight = {instrument = DEFAULT_INSTRUMENT, note = 0, duration = 0.5}, -- F#3 + nine = {instrument = DEFAULT_INSTRUMENT, note = 1, duration = 0.5}, -- G3 + zero = {instrument = DEFAULT_INSTRUMENT, note = 2, duration = 0.5}, -- G#3 + minus = {instrument = DEFAULT_INSTRUMENT, note = 3, duration = 0.5}, -- A3 + equals = {instrument = DEFAULT_INSTRUMENT, note = 4, duration = 0.5}, -- A#3 + q = {instrument = DEFAULT_INSTRUMENT, note = 5, duration = 0.5}, -- B3 + w = {instrument = DEFAULT_INSTRUMENT, note = 6, duration = 0.5}, -- C4 + e = {instrument = DEFAULT_INSTRUMENT, note = 7, duration = 0.5}, -- C#4 + r = {instrument = DEFAULT_INSTRUMENT, note = 8, duration = 0.5}, -- D4 + t = {instrument = DEFAULT_INSTRUMENT, note = 9, duration = 0.5}, -- D#4 + y = {instrument = DEFAULT_INSTRUMENT, note = 10, duration = 0.5}, -- E4 + u = {instrument = DEFAULT_INSTRUMENT, note = 11, duration = 0.5}, -- F4 + i = {instrument = DEFAULT_INSTRUMENT, note = 12, duration = 0.5}, -- F#4 + o = {instrument = DEFAULT_INSTRUMENT, note = 13, duration = 0.5}, -- G4 + p = {instrument = DEFAULT_INSTRUMENT, note = 14, duration = 0.5}, -- G#4 + leftBracket = {instrument = DEFAULT_INSTRUMENT, note = 15, duration = 0.5}, -- A4 + rightBracket = {instrument = DEFAULT_INSTRUMENT, note = 16, duration = 0.5}, -- A#4 + a = {instrument = DEFAULT_INSTRUMENT, note = 17, duration = 0.5}, -- B4 + s = {instrument = DEFAULT_INSTRUMENT, note = 18, duration = 0.5}, -- C5 + d = {instrument = DEFAULT_INSTRUMENT, note = 19, duration = 0.5}, -- C#5 + f = {instrument = DEFAULT_INSTRUMENT, note = 20, duration = 0.5}, -- D5 + g = {instrument = DEFAULT_INSTRUMENT, note = 21, duration = 0.5}, -- D#5 + h = {instrument = DEFAULT_INSTRUMENT, note = 22, duration = 0.5}, -- E5 + j = {instrument = DEFAULT_INSTRUMENT, note = 23, duration = 0.5}, -- F5 + k = {instrument = DEFAULT_INSTRUMENT, note = 24, duration = 0.5} -- F#5 +} + +-- 内部状态 ---------------------------------------------------------------- +local speakers = {} -- speaker 池: key = peripheralName -> {name=name, locked=false} +local timers = {} -- timers[id] = {speakerName=name} +local timerToSpeaker = {} -- 反向映射 timerId -> speakerName + +-- helper: 刷新 speaker 列表(扫描当前连接的 peripheral) +local function refreshSpeakers() + local names = peripheral.getNames() + local newPool = {} + for _, name in ipairs(names) do + local t = peripheral.getType(name) + if t == "speaker" then + newPool[name] = { name = name, locked = false } + end + end + speakers = newPool +end + +-- helper: 添加单个 speaker(attach 事件) +local function addSpeaker(name) + if peripheral.getType(name) == "speaker" then + speakers[name] = { name = name, locked = false } + print("[speaker] attached:", name) + end +end + +-- helper: 移除单个 speaker(detach 事件) +local function removeSpeaker(name) + if speakers[name] then + speakers[name] = nil + print("[speaker] detached:", name) + end +end + +local function countUnlocked() + local c = 0 + for _, s in pairs(speakers) do + if not s.locked then c = c + 1 end + end + return c +end + +-- 找到一个未锁定的 speaker 名称(返回 peripheralName 或 nil) +local function findFreeSpeaker() + for name, s in pairs(speakers) do + if not s.locked then + return name + end + end + return nil +end + +-- 锁定 speaker 并发起播放(使用 peripheral.call) +local function lockAndPlay(speakerName, instrument, note, duration) + local s = speakers[speakerName] + if not s then + return false, "speaker not found" + end + if s.locked then + return false, "speaker already locked" + end + + s.locked = true + -- 设置解锁计时器 + local t = os.startTimer(duration or DEFAULT_NOTE_DURATION) + timers[t] = { speaker = speakerName } + timerToSpeaker[t] = speakerName + + -- 异常保护:如果 playNote 调用失败则立即解锁并返回错误 + local ok, err = pcall(peripheral.call, speakerName, "playNote", instrument, 3, note) + if not ok then + -- 立刻解锁并取消 timer 映射(timer 仍会到期,但我们删除映射以避免重复解锁) + s.locked = false + timers[t] = nil + timerToSpeaker[t] = nil + return false, err + end + + return true +end + +-- 解锁 speaker(timer 到期或其他情况) +local function unlockSpeaker(speakerName) + local s = speakers[speakerName] + if s then + s.locked = false + end +end + +-- 在屏幕显示警告(短时间显示) +local function showWarning(msg, seconds) + seconds = seconds or 2 + local w, h = term.getSize() + term.setCursorPos(1, h) + term.clearLine() + term.setTextColor(colors.red) + term.write("[WARNING] " .. msg) + term.setTextColor(colors.white) + -- 设一个计时器把警告清掉 + os.startTimer(seconds) + -- 注意:这里不阻塞主循环,主循环会处理 timer 并清理最后一行(见主循环中 timer 处理) +end + +-- 播放多个音(notes 是一组 {instrument, note, duration}) +-- 返回 true/false, message +local function playNotes(notes) + if type(notes) ~= "table" then + return false, "notes must be a table" + end + local need = #notes + local unlocked = countUnlocked() + if need > unlocked then + showWarning(string.format("需要播放 %d 个音,但只有 %d 个可用扬声器,播放被拒绝。", need, unlocked)) + return false, "not enough unlocked speakers" + end + + -- 为每个音分配 speaker 并播放 + local assigned = {} + for i, n in ipairs(notes) do + local freeName = findFreeSpeaker() + if not freeName then + -- 失败时尝试回滚已分配的(解锁) + for _, nm in ipairs(assigned) do unlockSpeaker(nm) end + return false, "no free speaker during assignment" + end + local duration = n.duration or DEFAULT_NOTE_DURATION + local ok, err = lockAndPlay(freeName, n.instrument or DEFAULT_INSTRUMENT, n.note, duration) + if not ok then + -- 回滚 + for _, nm in ipairs(assigned) do unlockSpeaker(nm) end + return false, err + end + table.insert(assigned, freeName) + end + + return true +end + +-- 初始化:扫描 speakers +refreshSpeakers() +print("[info] found speakers:") +for name,_ in pairs(speakers) do print(" - " .. name) end +print("[info] ready. Press mapped keys to play notes. Ctrl+T to terminate (or stop the program).") + +-- 主事件循环 +while true do + local ev = { os.pullEventRaw() } -- 使用 pullEventRaw 防止 ctrl+t 自动关闭时丢失信息(按需修改) + local et = ev[1] + + if et == "key" then + local keycode = ev[2] + local keyname = keys.getName(keycode) + if keyname then + -- 支持同时按多个键:这里简单做单键响应;若要和弦需要监听 "char"/其他并维护按下集合 + local mapping = KEY_NOTE_MAP[keyname] + if mapping then + local ok, err = playNotes({ mapping }) + if not ok then + print("[error] playNotes: " .. tostring(err)) + end + end + end + + elseif et == "timer" then + local timerId = ev[2] + -- 如果是警告计时器(没有在 timers 映射中)则清掉最后一行 + if timers[timerId] then + local rec = timers[timerId] + local sname = rec.speaker + unlockSpeaker(sname) + timers[timerId] = nil + timerToSpeaker[timerId] = nil + -- 可选日志 + -- print(string.format("[info] speaker %s unlocked (timer %d)", sname, timerId)) + else + -- 可能是警告清除定时器:清掉最后一行 + -- 检查屏幕高度并清除最后一行 + local w, h = term.getSize() + term.setCursorPos(1, h) + term.clearLine() + end + + elseif et == "peripheral" then + -- attach: ev = {"peripheral", sideName, peripheralType} + local sideName = ev[2] + local pType = ev[3] + if pType == "speaker" then + addSpeaker(sideName) + end + + elseif et == "peripheral_detach" then + local sideName = ev[2] + removeSpeaker(sideName) + + elseif et == "terminate" then + -- Ctrl+T + print("[info] terminated by user.") + break + else + -- 其它事件忽略或用于扩展(如鼠标、多键和弦检测等) + -- print("event", et) + end +end \ No newline at end of file