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 ```py #!/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 ```py 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) ```