CreativePython 1.1.5__py3-none-any.whl → 1.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -624,7 +624,30 @@ class _QPolygonItem(_QtGraphicsItemEventMixin, QtWidgets.QGraphicsPolygonItem):
624
624
  pass
625
625
 
626
626
  class _QPixmapItem(_QtGraphicsItemEventMixin, QtWidgets.QGraphicsPixmapItem):
627
- pass
627
+ _colorFilter = None # (r, g, b, a) set by IconMirror._applyColor; None = no filter
628
+
629
+ def paint(self, painter, option, widget=None):
630
+ if self._colorFilter is None:
631
+ super().paint(painter, option, widget)
632
+ return
633
+
634
+ pixmap = self.pixmap()
635
+ if pixmap.isNull():
636
+ return
637
+
638
+ r, g, b, a = self._colorFilter
639
+ image = pixmap.toImage().convertToFormat(QtGui.QImage.Format.Format_ARGB32)
640
+
641
+ filtered = QtGui.QImage(image.size(), QtGui.QImage.Format.Format_ARGB32)
642
+ filtered.fill(QtCore.Qt.GlobalColor.transparent)
643
+
644
+ p = QtGui.QPainter(filtered)
645
+ p.drawImage(0, 0, image)
646
+ p.setCompositionMode(QtGui.QPainter.CompositionMode.CompositionMode_SourceOver)
647
+ p.fillRect(image.rect(), QtGui.QColor(r, g, b, a))
648
+ p.end()
649
+
650
+ painter.drawImage(self.offset(), filtered)
628
651
 
629
652
  class _QGroupItem(_QtGraphicsItemEventMixin, QtWidgets.QGraphicsItemGroup):
630
653
  pass
@@ -1688,15 +1711,12 @@ class _DrawableMirror:
1688
1711
  self._rotation = 0
1689
1712
  self._toolTipText = None
1690
1713
 
