This is a simple app that takes a bunch of pngs, each being a single line of sheet music,
and displays some of them. It responds to OSC messages to advance one line, or one screen,
and to toggle fullscreen. And there are keyboard shortcuts. It illustrates the use of
QPixmap and QPainters, along with simple OSC control.
#!/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
/forward n -- go forward n
/back n -- go back 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
import os
import sys
app = QApplication([])
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
self.full_screen = False
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)
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.select(self.idx+1)
elif k == Qt.Key_PageUp:
self.select(self.idx-self.num_lines)
elif k == Qt.Key_PageDown:
self.select(self.idx+self.num_lines)
elif k == Qt.Key_Equal:
self.num_lines = min(6,self.num_lines+1)
self.update()
elif k == Qt.Key_Minus:
self.num_lines = max(2,self.num_lines-1)
self.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)
class Receiver(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")
elif addr.lower() in ["pgdn","pagedown"]:
self.target.nextPage()
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 ]
sheet = Sheet(pixmaps=pixmaps,num_lines=num_lines)
sheet.resize(900,800)
sheet.show()
receiver = Receiver(sheet,4011)
exit(app.exec())