local Y=require("yaru.core") local gfx=require("yaru.gfx") local ui=require("yaru.ui") local fs=require("filesystem") local component=require("component") local computer=require("computer") local serialization=require("serialization") local unicode=require("unicode") local M={} local DIR="/home/Music" local INSTRUMENTS={"harp","basedrum","snare","hat","bass","flute","bell","guitar","chime","xylophone", "iron_xylophone","cow_bell","didgeridoo","bit","banjo","pling"} local COLORS={0x0049BF,0xE95420,0x26A269,0xC061CB,0xE5A50A,0x1C71D8,0xC01C28,0x865E3C} local NAMES={"F#3","G3","G#3","A3","A#3","B3","C4","C#4","D4","D#4","E4","F4", "F#4","G4","G#4","A4","A#4","B4","C5","C#5","D5","D#5","E5","F5","F#5"} local POS,SHARP={},{} do local letters={{0,true},{1},{1,true},{2},{2,true},{3},{4},{4,true},{5},{5,true},{6},{7}, {7,true},{8},{8,true},{9},{9,true},{10},{11},{11,true},{12},{12,true},{13},{14},{14,true}} for p=0,24 do POS[p],SHARP[p]=letters[p+1][1],letters[p+1][2]or false end end local function pitchAt(pos,sharp) for p=0,24 do if POS[p]==pos and SHARP[p]==(sharp and true or false)then return p end end if pos==0 then return 0 end end local function devices() local r={} for addr in component.list("note_block")do r[#r+1]={kind="note_block",addr=addr,name="Note block "..addr:sub(1,4)}end for addr in component.list("iron_noteblock")do r[#r+1]={kind="iron",addr=addr,name="Iron note block "..addr:sub(1,4)}end r[#r+1]={kind="beep",name="Computer speaker"} return r end local function playOn(dev,pitch,inst) if not dev then return end if dev.kind=="note_block"then if not pcall(component.invoke,dev.addr,"trigger",pitch+1)then pcall(component.invoke,dev.addr,"trigger")end elseif dev.kind=="iron"then pcall(component.invoke,dev.addr,"playNote",inst or"harp",pitch) else local oct=({bass=-24,didgeridoo=-24,guitar=-12,flute=12,cow_bell=12,bell=24,chime=24,xylophone=24})[inst or""]or 0 pcall(computer.beep,math.max(20,math.min(2000,440*2^((pitch+oct-15)/12))),0.08) end end local WIRING_FILE="/etc/yaru-composer.cfg" local SIDE_NAMES={[0]="bottom","top","back","front","right","left"} local function loadWiring() local f=io.open(WIRING_FILE) if not f then return{}end local ok,t=pcall(serialization.unserialize,f:read("*a")) f:close() return ok and type(t)=="table"and t or{} end local function saveWiring(w) local f=io.open(WIRING_FILE,"w") if f then f:write(serialization.serialize(w));f:close()end end local function rsSet(addr,sides,bundled,on) if next(sides)then local t={} for side in pairs(sides)do t[side]=on and 15 or 0 end if not pcall(component.invoke,addr,"setOutput",t)then for side in pairs(sides)do pcall(component.invoke,addr,"setOutput",side,on and 15 or 0)end end end for side,colors in pairs(bundled)do local t={} for c in pairs(colors)do t[c]=on and 255 or 0 end if not pcall(component.invoke,addr,"setBundledOutput",side,t)then for c in pairs(colors)do pcall(component.invoke,addr,"setBundledOutput",side,c,on and 255 or 0)end end end end function M.open(path) local st={step=1,scroll=0,track=1,playing=false,writeMode=true,dirty=false} local song local win=Y.newWindow{app="composer",title="Composer",w=124,h=36,minW=80,minH=30} local devs=devices() local function newSong() song={bpm=120,spb=2,length=32,tracks={},notes={}} for i,d in ipairs(devs)do song.tracks[i]={dev=d.addr or"beep",inst="harp",mute=false}end st.path,st.dirty,st.step,st.scroll=nil,false,1,0 win.title="Untitled song" end local function devOf(ti) local t=song.tracks[ti] if not t then return devs[#devs]end if t.dev then for _,d in ipairs(devs)do if(d.addr or"beep")==t.dev then return d end end end local real=#devs-1 if real<=0 then return devs[#devs]end return devs[(ti-1)%real+1] end local function noteAt(step,ti,pitch) local list=song.notes[step] if not list then return end for i,n in ipairs(list)do if n[1]==ti and(not pitch or n[2]==pitch)then return i,n end end end local function toggle(step,ti,pitch) song.notes[step]=song.notes[step]or{} local i=noteAt(step,ti,pitch) if i then table.remove(song.notes[step],i)else table.insert(song.notes[step],{ti,pitch});playOn(devOf(ti),pitch,song.tracks[ti].inst)end if#song.notes[step]==0 then song.notes[step]=nil end local last=0 for s in pairs(song.notes)do if s>last then last=s end end local bar=song.spb*4 song.length=math.max(bar*2,(last//bar+1)*bar+bar) st.dirty=true end local wiring=loadWiring() local blockPitch={} local function wired(dev)return dev and dev.kind=="note_block"and wiring[dev.addr]end local function prefetch(s) for _,n in ipairs(song.notes[s]or{})do local t,dev=song.tracks[n[1]],devOf(n[1]) if t and not t.mute and wired(dev)and blockPitch[dev.addr]~=n[2]then if pcall(component.invoke,dev.addr,"setPitch",n[2]+1)then blockPitch[dev.addr]=n[2]end end end end local function playStep(s) local fire={} local direct={} for _,n in ipairs(song.notes[s]or{})do local t,dev=song.tracks[n[1]],devOf(n[1]) if t and not t.mute then local wire=wired(dev) if wire and component.proxy(wire.rs)then if blockPitch[dev.addr]~=n[2]then if pcall(component.invoke,dev.addr,"setPitch",n[2]+1)then blockPitch[dev.addr]=n[2]end end local f=fire[wire.rs]or{sides={},bundled={}} fire[wire.rs]=f if wire.color then f.bundled[wire.side]=f.bundled[wire.side]or{} f.bundled[wire.side][wire.color]=true else f.sides[wire.side]=true end else direct[#direct+1]={dev,n[2],t.inst} end end end for addr,f in pairs(fire)do rsSet(addr,f.sides,f.bundled,true)end for _,d in ipairs(direct)do playOn(d[1],d[2],d[3])end for addr,f in pairs(fire)do rsSet(addr,f.sides,f.bundled,false)end prefetch(s+1>song.length and 1 or s+1) end local function save(p) p=p or st.path if not p then Y.dialog{title="Save Song",text="File name",entry=DIR.."/song.yms",buttons={{"Cancel"},{"Save",function(txt) if txt and txt~=""then if not txt:match("%.yms$")then txt=txt..".yms"end save(txt)end end,"suggested"}}} return end fs.makeDirectory(fs.path(p)) local f,err=io.open(p,"w") if not f then return Y.notify("Composer",tostring(err))end f:write(serialization.serialize(song)) f:close() st.path,st.dirty=p,false win.title=fs.name(p) end local function load(p) local f=io.open(p) if not f then return Y.notify("Composer","Cannot open "..p)end local ok,t=pcall(serialization.unserialize,f:read("*a")) f:close() if not ok or type(t)~="table"or type(t.notes)~="table"then return Y.notify("Composer","Not a song file")end song=t song.bpm,song.spb,song.length=song.bpm or 120,song.spb or 2,song.length or 32 song.tracks=song.tracks or{} if#song.tracks==0 then song.tracks[1]={inst="harp"}end for _,t in ipairs(song.tracks)do t.inst=t.inst or"harp"end st.path,st.dirty,st.step,st.scroll,st.track,st.ttop=p,false,1,0,1,0 win.title=fs.name(p) end local function setPlaying(on) st.playing=on if on then st.playStep=st.step blockPitch={} prefetch(st.step) win.tickInterval=60/song.bpm/song.spb st.nextAt=computer.uptime() else win.tickInterval=nil st.playStep=nil end end local SIDE,STEPW=28,4 local layout={} local function drawClef(x,row0) local art={"..##.",".#..#",".#.#.","..#..",".##..","#.#..","#.###","#.#.#",".###.","..#..","#.#..",".#..."} local top=row0*2+1 for i,line in ipairs(art)do for j=1,#line do if line:sub(j,j)=="#"then gfx.pset(x+j-1,top+i,Y.theme.fg)end end end end function win.header(w,hw) local x=2 x=x+ui.button(w,x,1,"New",function()newSong()end,"flat")+1 x=x+ui.button(w,x,1,"Open…",function() Y.dialog{title="Open Song",entry=st.path or(DIR.."/"),buttons={{"Cancel"},{"Open",function(p)if p and p~=""then load(p)end end,"suggested"}}} end,"flat")+1 x=x+ui.button(w,x,1,"Save",function()save()end,"flat")+1 x=x+2 x=x+ui.button(w,x,1,st.playing and"■ Stop"or"▶ Play",function()setPlaying(not st.playing)end,"suggested",9)+1 x=x+ui.button(w,x,1,"↻",function()st.loop=not st.loop end,st.loop and"suggested"or"flat",3)+2 gfx.text(x,1,"♩=",Y.theme.headerFg) x=x+3 ui.button(w,x,1,"−",function()song.bpm=math.max(30,song.bpm-10);if st.playing then win.tickInterval=60/song.bpm/song.spb end end,nil,3) gfx.text(x+4,1,tostring(song.bpm),Y.theme.headerFg) ui.button(w,x+8,1,"+",function()song.bpm=math.min(300,song.bpm+10);if st.playing then win.tickInterval=60/song.bpm/song.spb end end,nil,3) end function win.draw(w,cw,ch) local t=Y.theme gfx.fill(1,1,SIDE,ch,t.sidebar) gfx.text(2,1,"Tracks",t.dim) local rows=(ch-3)//3 st.ttop=st.ttop or 0 if st.track<=st.ttop then st.ttop=st.track-1 end if st.track>st.ttop+rows then st.ttop=st.track-rows end for i=st.ttop+1,math.min(#song.tracks,st.ttop+rows)do local tr=song.tracks[i] local d=devOf(i) local y=2+(i-st.ttop-1)*3 if i==st.track then ui.round(2,y,SIDE-2,2,t.dark and 0x454545 or 0xDEDDDA)end gfx.pcircle(4,y*2,1,COLORS[(i-1)%#COLORS+1]) gfx.text(6,y,ui.trunc(tr.name or d.name,SIDE-13),t.fg) ui.switch(w,SIDE-7,y,not tr.mute,function()tr.mute=not tr.mute end) local wire=d.kind=="note_block"and wiring[d.addr] local devName=d.kind=="beep"and"speaker"or d.addr:sub(1,4) local sub=d.kind=="iron"and(devName.." ♪ "..tr.inst)or d.kind=="beep"and"speaker (beep)" or wire and(devName.." ϟ "..SIDE_NAMES[wire.side]..(wire.color and(" c"..wire.color)or"").." sync") or(devName.." · adapter") gfx.text(6,y+1,ui.trunc(sub,SIDE-7),t.dim) local idx=i ui.hit(w,1,y,SIDE-8,2,function(_,px,py,btn) st.track=idx if btn~=1 then return end local items={} for _,dv in ipairs(devs)do local key=dv.addr or"beep" items[#items+1]={"▸ "..dv.name,function()tr.dev=key;st.dirty=true end,check=d==dv} end items[#items+1]="-" if d.kind=="iron"then for _,ins in ipairs(INSTRUMENTS)do items[#items+1]={"♪ "..ins,function()tr.inst=ins;st.dirty=true;playOn(d,12,ins)end,check=tr.inst==ins}end items[#items+1]="-" elseif d.kind=="note_block"then local cur=wiring[d.addr] items[#items+1]={"Play via adapter (one block per tick)",function()wiring[d.addr]=nil;saveWiring(wiring)end,check=cur==nil} for rs in component.list("redstone")do for side=0,5 do items[#items+1]={"Redstone "..rs:sub(1,4).." · "..SIDE_NAMES[side],function() wiring[d.addr]={rs=rs,side=side};saveWiring(wiring) end,check=cur and cur.rs==rs and cur.side==side and not cur.color} end items[#items+1]={"Redstone "..rs:sub(1,4).." · bundled colour…",function() Y.dialog{title="Bundled cable",text="side (0-5) and colour (0-15), e.g. 3 14",entry="3 0", buttons={{"Cancel"},{"Set",function(v) local sd,c=(v or""):match("(%d)%s+(%d+)") sd,c=tonumber(sd),tonumber(c) if sd and c and sd<=5 and c<=15 then wiring[d.addr]={rs=rs,side=sd,color=c};saveWiring(wiring)end end,"suggested"}}} end} end items[#items+1]={"Test (C4)",function()blockPitch[d.addr]=nil;local save=song.notes[0] song.notes[0]={{idx,6}};playStep(0);song.notes[0]=save end} items[#items+1]="-" end items[#items+1]={"Rename…",function() Y.dialog{title="Track name",entry=tr.name or d.name,buttons={{"Cancel"},{"OK",function(v) if v and v~=""then tr.name=v;st.dirty=true end end,"suggested"}}} end} items[#items+1]={"Delete track",function() if#song.tracks<=1 then return end table.remove(song.tracks,idx) for stp,list in pairs(song.notes)do for k=#list,1,-1 do if list[k][1]==idx then table.remove(list,k)elseif list[k][1]>idx then list[k][1]=list[k][1]-1 end end if#list==0 then song.notes[stp]=nil end end st.track=math.min(st.track,#song.tracks);st.dirty=true end,disabled=#song.tracks<=1} Y.menu(w.x+px,w.y+py,items) end) end if#song.tracks>rows then gfx.text(SIDE-6,ch-1,string.format("%d/%d",st.track,#song.tracks),t.dim) end gfx.text(2,ch-1,ui.trunc("Sound devices: "..#devs,SIDE-8),t.dim) ui.button(w,2,ch,"+ Track",function() song.tracks[#song.tracks+1]={inst="harp"} st.track=#song.tracks;st.dirty=true end,"flat",9) ui.button(w,12,ch,"Rescan",function()devs=devices()end,"flat",8) local sx=SIDE+2 local sw=cw-sx local row0=3 gfx.fill(sx-1,2,sw+1,17,t.view) local visible=(sw-8)//STEPW if st.step<=st.scroll then st.scroll=st.step-1 end if st.step>st.scroll+visible then st.scroll=st.step-visible end local x0=sx+7 layout.x0,layout.row0,layout.visible=x0,row0,visible local lineC=t.dark and 0x9A9996 or 0x5E5C64 local function rowOf(pos)return row0+(14-pos)end for pos=6,14,2 do gfx.prect(sx,rowOf(pos)*2-1,sw-1,1,lineC)end drawClef(sx+1,row0) local bar=song.spb*4 for i=1,visible do local s=st.scroll+i local cx=x0+(i-1)*STEPW if s==st.step then gfx.blend(cx,row0-1,STEPW,17,t.accent,0.18)end if st.playStep==s then gfx.blend(cx,row0-1,STEPW,17,0x26A269,0.35)end if(s-1)%bar==0 then gfx.prect(cx-1,rowOf(14)*2-1,1,17,lineC) gfx.text(cx,2,tostring((s-1)//bar+1),t.dim) elseif(s-1)%song.spb==0 then gfx.pset(cx-1,rowOf(4)*2+1,lineC) end if s>song.length then gfx.blend(cx,row0-1,STEPW,17,t.view,0.6)end end local order={} for i=1,#song.tracks do if i~=st.track then order[#order+1]=i end end order[#order+1]=st.track for i=1,visible do local s=st.scroll+i local cx=x0+(i-1)*STEPW for _,ti in ipairs(order)do for _,n in ipairs(song.notes[s]or{})do if n[1]==ti then local pos,sharp=POS[n[2]],SHARP[n[2]] local py=rowOf(pos)*2-1 local col=COLORS[(ti-1)%#COLORS+1] if song.tracks[ti]and song.tracks[ti].mute then col=t.dim end for lp=4,pos,-2 do if lp<6 then gfx.prect(cx,rowOf(lp)*2-1,STEPW,1,lineC)end end gfx.prect(cx+1,py-1,2,1,col);gfx.prect(cx,py,4,1,col);gfx.prect(cx+1,py+1,2,1,col) if pos<10 then gfx.prect(cx+3,py-6,1,6,col)else gfx.prect(cx,py+1,1,6,col)end if sharp then gfx.text(cx-1,rowOf(pos),"♯",col)end end end end local step=s ui.hit(w,cx,row0,STEPW,15,function(_,px,py,btn) local pos=14-(py-row0) st.step=step if pos>=0 and pos<=14 then local p=pitchAt(pos,btn==1) if p and st.writeMode then toggle(step,st.track,p) elseif p then playOn(devOf(st.track),p,song.tracks[st.track].inst)end end end) end local ky=21 local kx=sx+4 local KW=4 local whites={1,3,5,6,8,10,11,13,15,17,18,20,22,23} local hl=st.lastKey for i,p in ipairs(whites)do local x=kx+(i-1)*KW gfx.fill(x,ky,KW-1,6,hl==p and t.accent or 0xFFFFFF) gfx.text(x,ky+5,NAMES[p+1]:sub(1,1),0x5E5C64,hl==p and t.accent or 0xFFFFFF) ui.hit(w,x,ky,KW-1,6,function()st.lastKey=p;st.keyAt=computer.uptime();playOn(devOf(st.track),p,song.tracks[st.track].inst) if st.writeMode then toggle(st.step,st.track,p);st.step=st.step+1 end end) end local blacks={0,2,4,7,9,12,14,16,19,21,24} for _,p in ipairs(blacks)do local left for i,wp in ipairs(whites)do if wp==p-1 then left=i end end local x=left and(kx+left*KW-2)or(p==0 and kx-2 or kx+#whites*KW-2) gfx.prect(x,ky*2-1,3,7,hl==p and t.accent or 0x1E1E1E) ui.hit(w,x,ky,3,3,function()st.lastKey=p;st.keyAt=computer.uptime();playOn(devOf(st.track),p,song.tracks[st.track].inst) if st.writeMode then toggle(st.step,st.track,p);st.step=st.step+1 end end) end local ix=kx+#whites*KW+3 ui.button(w,ix,ky,st.writeMode and"● Write: on"or"○ Write: off",function()st.writeMode=not st.writeMode end,st.writeMode and"suggested"or nil,14) gfx.text(ix,ky+2,string.format("Step %d / %d · bar %d",st.step,song.length,(st.step-1)//(song.spb*4)+1),t.dim) local here={} for _,n in ipairs(song.notes[st.step]or{})do if n[1]==st.track then here[#here+1]=NAMES[n[2]+1]end end gfx.text(ix,ky+3,ui.trunc("Here: "..(#here>0 and table.concat(here," ")or"—"),cw-ix),t.dim) gfx.text(ix,ky+4,ui.trunc("Steps per beat: "..song.spb,cw-ix),t.dim) ui.button(w,ix+18,ky+4,"1/8",function()song.spb=2 end,song.spb==2 and"suggested"or"flat",5) ui.button(w,ix+24,ky+4,"1/16",function()song.spb=4 end,song.spb==4 and"suggested"or"flat",6) gfx.text(sx,ch,ui.trunc("Click staff: note · right click: ♯ · ←/→ step · Del clear · Space play · Ctrl+S save",sw),t.dim) if hl and st.keyAt and computer.uptime()-st.keyAt>0.3 then st.lastKey=nil end end function win.tick(w) if not st.playing then return end playStep(st.playStep) st.step=st.playStep st.playStep=st.playStep+1 if st.playStep>song.length then if st.loop then st.playStep=1 else setPlaying(false);st.step=1 end end Y.redraw(w) end local KEYMAP={} local lowRow,highRow="zsxdcvgbhnjm,","q2w3er5t6y7ui" for i=1,#lowRow do KEYMAP[lowRow:byte(i)]=i end for i=1,#highRow do KEYMAP[highRow:byte(i)]=12+i end function win.key(w,char,code) local K=Y.K if Y.mods.ctrl()then if code==K.s then save()end return end if char==32 then setPlaying(not st.playing) elseif code==K.right then st.step=st.step+1 elseif code==K.left then st.step=math.max(1,st.step-1) elseif code==K.home then st.step=1 elseif code==K.del or code==K.back then local list=song.notes[st.step] if list then for i=#list,1,-1 do if list[i][1]==st.track then table.remove(list,i)end end end if code==K.back then st.step=math.max(1,st.step-1)end st.dirty=true elseif code==K.up then st.track=math.max(1,st.track-1) elseif code==K.down then st.track=math.min(#song.tracks,st.track+1) elseif char and KEYMAP[char]then local p=KEYMAP[char] if p<=24 then st.lastKey,st.keyAt=p,computer.uptime() playOn(devOf(st.track),p,song.tracks[st.track].inst) if st.writeMode then toggle(st.step,st.track,p);st.step=st.step+1 end end end end function win.scroll(w,x,y,dir)st.scroll=math.max(0,st.scroll-dir*4);st.step=math.max(st.step,st.scroll+1)end function win.onSignal(name)if name=="component_added"or name=="component_removed"then devs=devices()end end function win.onclose(w) setPlaying(false) if not st.dirty then return true end Y.dialog{title="Save the song before closing?",buttons={{"Cancel"},{"Close without Saving",function()Y.closeWin(w,true)end,"destructive"}, {"Save",function()save();if not st.dirty then Y.closeWin(w,true)end end,"suggested"}}} return false end newSong() if path and fs.exists(path)then load(path)end return win end return M