hedgehog/software/ HedgehogPyScribble001


The Qt Scribble demo app rewritten in Python, which receives OSC messages that change the pen size, both absolute via /penwidth and relative via /penwidthby, both of which take integer parameters. Naturally other parameters such as colour could be controlled in the same way, and other events, as illustrated by /clear. That is the general idea, and all the clever workflow design can be factored out of the app – the core of the app needs only to do as it is told. Part of the idea of Hedgehog is that the core of an app is controlled by receiving messages, both from the App's own UI, and also from external sources, so that a user can control an app with anything he/she wishes. Thus Our Hedgehogs can talk to our applications.

The point to make is how easy it can be to Hedgehog-enable an application. (Here OSC is used as the messaging protocol, and it probably will suffice, Hedgehog is the concept of controlling our apps with possibly-custom-designed peripherals with a varied array of knobs and buttons.) In this instance we are using PySide6, and Qt nicely provides us with a UdpSocket class that neatly integrates with Qt's signals and slots and its runloop. Basically an app needs to start a thread which listens on the socket and, when they are received, store them somewhere and notify the main runloop that there is a message to be processed. Once that is done, you can control your apps happily using whatever control surface you wish to fashion, be it made of Midi controllers, or something cooked up with an Arduino, or some commercial knobs-and-buttons peripherals like the TourBox.

scribble.py

from PySide6.QtCore import *
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from PySide6.QtNetwork import *
import sys
from pythonosc.osc_message import OscMessage

app = QApplication(sys.argv)

class HHReceiver(QObject):
    # this only works with datagrams containing single osc messages
    def handleUdp(self):
        while self.udpSocket.hasPendingDatagrams():
            datagram = self.udpSocket.receiveDatagram(4096)
            data = datagram.data().data()
            print(f"Recv ({len(data)}): {data}")
            try:
                message = OscMessage(data)
            except Exception as e:
                print(f"#Fail to parse datagram ({e}) : {data}")
            self.processMessage(message)
    def processMessage(self,message):
        addr = message.address
        args = message.params
        if addr == "/penwidth":
            try:
                newWidth = int(args[0])
                self.scribble.setPenWidth(newWidth)
            except IndexError:
                print(f"/penwidth message must have single integer param")
        elif addr == "/penwidthby":
            try:
                dx = int(args[0])
                self.scribble.adjustPenWidthBy(dx)
            except IndexError:
                print(f"/penwidthby message must have single integer param")
        elif addr == "/clear":
            self.scribble.clearImage()

    def __init__(self,scribble,port=2800):
        self.scribble = scribble
        self.udpSocket = QUdpSocket()
        self.udpSocket.readyRead.connect(self.handleUdp)
        self.udpSocket.bind(QHostAddress.Any,port)

class ScribbleArea(QWidget):
    def __init__(self,*xs,**kw):
        super().__init__(*xs,**kw)
        self.setAttribute(Qt.WA_StaticContents)
        self._modified = False
        self._scribbling = False
        self._penWidth = 1
        self._penColor = Qt.blue
        self.image = QImage()
        self.lastPoint = QPoint()
    def adjustPenWidthBy(self,dx):
        self.setPenWidth(self._penWidth+dx)
    def setPenColor(self, newColor : QColor):
        self._penColor = newColor
    def setPenWidth(self, newWidth : int):
        self._penWidth = min(max(1,newWidth),100)
    def isModified(self) -> bool:
        return self._modified
    def penColor(self) -> QColor:
        return self._penColor
    def penwidth(self) -> int:
        return self._penWidth 
    def clearImage(self):
        self.image.fill(qRgb(255,255,255))
        self.modified = True
        self.update()
    def mousePressEvent(self,event):
        if event.button() == Qt.LeftButton:
            self.lastPoint = event.position().toPoint()
            self.scribbling = True
    def mouseMoveEvent(self,event):
        if (event.buttons() & Qt.LeftButton) and self.scribbling:
            self.drawLineTo(event.position().toPoint())
    def mouseReleaseEvent(self,event):
        if event.button() == Qt.LeftButton and self.scribbling:
            self.drawLineTo(event.position().toPoint())
            self.scribbling = False
    def paintEvent(self,event):
        with QPainter(self) as p:
            dirtyRect = event.rect()
            p.drawImage(dirtyRect,self.image,dirtyRect)
    def resizeEvent(self,event):
        w = self.width()
        h = self.height()
        iw = self.image.width()
        ih = self.image.height()
        if w > iw or h > ih:
            nw = max(w+128,iw)
            nh = max(h+128,ih)
            self.resizeImage(self.image,QSize(nw,nh))
        super().resizeEvent(event)
    def drawLineTo(self,endPoint : QPoint):
        with QPainter(self.image) as p:
            p.setPen(QPen(self._penColor,self._penWidth,Qt.SolidLine,Qt.RoundCap,Qt.RoundJoin))
            p.drawLine(self.lastPoint,endPoint)
            self.modified = True
            rad = int((self._penWidth // 3)*2)
            self.update(QRect(self.lastPoint, endPoint).normalized().adjusted(-rad,-rad,+rad,+rad))
            self.lastPoint = endPoint
    def resizeImage(self,image : QImage, newSize: QSize):
        if image.size() == newSize:
            return

        newImage = QImage(newSize, QImage.Format_RGB32)
        newImage.fill(qRgb(255,255,255))
        with QPainter(newImage) as p:
            p.drawImage(QPoint(0,0),self.image)
            self.image = newImage

scr = ScribbleArea()
scr.resize(600,500)
scr.show()

h = HHReceiver(scr)

exit(app.exec())

a trivial sender for interactive use

description = '''
load in interactive mode (i.e. python -i trivial_send.py)
then use s("/osc/message",[1,2,3,4]) to send
'''
import socket
from pythonosc import udp_client
client = udp_client.SimpleUDPClient("localhost",2800,family=socket.AF_INET)
def s(path,*args):
    print(f"Sending {path=} {args=}")
    client.send_message(path,args)

usage

python -i sender.py

example transcript of interactive session

>>> s("/penwidth",15)
Sending path='/penwidth' args=(15,)
>>> s("/penwidthby",15)
Sending path='/penwidthby' args=(15,)
>>> s("/clear")
Sending path='/clear' args=()