This displays the sheet music like ViewSheetMusic, but also has an 88 key keyboard diagram on the bottom. Control messages for the sheet and the app are sent to UDP 4011 as OSC, and on/off messages are sent to UDP 4010. (To allow for machines on the network to show what keys I am pressing, I use UDP port 4010 for this.)
the app
#!/usr/bin/env python3
help = """
Sheet music viewer.
Have individual lines in files. Either number these 1.png 2.png... and run in that directory,
or specify the line files, in order, on the command line.
If the first argument is numeric, it is taken as the initial number of lines per screen.
Keys:
f -- fullscreen
space -- advance one line
pgdn -- advance one screen
pgup -- go back one screen
-/= -- dec/inc number of lines per screen
OSC (port 4011):
/f -- fullscreen
/next -- next
/pgdn -- advance one screen
/pgup -- go back one screen
/numlines x -- if x is an int, set that numer of lines, if "+", inc, if "-" dec
/goto n -- goto line n
"""
from PySide6.QtCore import *
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from PySide6.QtNetwork import *
from glob import glob
from pythonosc.osc_message import OscMessage
from keyboard_display import KeyboardDisplay
import os
import sys
app = QApplication([])
class SheetKb(QWidget):
def __init__(self,*xs,pixmaps=None,num_lines=4,**kw):
super().__init__(*xs,**kw)
self.kb = KeyboardDisplay(self,ontop=False)
self.sheet = Sheet(self,pixmaps=pixmaps,num_lines=num_lines)
self.full_screen = False
def resizeEvent(self,e):
new_size = self.size()
new_height = new_size.height()
new_width = new_size.width()
new_kbheight = new_height//10
new_sheetheight = new_height-new_kbheight
self.sheet.move(0,0)
self.sheet.resize(new_width,new_sheetheight)
self.kb.move(0,new_sheetheight)
self.kb.resize(new_width,new_kbheight)
def keyPressEvent(self,e):
print(e)
k = e.key()
if k == Qt.Key_F:
self.toggleFullScreen()
elif k == Qt.Key_Q:
app.quit()
elif k == Qt.Key_Space:
self.sheet.select(self.idx+1)
elif k == Qt.Key_PageUp:
self.sheet.select(self.idx-self.num_lines)
elif k == Qt.Key_PageDown:
self.sheet.select(self.idx+self.num_lines)
elif k == Qt.Key_Equal:
self.sheet.num_lines = min(6,self.num_lines+1)
self.sheet.update()
elif k == Qt.Key_Minus:
self.sheet.num_lines = max(2,self.num_lines-1)
self.sheet.update()
return super().keyPressEvent(e)
def toggleFullScreen(self):
self.full_screen = not self.full_screen
if self.full_screen:
self.setWindowState(self.windowState() | Qt.WindowFullScreen)
else:
self.setWindowState(self.windowState() & ~Qt.WindowFullScreen)
# proxies for sheet
def prevPage(self,*xs):
self.sheet.prevPage(*xs)
def nextPage(self,*xs):
self.sheet.nextPage(*xs)
def goto(self,*xs):
self.sheet.goto(*xs)
def forward(self,*xs):
self.sheet.forward(*xs)
def back(self,*xs):
self.sheet.back(*xs)
def changeNumLines(self,*xs):
self.sheet.changeNumLines(*xs)
class Sheet(QWidget):
def __init__(self,*xs,pixmaps=None,num_lines=4,**kw):
super().__init__(*xs,**kw)
self.idx = 0
self.pixmaps = pixmaps
if pixmaps is None:
raise ValueError("No pixmaps")
self.select(self.idx)
self.num_lines = num_lines
def select(self,idx):
self.idx = idx % len(self.pixmaps)
self.update()
def next(self):
self.select(self.idx+1)
def nextPage(self):
self.select(self.idx+self.num_lines)
def prevPage(self):
self.select(self.idx-self.num_lines)
def paintEvent(self,e):
with QPainter(self) as qp:
rect = self.rect()
width = rect.width()
height = rect.height()
line_height = int(height/self.num_lines)
font = QFont("Arial",24)
metrics = QFontMetrics(font)
qp.setFont(font)
qp.fillRect(rect,QBrush(Qt.white))
qp.setBrush(Qt.black)
qp.setPen(QPen(Qt.black))
for i in range(self.num_lines):
y = i*line_height
pidx = (self.idx + i) % len(self.pixmaps)
pixmap = self.pixmaps[pidx].scaledToHeight(line_height,Qt.SmoothTransformation)
pwidth = pixmap.width()
dw = width - pwidth
if dw < 80:
pixmap = pixmap.scaledToWidth(width-80)
pwidth = pixmap.width()
dw = width - pwidth
dw2 = dw//2
qp.drawPixmap(dw2,y,pixmap)
line_num = str(((self.idx+i)%len(self.pixmaps))+1)
brect = metrics.boundingRect(line_num)
bwidth = brect.width()
bheight = brect.height()
tx = dw2-5-bwidth
ty = y + (line_height - bheight)//2 + metrics.ascent()
qp.drawText(QPoint(tx,ty),line_num)
def changeNumLines(self,n):
if isinstance(n,str):
if n.isnumeric():
n = int(n)
elif n == "+":
n = self.num_lines+1
elif n == "-":
n = self.num_lines-1
elif isinstance(n,float):
n = int(n)
n = min(6,max(2,n))
self.num_lines = n
self.update()
def goto(self,n):
try:
n = int(n)
except ValueError:
print(f"Ignoring goto {n}")
return
self.select(n-1)
def forward(self,n):
try:
n = self.idx + int(n)
except ValueError:
print(f"Ignoring goto {n}")
return
self.select(n)
def back(self,n):
try:
n = self.idx - int(n)
except ValueError:
print(f"Ignoring goto {n}")
return
self.select(n)
class SheetReceiver(QObject):
def __init__(self,target,port=4011):
self.target = target
self.udpSocket = QUdpSocket()
self.udpSocket.readyRead.connect(self.handleUdp)
self.udpSocket.bind(QHostAddress.Any,port)
def handleUdp(self):
while self.udpSocket.hasPendingDatagrams():
datagram = self.udpSocket.receiveDatagram(4096)
data = datagram.data().data()
print(f"Recv {data=} {len(data)} bytes")
try:
message = OscMessage(data)
except Exception as e:
print(f"Failed to parse {e} : {data=}")
return
self.processMessage(message)
def processMessage(self,message):
addr = message.address
params = message.params
if addr.lower() == "next":
self.target.next()
elif addr.lower() in ["f","fs","fullscreen"]:
self.target.toggleFullScreen()
elif addr.lower() in ["pgup","pageup"]:
self.target.prevPage()
elif addr.lower() in ["pgdn","pagedown"]:
self.target.nextPage()
elif addr.lower() == "goto":
if len(params) == 1:
self.target.goto(params[0])
else:
print(f"Wrong params to numlines")
elif addr.lower() == "forward":
if len(params) == 1:
self.target.forward(params[0])
else:
print(f"Wrong params to numlines")
elif addr.lower() == "back":
if len(params) == 1:
self.target.back(params[0])
else:
print(f"Wrong params to numlines")
elif addr.lower() == "numlines":
if len(params) == 1:
self.target.changeNumLines(params[0])
else:
print(f"Wrong params to numlines")
else:
print(f"Unrecognised {addr=}")
class KeyboardReceiver(QObject):
def __init__(self,kb,port=4010):
self.kb = kb
self.udpSocket = QUdpSocket()
self.udpSocket.readyRead.connect(self.handleUdp)
self.udpSocket.bind(QHostAddress.Any,port)
def handleUdp(self):
while self.udpSocket.hasPendingDatagrams():
datagram = self.udpSocket.receiveDatagram(4096)
data = datagram.data().data()
print(f"Recv {data=} {len(data)} bytes")
try:
message = OscMessage(data)
except Exception as e:
print(f"Failed to parse {e} : {data=}")
return
self.processMessage(message)
def processMessage(self,message):
addr = message.address
params = message.params
if addr == "on":
p = int(params[0])
self.kb.on(p)
elif addr == "off":
p = int(params[0])
self.kb.off(p)
else:
print(f"Unrecognised {addr=}")
args = sys.argv[1:]
num_lines = 4
if len(args) > 0:
arg = args[0]
if arg.isnumeric():
num_lines = int(arg)
num_lines = max(min(6,num_lines),2)
args = args[1:]
if len(args) > 0:
fns = args
else:
i = 1
fns = []
while os.path.exists(fn:=f"{i}.png"):
fns.append(fn)
i += 1
pixmaps = [ QPixmap(fn) for fn in fns ]
window = SheetKb(pixmaps=pixmaps,num_lines=num_lines)
window.resize(900,800)
window.show()
sheet_receiver = SheetReceiver(window,4011)
keyboard_receiver = KeyboardReceiver(window.kb,4010)
exit(app.exec())
keyboard view
from PySide6.QtCore import *
from PySide6.QtGui import *
from PySide6.QtWidgets import *
white_notes = [ 0, 2, 4, 5, 7, 9, 11 ]
black_notes = [ i for i in range(12) if not i in white_notes ]
class KeyboardDisplay(QWidget):
def __init__(self,*xs,lo=21,hi=108,ontop=True,**kw):
super().__init__(*xs,**kw)
self.setRange(lo,hi)
self.noteson = set()
if ontop == True:
self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint )
def on(self,p):
print(f"on {p=}")
self.noteson.add(p)
print(self.noteson)
self.update()
def off(self,p):
print(f"off {p=}")
if p in self.noteson:
self.noteson.remove(p)
print(self.noteson)
self.update()
def setRange(self,lo=21,hi=108):
# expand range if needed so that top and bottom notes are white
if lo % 12 in black_notes:
lo -= 1
if hi % 12 in black_notes:
hi += 1
if hi - lo < 4:
raise ValueError("Keyboard range too small")
self.lo = lo
self.hi = hi
nw = 0
nb = 0
for i in range(lo,hi+1):
if i % 12 in white_notes:
nw += 1
else:
nb += 1
self.nw = nw
self.nb = nb
self.calcRects()
def calcRects(self):
self.wrects = []
self.brects = []
w = 1/self.nw
r = 0
for i,p in enumerate(range(self.lo,self.hi+1)):
if p % 12 in white_notes:
self.wrects.append((p,r,r+w))
r += w
else:
self.brects.append((p,r-w/3,r+w/3))
def mousePressEvent(self,e):
modifiers = e.modifiers()
self.resizing = False
if modifiers == Qt.ControlModifier:
self.close()
elif modifiers == Qt.ShiftModifier:
self.resizing = True
self.mpos = e.position()
self.msize = self.size()
else:
self.mpos = e.position()
def mouseMoveEvent(self,e):
buttons = e.buttons()
if buttons == Qt.LeftButton:
dpos = e.position() - self.mpos
dpos = dpos.toPoint()
if self.resizing:
newsize = QSize(self.msize)
newsize.setWidth(newsize.width() + dpos.x())
newsize.setHeight(newsize.height() + dpos.y())
print(newsize,dpos)
if newsize.height() < 100:
return
if newsize.width() < 100:
return
print(1234)
self.resize(newsize)
else:
newpos = self.pos() + dpos
self.move(newpos)
def paintEvent(self,e):
num_wnotes = self.nw
rect = self.rect()
w = rect.width()
h = rect.height()
bh = int(h * 3/5)
# we need to identify exactly what rectangle is what.
# since we need to light them up when played.
with QPainter(self) as qp:
qp.fillRect(rect,Qt.white)
qp.setPen(QPen(Qt.black,2))
for nr in self.wrects:
p,l,r = nr
if p in self.noteson:
qp.setBrush(Qt.red)
else:
if p == 60:
qp.setBrush(QColor(255,255,120))
else:
qp.setBrush(Qt.white)
l *= w
r *= w
l = int(l)
r = int(r)
dx = r-l
qp.drawRect(l,0,dx,h)
for nr in self.brects:
p,l,r = nr
if p in self.noteson:
qp.setBrush(Qt.green)
else:
qp.setBrush(Qt.black)
l *= w
r *= w
l = int(l)
r = int(r)
dx = r-l
qp.drawRect(l,0,dx,bh)