diff --git a/repository/apps/app-nbcompose.lua b/repository/apps/app-nbcompose.lua new file mode 100644 index 0000000..c7417d1 --- /dev/null +++ b/repository/apps/app-nbcompose.lua @@ -0,0 +1,345 @@ +-- This is released into the public domain. +-- No warranty is provided, implied or otherwise. + +-- app-nbcompose.lua : Music! +-- Authors: 20kdc + +local nb = neo.requireAccess("c.iron_noteblock", "noteblocks").list()() +local ic = neo.requireAccess("x.neo.pub.base", "fs") + +local event = require("event")(neo) +local neoux = require("neoux")(event, neo) + +local iTranslation = { + [0] = 0, -- piano / air (def) + 4, -- double bass / wood (def) + 1, -- bass drum / stone (def) + 2, -- snare drum / sand (def) + 3, -- click / glass (def) +-- JUST GIVE UP + 4, -- guitar / wool + 5, -- flute / clay + 6, -- bell / gold + 6, -- chime / pice + 6, -- xylo / bone +} +local instKey = { + [2] = 0, + [3] = 1, + [4] = 2, + [5] = 3, + [6] = 4, + [144] = 5, + [7] = 5, + [8] = 6, + [9] = 7, + [10] = 8, + [11] = 9 +} +local noteKey = "1q2w3er5t6yu8i9o0pzsxdcvg" +-- Application State +local fileData +local uptime = os.uptime() +local songPosition = 0 +local selectionL, selectionR = -8, -9 +local running = true +local playing = false +local timerExistsFlag = false +local window +local defInst = 0 +-- +local tick -- Tick function for timer making + +local file = require("knbs").new() +-- Window width is always 50. Height is layers + 3, for the top bar. + +local theStatusBar, theNotePane, genMain + +local function updateStatusAndPane() + if theStatusBar.update then theStatusBar.update(window) end + if theNotePane then + for _, v in ipairs(theNotePane) do + v.update(window) + end + end +end + +local function commonKey(a, c, f) + if a == 32 then + playing = not playing + theStatusBar.update(window) + if playing then + if not timerExistsFlag then + uptime = os.uptime() + event.runAt(uptime, tick) + timerExistsFlag = true + end + end + elseif a == 91 then + selectionL = songPosition + updateStatusAndPane() + elseif a == 93 then + selectionR = songPosition + updateStatusAndPane() + elseif c == 203 and (f.shift or f.rshift) then + songPosition = 0 + updateStatusAndPane() + elseif c == 205 and (f.shift or f.rshift) then + songPosition = file.length + updateStatusAndPane() + elseif c == 203 then + songPosition = math.max(0, songPosition - 1) + updateStatusAndPane() + elseif c == 205 then + songPosition = songPosition + 1 + updateStatusAndPane() + end +end + +theStatusBar = { + x = 1, + y = 3, + w = 50, + h = 1, + selectable = true, + line = function (window, x, y, lined, bg, fg, selected) + if selected then + bg, fg = fg, bg + end + window.span(x, y, ((playing and "Playing") or "Paused") .. " (SPACE) ; " .. (songPosition + 1) .. "/" .. file.length .. " ([Shift-]←/→)", bg, fg) + end, + key = function (window, update, a, c, d, f) + if not d then return end + commonKey(a, c, f) + end +} + +local function genLayers() + theStatusBar.update = nil + theNotePane = nil + local layers = {} + for i = 1, file.height do + local layer = i - 1 + table.insert(layers, neoux.tcfield(1, i + 1, 40, function (tx) + file.layers[layer][1] = tx or file.layers[layer][1] + return file.layers[layer][1] + end)) + table.insert(layers, neoux.tcrawview(42, i + 1, {"Vol."})) + table.insert(layers, neoux.tcfield(46, i + 1, 5, function (tx) + if tx then + file.layers[layer][2] = math.max(0, math.min(255, math.floor(tonumber(tx) or 0))) + end + return tostring(file.layers[layer][2]) + end)) + end + return 50, file.height + 1, nil, neoux.tcwindow(50, file.height + 1, { + neoux.tcbutton(1, 1, "Purge Extra Layers", function (w) + local knbs = require("knbs") + local layerCount = knbs.correctSongLH(file) + knbs.resizeLayers(file, layerCount) + w.reset(genLayers()) + end), + neoux.tcbutton(21, 1, "Del.Last", function (w) + require("knbs").resizeLayers(file, file.height - 1) + w.reset(genLayers()) + end), + neoux.tcbutton(31, 1, "Append", function (w) + require("knbs").resizeLayers(file, file.height + 1) + w.reset(genLayers()) + end), + table.unpack(layers) + }, function (w) + w.reset(genMain()) + end, 0xFFFFFF, 0) +end +function genMain() + theNotePane = {} + for l = 1, file.height do + local layer = l - 1 + theNotePane[l] = { + x = 1, + y = 3 + l, + w = 50, + h = 1, + selectable = true, + line = function (window, x, y, lined, bg, fg, selected) + if selected then + bg, fg = fg, bg + end + local text = "" + for i = 1, 5 do + local noteL, noteR = " ", " " + local tick = songPosition + i - 3 + if songPosition == tick then + noteL = "[" + noteR = "]" + end + if selectionR >= selectionL then + if selectionL == tick then + noteL = "{" + end + if selectionR == tick then + noteR = "}" + end + end + text = text .. noteL + local fd = file.ticks[tick] + fd = fd and fd[layer] + if fd then + text = text .. string.format(" %02i/%02i", fd[1], fd[2]) + else + text = text .. " " + end + text = text .. noteR + end + window.span(x, y, text, bg, fg) + end, + key = function (window, update, a, c, d, f) + if not d then return end + commonKey(a, c, f) + if a == 8 then + if file.ticks[songPosition] then + file.ticks[songPosition][layer] = nil + require("knbs").correctSongLH(file) + update() + theStatusBar.update(window) + end + elseif instKey[c] and (f.shift or f.rshift) then + file.ticks[songPosition] = file.ticks[songPosition] or {} + defInst = instKey[c] + local nt = 45 + if file.ticks[songPosition][layer] then + file.ticks[songPosition][layer][1] = defInst + nt = file.ticks[songPosition][layer][2] + end + nb.playNote(iTranslation[defInst] or 0, nt - 33, file.layers[layer][2] / 100) + require("knbs").correctSongLH(file) + update() + theStatusBar.update(window) + elseif a >= 0 and a < 256 and noteKey:find(string.char(a), 1, true) then + file.ticks[songPosition] = file.ticks[songPosition] or {} + local note = noteKey:find(string.char(a), 1, true) - 1 + file.ticks[songPosition][layer] = {defInst, note + 33} + nb.playNote(iTranslation[defInst] or 0, note, file.layers[layer][2] / 100) + require("knbs").correctSongLH(file) + update() + theStatusBar.update(window) + elseif a == 123 then + if selectionR >= selectionL then + local storage = {} + for i = selectionL, selectionR do + storage[i] = file.ticks[i] and file.ticks[i][layer] and {table.unpack(file.ticks[i][layer])} + end + for i = selectionL, selectionR do + local p = songPosition + (i - selectionL) + file.ticks[p] = file.ticks[p] or {} + file.ticks[p][layer] = storage[i] + end + require("knbs").correctSongLH(file) + update() + theStatusBar.update(window) + end + end + end + } + end + -- We totally lie about the height here to tcwindow. "Bit of a cheat, but who's counting", anyone? + -- It is explicitly documented that the width and height are for background drawing, BTW. + return 50, file.height + 3, nil, neoux.tcwindow(50, 3, { + neoux.tcfield(1, 1, 20, function (tx) + file.name = tx or file.name + return file.name + end), + neoux.tcfield(21, 1, 15, function (tx) + file.transcriptor = tx or file.transcriptor + return file.transcriptor + end), + neoux.tcfield(36, 1, 15, function (tx) + file.songwriter = tx or file.songwriter + return file.songwriter + end), + neoux.tcbutton(1, 2, "New", function (w) + file = require("knbs").new() + songPosition = 0 + playing = false + window.reset(genMain()) + end), + neoux.tcbutton(6, 2, "Load", function (w) + neoux.fileDialog(false, function (f) + if not f then return end + file = nil + file = require("knbs").deserialize(f.read("*a")) + f.close() + songPosition = 0 + playing = false + window.reset(genMain()) + end) + end), + neoux.tcbutton(12, 2, "Save", function (w) + neoux.fileDialog(true, function (f) + if not f then return end + require("knbs").serialize(file, f.write) + f.close() + end) + end), + neoux.tcbutton(18, 2, "Ds.L", function (w) + neoux.fileDialog(false, function (f) + if not f then return end + file.description = f.read("*a") + f.close() + end) + end), + neoux.tcbutton(24, 2, "Ds.S", function (w) + neoux.fileDialog(true, function (f) + if not f then return end + f.write(file.description) + f.close() + end) + end), + neoux.tcbutton(30, 2, "Layers", function (w) + window.reset(genLayers()) + end), + neoux.tcrawview(39, 2, {"qT/S"}), + neoux.tcfield(43, 2, 8, function (tx) + if tx then + local txn = tonumber(tx) or 0 + file.tempo = math.min(math.max(0, math.floor(txn * 4)), 65535) + end + return tostring(math.floor(file.tempo / 25)) + end), + theStatusBar, + table.unpack(theNotePane) + }, function (w) + w.close() + running = false + end, 0xFFFFFF, 0) +end + +function tick() + if playing then + -- Stop the user from entering such a low tempo that stuff freezes by: + -- 1. Stopping tempo from going too low to cause /0 + -- 2. Ensuring timer is at most 1 second away + local temp = 1 / math.max(file.tempo / 100, 0.01) + if os.uptime() >= uptime + temp then + -- execute at this song position + if file.ticks[songPosition] then + for i = 0, file.height - 1 do + local tck = file.ticks[songPosition][i] + if tck then + nb.playNote(iTranslation[tck[1]] or 0, tck[2] - 33, file.layers[i][2] / 100) + end + end + end + songPosition = songPosition + 1 + if songPosition >= file.length then songPosition = 0 end + updateStatusAndPane() + uptime = uptime + temp + end + event.runAt(math.min(os.uptime() + 1, uptime + temp), tick) + else + timerExistsFlag = false + end +end +window = neoux.create(genMain()) +while running do event.pull() end diff --git a/repository/data/app-claw/local.lua b/repository/data/app-claw/local.lua index f44920e..98b09e5 100644 --- a/repository/data/app-claw/local.lua +++ b/repository/data/app-claw/local.lua @@ -164,6 +164,24 @@ return { "docs/repoauthors/app-rsctrl" }, }, + ["app-nbcompose"] = { + desc = "Music player/composer using the NBS format", + v = 0, + deps = { + "neo", + "lib-knbs", + "zzz-license-pd" + }, + dirs = { + "apps", + "docs", + "docs/repoauthors" + }, + files = { + "apps/app-nbcompose.lua", + "docs/repoauthors/app-nbcompose" + }, + }, ["app-launchbar"] = { desc = "Application launcher bar", v = 0, @@ -198,6 +216,23 @@ return { "docs/repoauthors/app-slaunch" }, }, + -- libraries + ["lib-knbs"] = { + desc = "NBS reader/writer library", + v = 0, + deps = { + "zzz-license-pd" + }, + dirs = { + "libs", + "docs", + "docs/repoauthors" + }, + files = { + "libs/knbs.lua", + "docs/repoauthors/lib-knbs" + }, + }, -- licenses (MUST BE IMMUTABLE) ["zzz-license-pd"] = { desc = "license file 'Public Domain'", diff --git a/repository/docs/repoauthors/app-nbcompose b/repository/docs/repoauthors/app-nbcompose new file mode 100644 index 0000000..fbe5fe4 --- /dev/null +++ b/repository/docs/repoauthors/app-nbcompose @@ -0,0 +1,2 @@ +repository/apps/app-nbcompose.lua: 20kdc, Public Domain + diff --git a/repository/docs/repoauthors/lib-knbs b/repository/docs/repoauthors/lib-knbs new file mode 100644 index 0000000..d460ac2 --- /dev/null +++ b/repository/docs/repoauthors/lib-knbs @@ -0,0 +1,2 @@ +repository/libs/knbs.lua: 20kdc, Public Domain + diff --git a/repository/libs/knbs.lua b/repository/libs/knbs.lua new file mode 100644 index 0000000..3ce12b4 --- /dev/null +++ b/repository/libs/knbs.lua @@ -0,0 +1,203 @@ +-- This is released into the public domain. +-- No warranty is provided, implied or otherwise. + +-- knbs.lua : Partial .nbs (Note Block Studio) R/W library +-- Does not support custom instruments! +-- Authors: 20kdc + +local function dsu16(str) + return + str:byte(1) + + (str:byte(2) * 256), + str:sub(3) +end +local function dsu32(str) + local a, str = dsu16(str) + local b, str = dsu16(str) + return a + (b * 0x10000), str +end +local function dsstr(str) + local a, str = dsu32(str) + return str:sub(1, a), str:sub(a + 1) +end +local function su16(i, wr) + wr(string.char(i % 0x100, math.floor(i / 0x100))) +end +local function su32(i, wr) + su16(i % 0x10000, wr) + su16(math.floor(i / 0x10000), wr) +end +local function sstr(str, wr) + su32(#str, wr) + wr(str) +end + +return { + new = function () + return { + length = 1, + height = 1, + name = "New Song", + transcriptor = "Mr.Anderson", + songwriter = "Morpheus", + description = "A blank song.", + tempo = 200, + autosave = 0, + autosaveMin = 60, + timeSignature = 4, + usageMin = 0, usageLeft = 0, usageRight = 0, usageAdd = 0, usageRm = 0, + importName = "", + ci = "", + ticks = { + [0] = { + [0] = {0, 33} + } + }, + layers = { + [0] = {"L0", 100} + } + } + end, + deserialize = function (str) + local nbs = {} + nbs.length, str = dsu16(str) + nbs.length = nbs.length + 1 -- hmph! + nbs.height, str = dsu16(str) + nbs.name, str = dsstr(str) + nbs.transcriptor, str = dsstr(str) + nbs.songwriter, str = dsstr(str) + nbs.description, str = dsstr(str) + nbs.tempo, str = dsu16(str) + nbs.autosave, str = str:byte(), str:sub(2) + nbs.autosaveMin, str = str:byte(), str:sub(2) + nbs.timeSignature, str = str:byte(), str:sub(2) + nbs.usageMin, str = dsu32(str) + nbs.usageLeft, str = dsu32(str) + nbs.usageRight, str = dsu32(str) + nbs.usageAdd, str = dsu32(str) + nbs.usageRm, str = dsu32(str) + nbs.importName, str = dsstr(str) + -- ticks[tick][layer] = key + nbs.ticks = {} + local tick = -1 + while true do + local ntJ + ntJ, str = dsu16(str) + if ntJ == 0 then break end + tick = tick + ntJ + local tickData = {} + nbs.ticks[tick] = tickData + local layer = -1 + while true do + local lJ + lJ, str = dsu16(str) + if lJ == 0 then break end + layer = layer + lJ + local ins = str:byte(1) + local key = str:byte(2) + str = str:sub(3) + local layerData = {ins, key} + if layer < nbs.height then + tickData[layer] = layerData + -- else: drop the invalid note + end + end + end + -- nbs.layers[layer] = {name, volume} + nbs.layers = {} + if str ~= "" then + for i = 0, nbs.height - 1 do + nbs.layers[i] = {} + nbs.layers[i][1], str = dsstr(str) + nbs.layers[i][2], str = str:byte(), str:sub(2) + end + else + for i = 0, nbs.height - 1 do + nbs.layers[i] = {"L" .. i, 100} + end + end + nbs.ci = str + return nbs + end, + resizeLayers = function (nbs, layers) + -- make all layers after target layer go away + for i = layers, nbs.height - 1 do + nbs.layers[i] = nil + end + -- add layers up to target + for i = nbs.height - 1, layers - 1 do + nbs.layers[i] = {"L" .. i, 100} + end + -- clean up song + for k, v in pairs(nbs.ticks) do + for lk, lv in pairs(v) do + if lk >= layers then + v[lk] = nil + end + end + end + nbs.height = layers + end, + -- Corrects length, height (should not be necessary in correct applications!), and clears out unused tick columns. + -- Returns the actual effective height, which can be passed to resizeLayers to remove dead weight. + correctSongLH = function (nbs) + nbs.length = 1 + nbs.height = 0 + for k, v in pairs(nbs.layers) do + nbs.height = math.max(nbs.height, k + 1) + end + local eH = 0 + for k, v in pairs(nbs.ticks) do + local ok = false + for lk, lv in pairs(v) do + ok = true + eH = math.max(eH, lk + 1) + end + if not ok then + nbs.ticks[k] = nil + else + nbs.length = math.max(nbs.length, k + 1) + end + end + return eH + end, + serialize = function (nbs, wr) + su16(math.max(0, nbs.length - 1), wr) + su16(nbs.height, wr) + sstr(nbs.name, wr) + sstr(nbs.transcriptor, wr) + sstr(nbs.songwriter, wr) + sstr(nbs.description, wr) + su16(nbs.tempo, wr) + wr(string.char(nbs.autosave, nbs.autosaveMin, nbs.timeSignature)) + su32(nbs.usageMin, wr) + su32(nbs.usageLeft, wr) + su32(nbs.usageRight, wr) + su32(nbs.usageAdd, wr) + su32(nbs.usageRm, wr) + sstr(nbs.importName, wr) + local ptr = -1 + for i = 0, nbs.length - 1 do + if nbs.ticks[i] then + su16(i - ptr, wr) + ptr = i + local lp = -1 + for j = 0, nbs.height - 1 do + local id = nbs.ticks[i][j] + if id then + su16(j - lp, wr) + lp = j + wr(string.char(id[1], id[2])) + end + end + su16(0, wr) + end + end + su16(0, wr) + for i = 0, nbs.height - 1 do + sstr(nbs.layers[i][1], wr) + wr(string.char(nbs.layers[i][2])) + end + wr(nbs.ci) + end +}