上传文件至 /
This commit is contained in:
395
midi_key_queuer.lua
Normal file
395
midi_key_queuer.lua
Normal 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.")
|
||||
250
piano_speaker_manager.lua
Normal file
250
piano_speaker_manager.lua
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user