1691
- # GuiRenderer only handles commands that alter an object visually,
1692
- # or that rely on hit testing. Everything else is handled in gui.py
1714
+ # GuiRenderer only handles commands that alter an object visually.
1715
+ # Hit testing (contains/intersects/encloses) is handled entirely in gui.py.
1693
1716
  self._commandHandlers = {
1694
1717
  'setPosition': self._setPosition,
1695
1718
  'setSize': self._setSize,
1696
1719
  'setRotation': self._setRotation,
1697
- 'contains': self._contains,
1698
- 'intersects': self._intersects,
1699
- 'encloses': self._encloses,
1700
1720
  'setToolTipText': self._setToolTipText,
1701
1721
  'show': self._show,
1702
1722
  'hide': self._hide,
@@ -1759,43 +1779,6 @@ class _DrawableMirror:
1759
1779
  self.qObject.prepareGeometryChange()
1760
1780
  self.qObject.setRotation(qtDegree)
1761
1781
 
1762
- # ── Hit testing ────────────────────────────────────────────────────────────
1763
-
1764
- def _contains(self, args, responseId):
1765
- """Returns True if the point (x, y) is inside this item."""
1766
- x = args.get('x', 0)
1767
- y = args.get('y', 0)
1768
- globalPoint = QtCore.QPointF(x, y)
1769
- localPoint = self.qObject.mapFromScene(globalPoint)
1770
- result = self.qObject.contains(localPoint)
1771
- self.guiRenderer.sendResponse(responseId, [result])
1772
-
1773
- def _intersects(self, args, responseId):
1774
- """Returns True if this item intersects another item."""
1775
- otherObjectId = args.get('otherObjectId')
1776
- otherMirror = self.guiRenderer._objectRegistry.get(otherObjectId)
1777
- result = False
1778
-
1779
- if otherMirror is not None:
1780
- pathA = self.qObject.mapToScene(self.qObject.shape())
1781
- pathB = otherMirror.qObject.mapToScene(otherMirror.qObject.shape())
1782
- result = pathA.intersects(pathB)
1783
-
1784
- self.guiRenderer.sendResponse(responseId, [result])
1785
-
1786
- def _encloses(self, args, responseId):
1787
- """Returns True if this item fully encloses another item."""
1788
- otherObjectId = args.get('otherObjectId')
1789
- otherMirror = self.guiRenderer._objectRegistry.get(otherObjectId)
1790
- result = False
1791
-
1792
- if otherMirror is not None:
1793
- pathA = self.qObject.mapToScene(self.qObject.shape())
1794
- pathB = otherMirror.qObject.mapToScene(otherMirror.qObject.shape())
1795
- result = pathA.contains(pathB)
1796
-
1797
- self.guiRenderer.sendResponse(responseId, [result])
1798
-
1799
1782
  # ── Visibility ─────────────────────────────────────────────────────────────
1800
1783
 
1801
1784
  def _show(self, args, responseId):
@@ -2405,6 +2388,7 @@ class PolygonMirror(_GraphicsMirror):
2405
2388
  # IconMirror — mirror of gui.py's Icon
2406
2389
  #######################################################################################
2407
2390
 
2391
+
2408
2392
  class IconMirror(_GraphicsMirror):
2409
2393
  """
2410
2394
  Mirror of gui.py's Icon. Backed by a QGraphicsPixmapItem.
@@ -2474,16 +2458,25 @@ class IconMirror(_GraphicsMirror):
2474
2458
  'getPixels': self._getPixels,
2475
2459
  'setPixels': self._setPixels,
2476
2460
  'write': self._write,
2461
+ 'setAlpha': self._setAlpha,
2477
2462
  })
2478
2463
 
2479
2464
  def _applyColor(self):
2480
- """No-op QGraphicsPixmapItem has no pen or brush."""
2481
- pass
2465
+ r, g, b, a = self._color
2466
+ if [r, g, b, a] == [0, 0, 0, 0]:
2467
+ self.qObject._colorFilter = None
2468
+ else:
2469
+ self.qObject._colorFilter = (r, g, b, a)
2470
+ self.qObject.update()
2482
2471
 
2483
2472
  def _applyThickness(self):
2484
2473
  """No-op — QGraphicsPixmapItem has no pen or brush."""
2485
2474
  pass
2486
2475
 
2476
+ def _setAlpha(self, args, responseId):
2477
+ alpha = args.get('alpha', 255)
2478
+ self.qObject.setOpacity(alpha / 255.0)
2479
+
2487
2480
  # ── Size ───────────────────────────────────────────────────────────────────
2488
2481
 
2489
2482
  def _getSize(self, args, responseId):
@@ -2922,8 +2915,7 @@ class _ControlMirror(_DrawableMirror):
2922
2915
  - dimensions: setRect(), setPath(), etc. -> setFixedSize()
2923
2916
  - color: [r, g, b] -> stylesheets
2924
2917
 
2925
- Controls have no hit-testing (contains/intersects/encloses) because QWidgets
2926
- handle their own input directly.
2918
+ Controls handle their own input directly via Qt's widget event system.
2927
2919
 
2928
2920
  Concrete classes must:
2929
2921
  1. Call super().__init__(objectId, guiRenderer, args) first.
@@ -2964,48 +2956,6 @@ class _ControlMirror(_DrawableMirror):
2964
2956
  def _setRotation(self, args, responseId):
2965
2957
  pass # Controls cannot be rotated; gui.py warns at the call site
2966
2958
 
2967
- # ── Hit testing ────────────────────────────────────────────────────────────
2968
-
2969
- def _contains(self, args, responseId):
2970
- """Returns True if the point (x, y) is inside this widget's bounding box."""
2971
- x = args.get('x', 0)
2972
- y = args.get('y', 0)
2973
- rect = QtCore.QRectF(self.qObject.geometry())
2974
- result = rect.contains(QtCore.QPointF(x, y))
2975
- self.guiRenderer.sendResponse(responseId, [result])
2976
-
2977
- def _intersects(self, args, responseId):
2978
- """Returns True if this widget's bounding box intersects another item."""
2979
- otherObjectId = args.get('otherObjectId')
2980
- otherMirror = self.guiRenderer._objectRegistry.get(otherObjectId)
2981
- result = False
2982
-
2983
- if otherMirror is not None:
2984
- rectA = QtCore.QRectF(self.qObject.geometry())
2985
- if isinstance(otherMirror, _ControlMirror):
2986
- rectB = QtCore.QRectF(otherMirror.qObject.geometry())
2987
- else:
2988
- rectB = otherMirror.qObject.sceneBoundingRect()
2989
- result = rectA.intersects(rectB)
2990
-
2991
- self.guiRenderer.sendResponse(responseId, [result])
2992
-
2993
- def _encloses(self, args, responseId):
2994
- """Returns True if this widget's bounding box fully encloses another item."""
2995
- otherObjectId = args.get('otherObjectId')
2996
- otherMirror = self.guiRenderer._objectRegistry.get(otherObjectId)
2997
- result = False
2998
-
2999
- if otherMirror is not None:
3000
- rectA = QtCore.QRectF(self.qObject.geometry())
3001
- if isinstance(otherMirror, _ControlMirror):
3002
- rectB = QtCore.QRectF(otherMirror.qObject.geometry())
3003
- else:
3004
- rectB = otherMirror.qObject.sceneBoundingRect()
3005
- result = rectA.contains(rectB)
3006
-
3007
- self.guiRenderer.sendResponse(responseId, [result])
3008
-
3009
2959
  # ── Size ───────────────────────────────────────────────────────────────────
3010
2960
 
3011
2961
  def _getSize(self, args, responseId):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: CreativePython
3
- Version: 1.1.5
3
+ Version: 1.1.6
4
4
  Summary: A Python-based software environment for developing algorithmic art projects.
5
5
  Author-email: "Dr. Bill Manaris" <manaris@cofc.edu>, Taj Ballinger <ballingertj@g.cofc.edu>, Trevor Ritchie <ritchiets@g.cofc.edu>
6
6
  License: MIT License
@@ -1,14 +1,14 @@
1
- gui.py,sha256=U_mAXHFJeSyDPF0lz3vs2AExwsOEdK_slAImKKRm9eE,136824
1
+ gui.py,sha256=io7p7aYK9EqcP9lBbBO3CBycaAvaHs3IxNw5BrOhhgs,154434
2
2
  iannix.py,sha256=jlBJhPzBcIl2iayG01ldoslAytxYOvP3z_vN-EgiJA4,17188
3
3
  image.py,sha256=wohpTTZH6QzN3wRfXI5ac7VTRsOKcwqk8_d3yha8oDw,3763
4
4
  markov.py,sha256=VhTXZfDDHETj6KhriFmOLHKNjgx08cWZvpLECuGRTG0,24653
5
- midi.py,sha256=g5qezyjD--oj92uUR7g91V4-IiYWhjrHQuIwV8XfQR8,27282
6
- music.py,sha256=H-4c03IZBmjaLeio6ZQTl0BZGGU-Ri6GdiOj2L-mmJI,268534
7
- osc.py,sha256=yUIIvPZmEmaxnpP2Q9FBh-sCL9aXR-qFDQk4N7998vk,8553
5
+ midi.py,sha256=avVY6dyTHbFazt10w2Uy0zUPXvRYleZM2O89gDX3-h4,27203
6
+ music.py,sha256=VyYcjxEIRYG7YWFzBIbSFaR4mVcO2fHDZnkdCh0G4sM,268532
7
+ osc.py,sha256=GtjWV5LD4xiW9Yen3PzFYstWHhEZ_QyrKunAu-VsPrc,8353
8
8
  timer.py,sha256=uQ1BHWTjZ6DpJo-leGhxgOJcVcd1Lj0jvQQodXx1kw4,16341
9
9
  zipf.py,sha256=J6fWwUYz88LFAwanc0TmXJlU7VtpBM24_Rw5XJTOn9c,40312
10
10
  CreativePython/GuiHandler.py,sha256=ONnxhqivBrzCHQv7GcE4vkMgnONbuXXq7Fwe0Au5QPs,30974
11
- CreativePython/GuiRenderer.py,sha256=jqoKD2XSNeDJ4lAmQXfaEbtVrsL-4LublHhbUHbJRkk,139445
11
+ CreativePython/GuiRenderer.py,sha256=XUXhPjtLRUA3D0Q05ZP85gb3gPqg8Zvs732bSYBOVo4,136843
12
12
  CreativePython/RealtimeAudioPlayer.py,sha256=tjqBjSOvSvH1kP0t6q35xsbKHPr0wuLUvGdxdWAY73E,44590
13
13
  CreativePython/__init__.py,sha256=bT1nZ-Fp8-7uiKqhVdnFeOwL-8Jps2rvE6BfoOE8fZ0,451
14
14
  CreativePython/notationRenderer.py,sha256=2KIMvInNLEUZyn_r1KMmyt7X2XDrfekVkggFeHLRu1M,35440
@@ -77,9 +77,9 @@ CreativePython/nevmuse/utilities/CSVWriter.py,sha256=k2GccvzD5IwdkmFdN5qtRA2qZX1
77
77
  CreativePython/nevmuse/utilities/PowerLawRandom.py,sha256=WIEI-p8wiiWhkkBR5pO9gJ2TwxZBw453T_xXzCNdKPY,3752
78
78
  CreativePython/nevmuse/utilities/__init__.py,sha256=pAGIMMGfLvR2DQfxr8qkfKQJ-4fIkVqlhnGK657JPL8,166
79
79
  bin/libportaudio.2.dylib,sha256=ooJlm7QeL7jYzc1c8YMmH-C8PhYHwDvBJZkNDVPFRbE,286592
80
- creativepython-1.1.5.dist-info/licenses/LICENSE,sha256=tz38qDa5RhHMqzZR1Q5QafqWdXn_EhheXYBP7FjgtEM,1992
81
- creativepython-1.1.5.dist-info/licenses/LICENSE-PSF,sha256=JN1jUPIsEUH1mq3AXxet-b1IdhAr-XzscXBZiz74c9c,3371
82
- creativepython-1.1.5.dist-info/METADATA,sha256=jesD86ZrXoIlpqmWVilNd9KtPsNsQEeP9UmEfMZEuzE,8703
83
- creativepython-1.1.5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
84
- creativepython-1.1.5.dist-info/top_level.txt,sha256=PzvvUyy-3YzTedVGnLS02ju1TCMuo76h1LuVd8XvHhA,69
85
- creativepython-1.1.5.dist-info/RECORD,,
80
+ creativepython-1.1.6.dist-info/licenses/LICENSE,sha256=tz38qDa5RhHMqzZR1Q5QafqWdXn_EhheXYBP7FjgtEM,1992
81
+ creativepython-1.1.6.dist-info/licenses/LICENSE-PSF,sha256=JN1jUPIsEUH1mq3AXxet-b1IdhAr-XzscXBZiz74c9c,3371
82
+ creativepython-1.1.6.dist-info/METADATA,sha256=MGKp9oy0r3G1TsnJxa2M9WtRlP_4X7bGv-05HyB2z0s,8703
83
+ creativepython-1.1.6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
84
+ creativepython-1.1.6.dist-info/top_level.txt,sha256=PzvvUyy-3YzTedVGnLS02ju1TCMuo76h1LuVd8XvHhA,69
85
+ creativepython-1.1.6.dist-info/RECORD,,
gui.py CHANGED
@@ -529,9 +529,14 @@ class _Interactable:
529
529
  def _registerCallback(self, eventType, action):
530
530
  """
531
531
  Registers a single event callback via IPC to the renderer process.
532
+ Skips the IPC sendCommand if the event type is already registered,
533
+ since the renderer's _registeredEvents is a set (idempotent) and only
534
+ the callback stored locally needs updating.
532
535
  """
536
+ alreadyRegistered = eventType in self._actionList
533
537
  self._actionList[eventType] = action
534
- _handler().registerEvent(self._objectId, eventType, action)
538
+ if not alreadyRegistered:
539
+ _handler().registerEvent(self._objectId, eventType, action)
535
540
 
536
541
  def onMouseClick(self, action):
537
542
  """
@@ -1123,6 +1128,155 @@ class Display(_Interactable):
1123
1128
  """
1124
1129
  self.drawLabel(text, x, y, color, font)
1125
1130
 
1131
+ #######################################################################################
1132
+ # Geometry helpers — used by contains(), intersects(), encloses()
1133
+ #######################################################################################
1134
+
1135
+ def _pointInPolygon(x, y, points):
1136
+ """
1137
+ Returns True if (x, y) lies inside the closed polygon defined by points.
1138
+ Uses the ray-casting (even-odd) algorithm. Points are (x, y) tuples.
1139
+ """
1140
+ inside = False
1141
+ n = len(points)
1142
+ j = n - 1
1143
+ for i in range(n):
1144
+ xi, yi = points[i]
1145
+ xj, yj = points[j]
1146
+ if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi):
1147
+ inside = not inside
1148
+ j = i
1149
+ return inside
1150
+
1151
+
1152
+ def _pointToSegmentDistSq(px, py, x1, y1, x2, y2):
1153
+ """
1154
+ Returns the squared distance from point (px, py) to segment (x1,y1)-(x2,y2).
1155
+ """
1156
+ dx, dy = x2 - x1, y2 - y1
1157
+ lenSq = dx * dx + dy * dy
1158
+ if lenSq == 0:
1159
+ return (px - x1) ** 2 + (py - y1) ** 2
1160
+ t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / lenSq))
1161
+ nearX = x1 + t * dx
1162
+ nearY = y1 + t * dy
1163
+ return (px - nearX) ** 2 + (py - nearY) ** 2
1164
+
1165
+
1166
+ def _pointNearPath(x, y, points, halfThickness):
1167
+ """
1168
+ Returns True if (x, y) is within halfThickness of any segment in points.
1169
+ Used for open paths (Polyline, Arc OPEN) where the interior has no fill.
1170
+ """
1171
+ threshSq = halfThickness * halfThickness
1172
+ result = False
1173
+ i = 0
1174
+ while not result and i < len(points) - 1:
1175
+ x1, y1 = points[i]
1176
+ x2, y2 = points[i + 1]
1177
+ result = _pointToSegmentDistSq(x, y, x1, y1, x2, y2) <= threshSq
1178
+ i += 1
1179
+ return result
1180
+
1181
+
1182
+ def _segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4):
1183
+ """
1184
+ Returns True if segment (x1,y1)-(x2,y2) intersects segment (x3,y3)-(x4,y4).
1185
+ Uses parametric intersection; parallel or collinear segments return False.
1186
+ """
1187
+ d1x, d1y = x2 - x1, y2 - y1
1188
+ d2x, d2y = x4 - x3, y4 - y3
1189
+ cross = d1x * d2y - d1y * d2x
1190
+ if cross == 0:
1191
+ result = False # parallel or collinear
1192
+ else:
1193
+ t = ((x3 - x1) * d2y - (y3 - y1) * d2x) / cross
1194
+ u = ((x3 - x1) * d1y - (y3 - y1) * d1x) / cross
1195
+ result = 0.0 <= t <= 1.0 and 0.0 <= u <= 1.0
1196
+ return result
1197
+
1198
+
1199
+ def _polygonEdgesIntersect(polyA, polyB):
1200
+ """
1201
+ Returns True if any edge of polyA crosses any edge of polyB.
1202
+ Each polygon is a list of (x, y) tuples; edges wrap from last to first.
1203
+ """
1204
+ nA = len(polyA)
1205
+ nB = len(polyB)
1206
+ result = False
1207
+ i = 0
1208
+ while not result and i < nA:
1209
+ x1, y1 = polyA[i]
1210
+ x2, y2 = polyA[(i + 1) % nA]
1211
+ j = 0
1212
+ while not result and j < nB:
1213
+ x3, y3 = polyB[j]
1214
+ x4, y4 = polyB[(j + 1) % nB]
1215
+ result = _segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4)
1216
+ j += 1
1217
+ i += 1
1218
+ return result
1219
+
1220
+
1221
+ def _polygonsIntersect(polyA, polyB):
1222
+ """
1223
+ Returns True if two closed polygons overlap.
1224
+ Covers three cases: edges crossing, A fully inside B, and B fully inside A.
1225
+ """
1226
+ result = (_polygonEdgesIntersect(polyA, polyB) or
1227
+ _pointInPolygon(*polyA[0], polyB) or
1228
+ _pointInPolygon(*polyB[0], polyA))
1229
+ return result
1230
+
1231
+
1232
+ def _openPathIntersectsPolygon(pathPoints, polyPoints):
1233
+ """
1234
+ Returns True if an open path intersects a closed polygon.
1235
+ Checks whether any segment of the path crosses a polygon edge, or whether
1236
+ any path point lies inside the polygon (path running through the interior).
1237
+ """
1238
+ nPath = len(pathPoints)
1239
+ nPoly = len(polyPoints)
1240
+ result = False
1241
+ i = 0
1242
+ while not result and i < nPath - 1:
1243
+ x1, y1 = pathPoints[i]
1244
+ x2, y2 = pathPoints[i + 1]
1245
+ j = 0
1246
+ while not result and j < nPoly:
1247
+ x3, y3 = polyPoints[j]
1248
+ x4, y4 = polyPoints[(j + 1) % nPoly]
1249
+ result = _segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4)
1250
+ j += 1
1251
+ i += 1
1252
+ i = 0
1253
+ while not result and i < nPath:
1254
+ result = _pointInPolygon(*pathPoints[i], polyPoints)
1255
+ i += 1
1256
+ return result
1257
+
1258
+
1259
+ def _pathsIntersect(pathA, pathB):
1260
+ """
1261
+ Returns True if any segment of open path A crosses any segment of open path B.
1262
+ """
1263
+ nA = len(pathA)
1264
+ nB = len(pathB)
1265
+ result = False
1266
+ i = 0
1267
+ while not result and i < nA - 1:
1268
+ x1, y1 = pathA[i]
1269
+ x2, y2 = pathA[i + 1]
1270
+ j = 0
1271
+ while not result and j < nB - 1:
1272
+ x3, y3 = pathB[j]
1273
+ x4, y4 = pathB[j + 1]
1274
+ result = _segmentsIntersect(x1, y1, x2, y2, x3, y3, x4, y4)
1275
+ j += 1
1276
+ i += 1
1277
+ return result
1278
+
1279
+
1126
1280
  #######################################################################################
1127
1281
  # Drawable - items that can be added to a Display
1128
1282
  #######################################################################################
@@ -1150,6 +1304,7 @@ class _Drawable(_Interactable):
1150
1304
  self._anchorX = None # last used global rotation anchor (None = center)
1151
1305
  self._anchorY = None # ...
1152
1306
  self._toolTipText = None # text to Display on mouse over (None == disabled)
1307
+ self._cachedBoundary = None # lazily computed by _getBoundary(), cleared by _invalidateBoundary()
1153
1308
 
1154
1309
  # renderer visibility toggle
1155
1310
  # only used by Toggle and Push, but doesn't hurt to have as part of Drawable
@@ -1170,6 +1325,7 @@ class _Drawable(_Interactable):
1170
1325
  """
1171
1326
  self._localCornerX = localX
1172
1327
  self._localCornerY = localY
1328
+ self._invalidateBoundary()
1173
1329
 
1174
1330
  _handler().sendCommand('setPosition', self._objectId, {'x': localX, 'y': localY})
1175
1331
  if isinstance(self._parent, Group):
@@ -1257,8 +1413,9 @@ class _Drawable(_Interactable):
1257
1413
  y2 = (height / 2)
1258
1414
  corners = [(x1, y1), (x2, y1), (x2, y2), (x1, y2)]
1259
1415
 
1260
- rotatedXs = [centerX + (px * cos) - (py * sin) for px, py in corners]
1261
- rotatedYs = [centerY + (px * sin) + (py * cos) for px, py in corners]
1416
+ # visual CCW: x' = dx*cos + dy*sin, y' = -dx*sin + dy*cos
1417
+ rotatedXs = [centerX + (px * cos) + (py * sin) for px, py in corners]
1418
+ rotatedYs = [centerY - (px * sin) + (py * cos) for px, py in corners]
1262
1419
 
1263
1420
  # get bounding box dimensions
1264
1421
  x = min(rotatedXs)
@@ -1268,6 +1425,28 @@ class _Drawable(_Interactable):
1268
1425
 
1269
1426
  return x, y, width, height
1270
1427
 
1428
+ def _computeBoundary(self, n=32):
1429
+ """
1430
+ Computes this shape's boundary as a list of (x, y) tuples in global coordinates.
1431
+ Default returns the 4 corners of the axis-aligned bounding box.
1432
+ Subclasses override this for their exact geometry.
1433
+ """
1434
+ x, y, w, h = self._getBoundingBox()
1435
+ return [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
1436
+
1437
+ def _getBoundary(self, n=32):
1438
+ """
1439
+ Returns this shape's boundary, computing and caching it on first call.
1440
+ Invalidated by any change to position, size, or rotation.
1441
+ """
1442
+ if self._cachedBoundary is None:
1443
+ self._cachedBoundary = self._computeBoundary(n)
1444
+ return self._cachedBoundary
1445
+
1446
+ def _invalidateBoundary(self):
1447
+ """Clears the cached boundary so it is recomputed on next access."""
1448
+ self._cachedBoundary = None
1449
+
1271
1450
  def _rotatePoints(self, xPoints, yPoints):
1272
1451
  """
1273
1452
  Rotates a list of global-coordinate points around this item's center.
@@ -1404,6 +1583,7 @@ class _Drawable(_Interactable):
1404
1583
  # update internal coordinates
1405
1584
  self._width = localWidth
1406
1585
  self._height = localHeight
1586
+ self._invalidateBoundary()
1407
1587
 
1408
1588
  # update rendering canvas
1409
1589
  _handler().sendCommand('setSize', self._objectId, {'width': localWidth, 'height': localHeight})
@@ -1492,6 +1672,7 @@ class _Drawable(_Interactable):
1492
1672
 
1493
1673
  # update internal rotation
1494
1674
  self._rotation = rotation
1675
+ self._invalidateBoundary()
1495
1676
 
1496
1677
  # update the renderer
1497
1678
  _handler().sendCommand('setRotation', self._objectId, {
@@ -1500,38 +1681,67 @@ class _Drawable(_Interactable):
1500
1681
 
1501
1682
  # ── Hit testing ────────────────────────────────────────────────────────────
1502
1683
 
1684
+ def _isOpen(self):
1685
+ """
1686
+ Returns True if this shape is an open path (no interior).
1687
+ Open shapes use proximity-to-path tests rather than point-in-polygon.
1688
+ Overridden by Polyline (always open) and Arc (open when style == OPEN).
1689
+ """
1690
+ return False
1691
+
1503
1692
  def contains(self, x, y):
1504
1693
  """
1505
1694
  Returns True if the point (x, y) is inside the item.
1695
+ Uses ray-casting on the shape's boundary polygon.
1506
1696
  """
1507
- result = _handler().sendQuery('contains', self._objectId, {'x': x, 'y': y})
1508
- return result[0]
1697
+ return _pointInPolygon(x, y, self._getBoundary())
1509
1698
 
1510
1699
  def intersects(self, other):
1511
1700
  """
1512
- Returns True if this item intersects the other item.
1701
+ Returns True if this item overlaps the other item.
1702
+ Dispatches to the appropriate algorithm based on whether each shape is
1703
+ an open path (Polyline, Arc OPEN) or a closed shape (everything else).
1513
1704
  """
1514
- result = None
1515
-
1516
1705
  if not isinstance(other, _Drawable):
1517
1706
  raise TypeError(f'{type(self).__name__}.intersects(): other should be a Drawable object (it was {type(other).__name__})')
1707
+
1708
+ # AABB pre-check: if bounding boxes don't overlap, nothing can intersect.
1709
+ ax, ay, aw, ah = self._getBoundingBox()
1710
+ bx, by, bw, bh = other._getBoundingBox()
1711
+ if ax + aw < bx or bx + bw < ax or ay + ah < by or by + bh < ay:
1712
+ result = False
1518
1713
  else:
1519
- response = _handler().sendQuery('intersects', self._objectId, {'otherObjectId': other._objectId})
1520
- result = response[0]
1714
+ boundaryA = self._getBoundary()
1715
+ boundaryB = other._getBoundary()
1716
+ selfOpen = self._isOpen()
1717
+ otherOpen = other._isOpen()
1718
+
1719
+ if not selfOpen and not otherOpen:
1720
+ result = _polygonsIntersect(boundaryA, boundaryB)
1721
+ elif selfOpen and not otherOpen:
1722
+ result = _openPathIntersectsPolygon(boundaryA, boundaryB)
1723
+ elif not selfOpen and otherOpen:
1724
+ result = _openPathIntersectsPolygon(boundaryB, boundaryA)
1725
+ else:
1726
+ result = _pathsIntersect(boundaryA, boundaryB)
1521
1727
 
1522
1728
  return result
1523
1729
 
1524
1730
  def encloses(self, other):
1525
1731
  """
1526
- Returns True if this item fully encloses the other item.
1732
+ Returns True if this item fully contains the other item.
1733
+ An open path has no interior and cannot enclose anything.
1734
+ Otherwise, checks that every point of other's boundary lies inside self.
1527
1735
  """
1528
- result = None
1529
-
1530
1736
  if not isinstance(other, _Drawable):
1531
1737
  raise TypeError(f'{type(self).__name__}.encloses(): other should be a Drawable object (it was {type(other).__name__})')
1738
+
1739
+ if self._isOpen():
1740
+ result = False
1532
1741
  else:
1533
- response = _handler().sendQuery('encloses', self._objectId, {'otherObjectId': other._objectId})
1534
- result = response[0]
1742
+ boundaryA = self._getBoundary()
1743
+ boundaryB = other._getBoundary()
1744
+ result = all(_pointInPolygon(px, py, boundaryA) for px, py in boundaryB)
1535
1745
 
1536
1746
  return result
1537
1747
 
@@ -1601,6 +1811,21 @@ class _Graphics(_Drawable):
1601
1811
  self._color = [r, g, b, a]
1602
1812
  _handler().sendCommand('setColor', self._objectId, {'color': [r, g, b, a]})
1603
1813
 
1814
+ # ── Alpha ────────────────────────────────────────────────────────────────
1815
+
1816
+ def getAlpha(self):
1817
+ """
1818
+ Returns the item's transparency (0-255, where 0 is fully transparent).
1819
+ """
1820
+ return self._color[3]
1821
+
1822
+ def setAlpha(self, alpha):
1823
+ """
1824
+ Sets the item's transparency (0-255, where 0 is fully transparent).
1825
+ """
1826
+ self._color[3] = max(0, min(255, int(alpha)))
1827
+ _handler().sendCommand('setColor', self._objectId, {'color': self._color})
1828
+
1604
1829
  # ── Fill ────────────────────────────────────────────────────────────────
1605
1830
 
1606
1831
  def getFill(self):
@@ -1694,6 +1919,16 @@ class Rectangle(_Graphics):
1694
1919
  xPoints, yPoints = self._getEndpoints()
1695
1920
  return self._rotatePoints(xPoints, yPoints)
1696
1921
 
1922
+ # ── Boundary ───────────────────────────────────────────────────────────────
1923
+
1924
+ def _computeBoundary(self, n=32):
1925
+ """
1926
+ Computes the 4 rotated corners of the rectangle as (x, y) tuples in global
1927
+ coordinates. Rotation is applied around the rectangle's center.
1928
+ """
1929
+ xPoints, yPoints = self.getEndpoints()
1930
+ return list(zip(xPoints, yPoints))
1931
+
1697
1932
 
1698
1933
  class Oval(_Graphics):
1699
1934
  """
@@ -1734,6 +1969,38 @@ class Oval(_Graphics):
1734
1969
  thickness = self.getThickness()
1735
1970
  return f'Oval(x1 = {x1}, y1 = {y1}, x2 = {x2}, y2 = {y2}, color = {color}, fill = {fill}, thickness = {thickness}, rotation = {rotation})'
1736
1971
 
1972
+ # ── Boundary ───────────────────────────────────────────────────────────────
1973
+
1974
+ def _computeBoundary(self, n=32):
1975
+ """
1976
+ Computes n points around the ellipse boundary as (x, y) tuples in global
1977
+ coordinates, with rotation applied around the ellipse's center.
1978
+ Circle inherits this; its rotation is always 0 so the rotation branch
1979
+ is never taken.
1980
+ """
1981
+ gx, gy = self._getGlobalCorner()
1982
+ cx = gx + self._width / 2
1983
+ cy = gy + self._height / 2
1984
+ rx = self._width / 2
1985
+ ry = self._height / 2
1986
+
1987
+ if self._rotation != 0:
1988
+ rad = np.radians(self._rotation)
1989
+ cos = np.cos(rad)
1990
+ sin = np.sin(rad)
1991
+ points = []
1992
+ for i in range(n):
1993
+ t = 2 * np.pi * i / n
1994
+ dx = rx * np.cos(t)
1995
+ dy = ry * np.sin(t)
1996
+ # visual CCW rotation: x' = dx*cos + dy*sin, y' = -dx*sin + dy*cos
1997
+ points.append((cx + dx * cos + dy * sin,
1998
+ cy - dx * sin + dy * cos))
1999
+ return points
2000
+ else:
2001
+ return [(cx + rx * np.cos(2 * np.pi * i / n),
2002
+ cy + ry * np.sin(2 * np.pi * i / n)) for i in range(n)]
2003
+
1737
2004
 
1738
2005
  class Circle(Oval):
1739
2006
  """
@@ -1794,6 +2061,29 @@ class Circle(Oval):
1794
2061
  diameter = radius * 2
1795
2062
  self.setWidth(diameter)
1796
2063
 
2064
+ # ── Hit testing ────────────────────────────────────────────────────────────
2065
+
2066
+ def intersects(self, other):
2067
+ """
2068
+ Returns True if this circle overlaps the other item.
2069
+ For Circle-Circle pairs uses an exact O(1) distance check.
2070
+ Falls back to the base-class AABB + polygon path for all other pairings.
2071
+ """
2072
+ if isinstance(other, Circle):
2073
+ ax, ay = self._getGlobalCorner()
2074
+ bx, by = other._getGlobalCorner()
2075
+ selfCX = ax + self._width / 2
2076
+ selfCY = ay + self._height / 2
2077
+ otherCX = bx + other._width / 2
2078
+ otherCY = by + other._height / 2
2079
+ radiiSum = (self._width + other._width) / 2
2080
+ dx = selfCX - otherCX
2081
+ dy = selfCY - otherCY
2082
+ result = dx * dx + dy * dy <= radiiSum * radiiSum
2083
+ else:
2084
+ result = _Drawable.intersects(self, other)
2085
+ return result
2086
+
1797
2087
 
1798
2088
  class Point(Circle):
1799
2089
  """
@@ -1859,6 +2149,78 @@ class Arc(_Graphics):
1859
2149
  thickness = self.getThickness()
1860
2150
  return f'Arc(x1 = {x1}, y1 = {y1}, x2 = {x2}, y2 = {y2}, startAngle = {startAngle}, endAngle = {endAngle}, style = {style}, color = {color}, fill = {fill}, thickness = {thickness}, rotation = {rotation})'
1861
2151
 
2152
+ # ── Boundary ───────────────────────────────────────────────────────────────
2153
+
2154
+ def _computeBoundary(self, n=32):
2155
+ """
2156
+ Computes boundary points for the arc as (x, y) tuples in global coordinates,
2157
+ with any outer rotation applied.
2158
+
2159
+ Angles are in degrees: 0 = east, positive = CCW visual. The arc sweeps
2160
+ from startAngle toward endAngle following the same path the renderer draws.
2161
+
2162
+ Returned point layout by style:
2163
+ OPEN — n+1 points along the arc curve only (open path).
2164
+ CHORD — n+1 arc curve points; first and last are implicitly connected.
2165
+ PIE — center point followed by n+1 arc curve points; last connects
2166
+ back to center to close the pie slice.
2167
+
2168
+ ArcCircle inherits this implementation.
2169
+ """
2170
+ gx, gy = self._getGlobalCorner()
2171
+ cx = gx + self._width / 2
2172
+ cy = gy + self._height / 2
2173
+ rx = self._width / 2
2174
+ ry = self._height / 2
2175
+
2176
+ # The renderer uses arcWidth = -(endAngle - startAngle) and passes it to
2177
+ # Qt as the sweep, so the arc steps from startAngle by that delta.
2178
+ # Replicating that here: t steps from startAngle_rad toward
2179
+ # startAngle_rad + sweep_rad, where sweep = -(endAngle - startAngle).
2180
+ startRad = np.radians(self._startAngle)
2181
+ sweepRad = np.radians(-(self._endAngle - self._startAngle))
2182
+
2183
+ # Qt point formula for CCW-convention angles in a Y-down screen:
2184
+ # x = cx + rx * cos(t), y = cy - ry * sin(t)
2185
+ arcPoints = []
2186
+ for i in range(n + 1):
2187
+ t = startRad + sweepRad * i / n
2188
+ arcPoints.append((cx + rx * np.cos(t),
2189
+ cy - ry * np.sin(t)))
2190
+
2191
+ if self._style == PIE:
2192
+ points = [(cx, cy)] + arcPoints
2193
+ else:
2194
+ points = arcPoints # OPEN and CHORD share the same point list;
2195
+ # CHORD's implicit closing segment is handled
2196
+ # by the polygon hit-test closing first-to-last.
2197
+
2198
+ # Apply outer rotation around the shape's center if set
2199
+ if self._rotation != 0:
2200
+ rad = np.radians(self._rotation)
2201
+ cos = np.cos(rad)
2202
+ sin = np.sin(rad)
2203
+ points = [(cx + (px - cx) * cos + (py - cy) * sin,
2204
+ cy - (px - cx) * sin + (py - cy) * cos)
2205
+ for px, py in points]
2206
+
2207
+ return points
2208
+
2209
+ # ── Hit testing ────────────────────────────────────────────────────────────
2210
+
2211
+ def _isOpen(self):
2212
+ return self._style == OPEN
2213
+
2214
+ def contains(self, x, y):
2215
+ """
2216
+ Returns True if (x, y) is inside the arc shape.
2217
+ PIE and CHORD styles form closed polygons and use ray-casting.
2218
+ OPEN style has no interior, so it uses a proximity-to-path check instead.
2219
+ """
2220
+ if self._isOpen():
2221
+ return _pointNearPath(x, y, self._getBoundary(), max(self._thickness, 1) / 2)
2222
+ return _pointInPolygon(x, y, self._getBoundary())
2223
+
1862
2224
  # ── ArcWidth ────────────────────────────────────────────────────────────────
1863
2225
 
1864
2226
  def _setArcWidth(self, arcWidth):
@@ -1998,6 +2360,39 @@ class Line(_Graphics):
1998
2360
  xPoints, yPoints = self._getEndpoints()
1999
2361
  return self._rotatePoints(xPoints, yPoints)
2000
2362
 
2363
+ # ── Boundary ───────────────────────────────────────────────────────────────
2364
+
2365
+ def _computeBoundary(self, n=32):
2366
+ """
2367
+ Computes the 4 corners of a thin rectangle enclosing the line as (x, y)
2368
+ tuples in global coordinates. The rectangle's width equals the line length
2369
+ and its height equals the line's thickness (minimum 1), giving accurate
2370
+ hit-test coverage for both thick and thin lines. Any rotation is already
2371
+ baked into the post-rotation endpoints returned by getEndpoints().
2372
+ """
2373
+ xs, ys = self.getEndpoints()
2374
+ x1, x2 = xs
2375
+ y1, y2 = ys
2376
+
2377
+ dx = x2 - x1
2378
+ dy = y2 - y1
2379
+ length = np.sqrt(dx * dx + dy * dy)
2380
+
2381
+ if length == 0:
2382
+ return [(x1, y1)]
2383
+
2384
+ # unit vector along the line and its CCW perpendicular
2385
+ ux, uy = dx / length, dy / length
2386
+ px, py = -uy, ux
2387
+ halfT = max(self._thickness, 1) / 2
2388
+
2389
+ return [
2390
+ (x1 + px * halfT, y1 + py * halfT),
2391
+ (x1 - px * halfT, y1 - py * halfT),
2392
+ (x2 - px * halfT, y2 - py * halfT),
2393
+ (x2 + px * halfT, y2 + py * halfT),
2394
+ ]
2395
+
2001
2396
  # ── Length ────────────────────────────────────────────────────────────────
2002
2397
 
2003
2398
  def getLength(self):
@@ -2134,6 +2529,30 @@ class Polyline(_Graphics):
2134
2529
  xPoints, yPoints = self._getEndpoints()
2135
2530
  return self._rotatePoints(xPoints, yPoints)
2136
2531
 
2532
+ # ── Boundary ───────────────────────────────────────────────────────────────
2533
+
2534
+ def _computeBoundary(self, n=32):
2535
+ """
2536
+ Computes the polyline's joint points as (x, y) tuples in global coordinates,
2537
+ with rotation applied. Forms an open path — the last point does not
2538
+ implicitly connect back to the first.
2539
+ """
2540
+ xPoints, yPoints = self.getEndpoints()
2541
+ return list(zip(xPoints, yPoints))
2542
+
2543
+ # ── Hit testing ────────────────────────────────────────────────────────────
2544
+
2545
+ def _isOpen(self):
2546
+ return True
2547
+
2548
+ def contains(self, x, y):
2549
+ """
2550
+ Returns True if (x, y) lies within half the stroke thickness of any
2551
+ segment. A Polyline has no interior, so proximity to the path is the
2552
+ correct containment test.
2553
+ """
2554
+ return _pointNearPath(x, y, self._getBoundary(), max(self._thickness, 1) / 2)
2555
+
2137
2556
 
2138
2557
  class Polygon(_Graphics):
2139
2558
  """
@@ -2208,6 +2627,17 @@ class Polygon(_Graphics):
2208
2627
  xPoints, yPoints = self._getEndpoints()
2209
2628
  return self._rotatePoints(xPoints, yPoints)
2210
2629
 
2630
+ # ── Boundary ───────────────────────────────────────────────────────────────
2631
+
2632
+ def _computeBoundary(self, n=32):
2633
+ """
2634
+ Computes the polygon's vertices as (x, y) tuples in global coordinates,
2635
+ with rotation applied. Forms a closed boundary (first point implicitly
2636
+ connects back to last).
2637
+ """
2638
+ xPoints, yPoints = self.getEndpoints()
2639
+ return list(zip(xPoints, yPoints))
2640
+
2211
2641
 
2212
2642
  class Icon(_Graphics):
2213
2643
  """
@@ -2223,6 +2653,7 @@ class Icon(_Graphics):
2223
2653
 
2224
2654
  self._filename = filename
2225
2655
  self._rotation = rotation
2656
+ self._alpha = 255
2226
2657
 
2227
2658
  # width/height are resolved by the renderer (from the actual pixmap)
2228
2659
  # For now, cache what the user provided; the renderer will resolve None values.
@@ -2254,6 +2685,23 @@ class Icon(_Graphics):
2254
2685
  rotation = self.getRotation()
2255
2686
  return f'Icon(filename = "{filename}", width = {width}, height = {height}, rotation = {rotation})'
2256
2687
 
2688
+ # ── Alpha ────────────────────────────────────────────────────────────────
2689
+
2690
+ def getAlpha(self):
2691
+ """
2692
+ Returns the icon's global transparency (0-255, where 0 is fully transparent).
2693
+ This is independent of the color filter set by setColor().
2694
+ """
2695
+ return self._alpha
2696
+
2697
+ def setAlpha(self, alpha):
2698
+ """
2699
+ Sets the icon's global transparency (0-255, where 0 is fully transparent).
2700
+ This is independent of the color filter set by setColor().
2701
+ """
2702
+ self._alpha = max(0, min(255, int(alpha)))
2703
+ _handler().sendCommand('setAlpha', self._objectId, {'alpha': self._alpha})
2704
+
2257
2705
  # ── Save ────────────────────────────────────────────────────────────────
2258
2706
 
2259
2707
  def save(self, filename, width=None, height=None):
midi.py CHANGED
@@ -62,8 +62,8 @@ notesCurrentlyPlaying = [] # (pitch, channel) tuples; prevents early note-off
62
62
  # Usage:
63
63
  # midiIn = MidiIn()
64
64
  #
65
- # def onEvent(msgType, msgChannel, msgData1, msgData2):
66
- # print(msgType, msgChannel, msgData1, msgData2)
65
+ # def onEvent(eventType, channel, data1, data2):
66
+ # print(eventType, channel, data1, data2)
67
67
  #
68
68
  # midiIn.onInput(ALL_EVENTS, onEvent)
69
69
 
@@ -411,54 +411,54 @@ class MidiOut:
411
411
  start = int(startTime)
412
412
  duration = int(note.getLength() * FACTOR)
413
413
  startTime += note.getDuration() * FACTOR
414
- velocity = note.getDynamic()
414
+ dynamic = note.getDynamic()
415
415
 
416
416
  if frequency != REST:
417
- noteList.append((start, duration, frequency, velocity, channel, instrument, panning))
417
+ noteList.append((start, duration, frequency, dynamic, channel, instrument, panning))
418
418
 
419
419
  # sort by start time so chords (same start time, duration=0) sort before their anchor note
420
420
  noteList.sort()
421
421
 
422
422
  # schedule all notes; chords are sequences of duration=0 notes followed by one note with the real duration
423
423
  chordNotes = []
424
- for start, duration, pitch, velocity, channel, instrument, panning in noteList:
424
+ for start, duration, pitch, dynamic, channel, instrument, panning in noteList:
425
425
  self.setInstrument(instrument, channel)
426
426
 
427
427
  if duration == 0: # chord member — collect it
428
- chordNotes.append([start, duration, pitch, velocity, channel, panning])
428
+ chordNotes.append([start, duration, pitch, dynamic, channel, panning])
429
429
 
430
430
  elif chordNotes == []: # regular solo note
431
- self.note(pitch, start, duration, velocity, channel, panning)
431
+ self.note(pitch, start, duration, dynamic, channel, panning)
432
432
 
433
433
  else: # final note of a chord — schedule all collected chord members with this duration
434
- chordNotes.append([start, duration, pitch, velocity, channel, panning])
435
- for start, _, pitch, velocity, channel, panning in chordNotes:
436
- self.note(pitch, start, duration, velocity, channel, panning)
434
+ chordNotes.append([start, duration, pitch, dynamic, channel, panning])
435
+ for start, _, pitch, dynamic, channel, panning in chordNotes:
436
+ self.note(pitch, start, duration, dynamic, channel, panning)
437
437
  chordNotes = []
438
438
 
439
439
 
440
- def noteOn(self, pitch, velocity=100, channel=0, panning=-1):
440
+ def noteOn(self, pitch, dynamic=100, channel=0, panning=-1):
441
441
  """Sends NOTE_ON for 'pitch' (MIDI int 0-127 or Hz float) on 'channel'."""
442
442
 
443
443
  if isinstance(pitch, int) and (0 <= pitch <= 127):
444
444
  if panning != -1:
445
445
  self.sendMidiMessage(CONTROL_CHANGE, channel, 10, panning)
446
- self.sendMidiMessage(NOTE_ON, channel, pitch, velocity)
446
+ self.sendMidiMessage(NOTE_ON, channel, pitch, dynamic)
447
447
  notesCurrentlyPlaying.append((pitch, channel))
448
448
 
449
449
  elif isinstance(pitch, float):
450
- self.frequencyOn(pitch, velocity, channel, panning)
450
+ self.frequencyOn(pitch, dynamic, channel, panning)
451
451
 
452
452
  else:
453
453
  print(f"MidiOut.noteOn(): Unrecognized pitch {pitch}, expected int 0-127 or float Hz 8.17-12600.0.")
454
454
 
455
455
 
456
- def frequencyOn(self, frequency, velocity=100, channel=0, panning=-1):
456
+ def frequencyOn(self, frequency, dynamic=100, channel=0, panning=-1):
457
457
  """Sends NOTE_ON for 'frequency' (in Hz) on 'channel', applying pitch bend for microtones."""
458
458
  if isinstance(frequency, float) and (8.17 <= frequency <= 12600.0):
459
459
  pitch, bend = freqToNote(frequency)
460
460
  notesCurrentlyPlaying.append((pitch, channel))
461
- self.noteOnPitchBend(pitch, bend, velocity, channel, panning)
461
+ self.noteOnPitchBend(pitch, bend, dynamic, channel, panning)
462
462
  else:
463
463
  print(f"MidiOut.frequencyOn(): Invalid frequency {frequency}, expected float Hz 8.17-12600.0.")
464
464
 
@@ -497,16 +497,16 @@ class MidiOut:
497
497
  print(f"MidiOut.frequencyOff(): Invalid frequency {frequency}, expected float Hz 8.17-12600.0.")
498
498
 
499
499
 
500
- def note(self, pitch, start, duration, velocity=100, channel=0, panning=-1):
500
+ def note(self, pitch, start, duration, dynamic=100, channel=0, panning=-1):
501
501
  """Schedules a note to start at 'start' ms from now and last 'duration' ms."""
502
- Timer(start, self.noteOn, [pitch, velocity, channel, panning], False).start()
503
- Timer(start+duration, self.noteOff, [pitch, channel], False).start()
502
+ Timer(start, self.noteOn, [pitch, dynamic, channel, panning], False).start()
503
+ Timer(start+duration, self.noteOff, [pitch, channel], False).start()
504
504
 
505
505
 
506
- def frequency(self, frequency, start, duration, velocity=100, channel=0, panning=-1):
506
+ def frequency(self, frequency, start, duration, dynamic=100, channel=0, panning=-1):
507
507
  """Schedules a frequency (in Hz) to start at 'start' ms from now and last 'duration' ms."""
508
- Timer(start, self.frequencyOn, [frequency, velocity, channel, panning], False).start()
509
- Timer(start+duration, self.frequencyOff, [frequency, channel], False).start()
508
+ Timer(start, self.frequencyOn, [frequency, dynamic, channel, panning], False).start()
509
+ Timer(start+duration, self.frequencyOff, [frequency, channel], False).start()
510
510
 
511
511
 
512
512
  def setPitchBend(self, bend=0, channel=0):
@@ -525,12 +525,12 @@ class MidiOut:
525
525
  return self.pitchBend[channel]
526
526
 
527
527
 
528
- def noteOnPitchBend(self, pitch, bend=0, velocity=100, channel=0, panning=-1):
528
+ def noteOnPitchBend(self, pitch, bend=0, dynamic=100, channel=0, panning=-1):
529
529
  """Sends NOTE_ON for 'pitch' with 'bend' applied on 'channel'."""
530
530
  self.setPitchBend(bend, channel)
531
531
  if panning != -1:
532
532
  self.sendMidiMessage(CONTROL_CHANGE, channel, 10, panning)
533
- self.sendMidiMessage(NOTE_ON, channel, pitch, velocity)
533
+ self.sendMidiMessage(NOTE_ON, channel, pitch, dynamic)
534
534
 
535
535
 
536
536
  def allNotesOff(self):
@@ -583,61 +583,61 @@ class MidiOut:
583
583
  return self.panning[channel]
584
584
 
585
585
 
586
- def sendMidiMessage(self, msgType, msgChannel, msgData1, msgData2):
586
+ def sendMidiMessage(self, eventType, channel, data1, data2):
587
587
  """Sends a raw MIDI message to the output device."""
588
588
  try:
589
- if msgType == NOTE_ON:
590
- msg = mido.Message('note_on', channel=msgChannel, note=msgData1, velocity=msgData2)
589
+ if eventType == NOTE_ON:
590
+ msg = mido.Message('note_on', channel=channel, note=data1, velocity=data2)
591
591
 
592
- elif msgType == NOTE_OFF:
593
- msg = mido.Message('note_off', channel=msgChannel, note=msgData1, velocity=msgData2)
592
+ elif eventType == NOTE_OFF:
593
+ msg = mido.Message('note_off', channel=channel, note=data1, velocity=data2)
594
594
 
595
- elif msgType == SET_INSTRUMENT:
596
- msg = mido.Message('program_change', channel=msgChannel, program=msgData1)
595
+ elif eventType == SET_INSTRUMENT:
596
+ msg = mido.Message('program_change', channel=channel, program=data1)
597
597
 
598
- elif msgType == CONTROL_CHANGE:
599
- msg = mido.Message('control_change', channel=msgChannel, control=msgData1, value=msgData2)
598
+ elif eventType == CONTROL_CHANGE:
599
+ msg = mido.Message('control_change', channel=channel, control=data1, value=data2)
600
600
 
601
- elif msgType == PITCH_BEND:
601
+ elif eventType == PITCH_BEND:
602
602
  # Recombine 7-bit LSB/MSB into a 14-bit value, then shift to mido's -8192..8191 range.
603
- bendValue = ((msgData2 << 7) + msgData1) - PITCHBEND_NORMAL
604
- msg = mido.Message('pitchwheel', channel=msgChannel, pitch=bendValue)
603
+ bendValue = ((data2 << 7) + data1) - PITCHBEND_NORMAL
604
+ msg = mido.Message('pitchwheel', channel=channel, pitch=bendValue)
605
605
 
606
- elif msgType == AFTERTOUCH:
607
- msg = mido.Message('aftertouch', channel=msgChannel, value=msgData1)
606
+ elif eventType == AFTERTOUCH:
607
+ msg = mido.Message('aftertouch', channel=channel, value=data1)
608
608
 
609
- elif msgType == POLYTOUCH:
610
- msg = mido.Message('polytouch', channel=msgChannel, note=msgData1, value=msgData2)
609
+ elif eventType == POLYTOUCH:
610
+ msg = mido.Message('polytouch', channel=channel, note=data1, value=data2)
611
611
 
612
- elif msgType == SYSTEM_MESSAGE_VALUES['system_exclusive']:
613
- msg = mido.Message('sysex', data=[msgData1, msgData2])
612
+ elif eventType == SYSTEM_MESSAGE_VALUES['system_exclusive']:
613
+ msg = mido.Message('sysex', data=[data1, data2])
614
614
 
615
- elif msgType == SYSTEM_MESSAGE_VALUES['songpos']:
616
- msg = mido.Message('songpos', pos=(msgData2 << 7) + msgData1)
615
+ elif eventType == SYSTEM_MESSAGE_VALUES['songpos']:
616
+ msg = mido.Message('songpos', pos=(data2 << 7) + data1)
617
617
 
618
- elif msgType == SYSTEM_MESSAGE_VALUES['songsel']:
619
- msg = mido.Message('songsel', song=msgData1)
618
+ elif eventType == SYSTEM_MESSAGE_VALUES['songsel']:
619
+ msg = mido.Message('songsel', song=data1)
620
620
 
621
- elif msgType == SYSTEM_MESSAGE_VALUES['tune_request']:
621
+ elif eventType == SYSTEM_MESSAGE_VALUES['tune_request']:
622
622
  msg = mido.Message('tune_request')
623
623
 
624
- elif msgType == SYSTEM_MESSAGE_VALUES['system_reset']:
624
+ elif eventType == SYSTEM_MESSAGE_VALUES['system_reset']:
625
625
  msg = mido.Message('reset')
626
626
 
627
- elif msgType == REALTIME_MESSAGE_VALUES['clock']:
627
+ elif eventType == REALTIME_MESSAGE_VALUES['clock']:
628
628
  msg = mido.Message('clock')
629
629
 
630
- elif msgType == REALTIME_MESSAGE_VALUES['start']:
630
+ elif eventType == REALTIME_MESSAGE_VALUES['start']:
631
631
  msg = mido.Message('start')
632
632
 
633
- elif msgType == REALTIME_MESSAGE_VALUES['continue']:
633
+ elif eventType == REALTIME_MESSAGE_VALUES['continue']:
634
634
  msg = mido.Message('continue')
635
635
 
636
- elif msgType == REALTIME_MESSAGE_VALUES['stop']:
636
+ elif eventType == REALTIME_MESSAGE_VALUES['stop']:
637
637
  msg = mido.Message('stop')
638
638
 
639
639
  else:
640
- print(f"Unsupported MIDI message type: {msgType}")
640
+ print(f"Unsupported MIDI message type: {eventType}")
641
641
  return
642
642
 
643
643
  if self._port:
music.py CHANGED
@@ -4696,7 +4696,7 @@ class AudioSample:
4696
4696
  delayMs=float(effectiveDelay),
4697
4697
  startValue=currentVolumeFactor,
4698
4698
  endValue=targetVolumeFactor,
4699
- function=rampCallback,
4699
+ action=rampCallback,
4700
4700
  stepMs=stepMs
4701
4701
  )
4702
4702
  volumeRamp.start() # start volume ramp
osc.py CHANGED
@@ -63,10 +63,12 @@ class OscIn:
63
63
  alternativeIPs = []
64
64
 
65
65
  try:
66
- hostname = socket.gethostname()
67
- hostIP = socket.gethostbyname(hostname)
68
- allIPs = socket.gethostbyname_ex(hostname)[2]
69
- alternativeIPs = sorted(list(set(ip for ip in allIPs if ip != hostIP and ip != "127.0.0.1")))
66
+ hostname = socket.gethostname()
67
+ allIPs = socket.gethostbyname_ex(hostname)[2]
68
+ nonLoopback = sorted(set(ip for ip in allIPs if ip != "127.0.0.1"))
69
+ if nonLoopback:
70
+ hostIP = nonLoopback[0]
71
+ alternativeIPs = nonLoopback[1:]
70
72
  except socket.gaierror:
71
73
  pass
72
74
 
@@ -79,8 +81,6 @@ class OscIn:
79
81
  print(f'Accepting OSC input on IP address {self.ipAddress}, at port {self.port}')
80
82
  if alternativeIPs:
81
83
  print(f'(Alternative IP addresses: {", ".join(alternativeIPs)})')
82
- elif self.ipAddress != "127.0.0.1" and "127.0.0.1" in socket.gethostbyname_ex(socket.gethostname())[2]:
83
- print('(Alternative IP addresses: 127.0.0.1)')
84
84
  print('Use this info to configure OSC clients.\n')
85
85
 
86
86
  self.showIncomingMessages = True
@@ -112,13 +112,12 @@ class OscIn:
112
112
  """Routes an incoming OSC message to all callbacks with a matching pattern."""
113
113
  address = rawMsg.addrpattern
114
114
  msg = OscMessage(address, rawMsg.arguments)
115
- printed = False
115
+
116
+ if self.showIncomingMessages:
117
+ self._printIncomingMessage(msg)
116
118
 
117
119
  for pattern, callback in self._handlers:
118
120
  if re.fullmatch(pattern, address):
119
- if self.showIncomingMessages and not printed:
120
- self._printIncomingMessage(msg)
121
- printed = True
122
121
  self._executeCallback(callback, msg)
123
122
 
124
123
 
@@ -206,6 +205,7 @@ class OscOut:
206
205
  """Sends an OSC message to 'oscAddress' with optional 'args'."""
207
206
  message = oscbuildparse.OSCMessage(oscAddress, None, args)
208
207
  osc_send(message, self.client)
208
+ osc_process()
209
209
 
210
210
 
211
211
  #################### OscMessage #######################################################