python-max 0.1.0__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.
- python_max-0.1.0.dist-info/METADATA +119 -0
- python_max-0.1.0.dist-info/RECORD +5 -0
- python_max-0.1.0.dist-info/WHEEL +4 -0
- python_max-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_max.py +227 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-max
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Send typed values from Python to a Max/MSP patch over Socket.IO.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jasper-zheng/python-max-python
|
|
6
|
+
Project-URL: Repository, https://github.com/jasper-zheng/python-max-python
|
|
7
|
+
Project-URL: Issues, https://github.com/jasper-zheng/python-max-python/issues
|
|
8
|
+
Author-email: Jasper Zheng <jasper.zheng.u@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: audio,creative-coding,max,maxmsp,music,socketio
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Requires-Dist: python-socketio[client]<6,>=5.13
|
|
19
|
+
Provides-Extra: audio
|
|
20
|
+
Requires-Dist: soundfile; extra == 'audio'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# python-max
|
|
24
|
+
|
|
25
|
+
A request/response protocol between a Python script and a Max/MSP patch, via
|
|
26
|
+
[Socket.IO](https://socket.io/). In Python you call `request(route, type, data)` and get back whatever the Max patch returns.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## Python side (client)
|
|
30
|
+
|
|
31
|
+
### Setup
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install python-max
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The audio example additionally needs [`soundfile`](https://pypi.org/project/soundfile/):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install "python-max[audio]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Use
|
|
44
|
+
|
|
45
|
+
Below is an example of sending a typed value to Max without awaiting a reply.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from python_max import MaxClient
|
|
49
|
+
|
|
50
|
+
client = MaxClient(url="http://127.0.0.1", port=5002)
|
|
51
|
+
client.connect()
|
|
52
|
+
|
|
53
|
+
client.emit(
|
|
54
|
+
route="test",
|
|
55
|
+
type="dict",
|
|
56
|
+
value={"freq": 440, "gain": 0.5}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
client.disconnect()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
Below is an example of sending a typed request to Max and awaiting a reply.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from python_max import MaxClient
|
|
67
|
+
|
|
68
|
+
client = MaxClient(url="http://127.0.0.1", port=5002)
|
|
69
|
+
client.connect()
|
|
70
|
+
|
|
71
|
+
result = client.request(
|
|
72
|
+
route="test",
|
|
73
|
+
type="symbol",
|
|
74
|
+
value="hello max",
|
|
75
|
+
timeout=5.0
|
|
76
|
+
)
|
|
77
|
+
# Raises `TimeoutError` if Max doesn't reply in 5s.
|
|
78
|
+
|
|
79
|
+
client.disconnect()
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
### Route
|
|
84
|
+
|
|
85
|
+
`route` (required, first arg) is prepended to the value and needs to be routed in the Max patch. Use `[route <route>]` to catch it.
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
### Types
|
|
89
|
+
|
|
90
|
+
`type` is required and must be one of five Max data types:
|
|
91
|
+
|
|
92
|
+
| type | Python value | notes |
|
|
93
|
+
|----------|---------------------------------------|------------------------------------------|
|
|
94
|
+
| `int` | `int` | |
|
|
95
|
+
| `float` | `float` | |
|
|
96
|
+
| `symbol` | `str` | ≤ 2048 characters (Max's symbol limit) |
|
|
97
|
+
| `list` | `list` of `int` / `float` / `str` | flat list only, **no nested list/dict** |
|
|
98
|
+
| `dict` | `dict` with `str` keys | JSON-object. |
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
emit("test", "int", 5)
|
|
103
|
+
emit("test", "float", 3.14)
|
|
104
|
+
emit("test", "symbol", "hello max")
|
|
105
|
+
emit("test", "list", [1, 2.0, "three"])
|
|
106
|
+
emit("test", "dict", {"freq": [440, 880, 1760], "gain": 0.5})
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Max side (server)
|
|
110
|
+
|
|
111
|
+
`max_server.js` runs inside a `node.script` object and hosts the Socket.IO server on port `5002`.
|
|
112
|
+
|
|
113
|
+
### Run it
|
|
114
|
+
|
|
115
|
+
See `demo-pmp.maxpat`.
|
|
116
|
+
|
|
117
|
+
## How to send audio data
|
|
118
|
+
|
|
119
|
+
Node for Max does not have access to Max's `buffer~` objects (it's a diferent JS runtime than v8). So to send audio data from Python to Max, you need to send it as an Array via a dictionary, and then use `array.tobuffer` to copy it into a `buffer~` object. See [example_audio.py](python/example_audio.py) and [example_audio.maxpat](example_audio.maxpat) for a working example.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
python_max.py,sha256=q9gbL41jObrUtuhLM95DJpS3sW1vLS1oU0uo77n1MBA,8830
|
|
2
|
+
python_max-0.1.0.dist-info/METADATA,sha256=IZ9DloJbLrSe_G24UUiEyUeJmXiI4uua1bwHCRxn3Fw,3694
|
|
3
|
+
python_max-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
4
|
+
python_max-0.1.0.dist-info/licenses/LICENSE,sha256=LNL8gLNErCkAWLUhBGL91GWwCwQnCs2YJsX-zjJohik,1069
|
|
5
|
+
python_max-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jasper Zheng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
python_max.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Synchronous Socket.IO client for talking to a Max/MSP patch.
|
|
2
|
+
|
|
3
|
+
Topology: Max hosts the Socket.IO *server* (Node.js `node.script`); this module is
|
|
4
|
+
the Python *client*. We send a `request` event carrying a `route` (the Max
|
|
5
|
+
outlet selector the value is routed to), a Max data `type`, the `value`, and a
|
|
6
|
+
unique `request_id`, then block until a matching `response` event
|
|
7
|
+
`{"request_id": ..., "results": ...}` arrives.
|
|
8
|
+
|
|
9
|
+
For fire-and-forget delivery, `emit` sends the same typed payload on a separate
|
|
10
|
+
`emit` event (no `request_id`) and returns without awaiting a reply.
|
|
11
|
+
|
|
12
|
+
Supported types (Max's way of naming data):
|
|
13
|
+
int -> a Python int
|
|
14
|
+
float -> a Python int or float (sent as float)
|
|
15
|
+
symbol -> a Python str, at most 2048 characters
|
|
16
|
+
list -> a Python list of atoms (int / float / str)
|
|
17
|
+
dict -> a Python dict with str keys
|
|
18
|
+
|
|
19
|
+
Basic usage:
|
|
20
|
+
|
|
21
|
+
from python_max import request
|
|
22
|
+
|
|
23
|
+
# send to the "test" selector (matches `route test` in the patch)
|
|
24
|
+
results = request("test", "symbol", "hello max")
|
|
25
|
+
print(results)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import threading
|
|
29
|
+
import uuid
|
|
30
|
+
from typing import Any, Optional
|
|
31
|
+
|
|
32
|
+
import socketio
|
|
33
|
+
|
|
34
|
+
SERVER_URL = "http://127.0.0.1"
|
|
35
|
+
SERVER_PORT = 5002
|
|
36
|
+
# Default namespace "/" keeps both sides minimal. If you switch to a named
|
|
37
|
+
# namespace, set it on connect() and pass `namespace=` on every emit/on.
|
|
38
|
+
|
|
39
|
+
SUPPORTED_TYPES = ("int", "float", "symbol", "list", "dict")
|
|
40
|
+
MAX_SYMBOL_LEN = 2048 # Max symbols are strings capped at 2048 characters.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_atom(v: Any) -> Any:
|
|
44
|
+
"""Validate a single Max list atom (int / float / symbol)."""
|
|
45
|
+
if isinstance(v, bool) or not isinstance(v, (int, float, str)):
|
|
46
|
+
raise TypeError("list elements must be int, float, or str (Max atoms)")
|
|
47
|
+
if isinstance(v, str) and len(v) > MAX_SYMBOL_LEN:
|
|
48
|
+
raise ValueError(f"list symbol exceeds {MAX_SYMBOL_LEN} chars")
|
|
49
|
+
return v
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _validate_route(route: str) -> None:
|
|
53
|
+
"""Check the Max outlet selector: a non-empty str within the symbol limit."""
|
|
54
|
+
if not isinstance(route, str):
|
|
55
|
+
raise TypeError(f"route must be a str, got {route.__class__.__name__}")
|
|
56
|
+
if not route:
|
|
57
|
+
raise ValueError("route must be a non-empty str")
|
|
58
|
+
if len(route) > MAX_SYMBOL_LEN:
|
|
59
|
+
raise ValueError(f"route exceeds {MAX_SYMBOL_LEN} chars (got {len(route)})")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _validate(type: str, value: Any) -> Any:
|
|
63
|
+
"""Check `value` against the declared Max `type` and return it normalized.
|
|
64
|
+
|
|
65
|
+
Raises ValueError for an unsupported type / out-of-range symbol, or
|
|
66
|
+
TypeError when the Python value does not match the declared type.
|
|
67
|
+
"""
|
|
68
|
+
if type not in SUPPORTED_TYPES:
|
|
69
|
+
raise ValueError(f"unsupported type {type!r}; expected one of {SUPPORTED_TYPES}")
|
|
70
|
+
if type == "int":
|
|
71
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
72
|
+
raise TypeError(f"type 'int' needs an int, got {value.__class__.__name__}")
|
|
73
|
+
return value
|
|
74
|
+
if type == "float":
|
|
75
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
76
|
+
raise TypeError(
|
|
77
|
+
f"type 'float' needs an int or float, got {value.__class__.__name__}"
|
|
78
|
+
)
|
|
79
|
+
return float(value)
|
|
80
|
+
if type == "symbol":
|
|
81
|
+
if not isinstance(value, str):
|
|
82
|
+
raise TypeError(f"type 'symbol' needs a str, got {value.__class__.__name__}")
|
|
83
|
+
if len(value) > MAX_SYMBOL_LEN:
|
|
84
|
+
raise ValueError(f"symbol exceeds {MAX_SYMBOL_LEN} chars (got {len(value)})")
|
|
85
|
+
return value
|
|
86
|
+
if type == "list":
|
|
87
|
+
if not isinstance(value, list):
|
|
88
|
+
raise TypeError(f"type 'list' needs a list, got {value.__class__.__name__}")
|
|
89
|
+
return [_validate_atom(v) for v in value]
|
|
90
|
+
# dict
|
|
91
|
+
if not isinstance(value, dict):
|
|
92
|
+
raise TypeError(f"type 'dict' needs a dict, got {value.__class__.__name__}")
|
|
93
|
+
if any(not isinstance(k, str) for k in value):
|
|
94
|
+
raise TypeError("dict keys must be str (Max symbols)")
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class _Pending:
|
|
99
|
+
"""A single in-flight request: a threading.Event plus the result slot the
|
|
100
|
+
`response` handler fills in from the background thread."""
|
|
101
|
+
|
|
102
|
+
__slots__ = ("event", "result")
|
|
103
|
+
|
|
104
|
+
def __init__(self) -> None:
|
|
105
|
+
self.event = threading.Event()
|
|
106
|
+
self.result: Any = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class MaxClient:
|
|
110
|
+
"""One persistent connection to the Max-side Socket.IO server."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, url: str = SERVER_URL, port: int = SERVER_PORT):
|
|
113
|
+
self.url = url
|
|
114
|
+
self.port = port
|
|
115
|
+
# Sync client: connect/emit/disconnect block, and inbound events are
|
|
116
|
+
# dispatched on a background thread (so request() can block on an Event).
|
|
117
|
+
# websocket-client has no default receive-size cap, so large echoed
|
|
118
|
+
# replies (e.g. audio buffers) get through without extra options.
|
|
119
|
+
self.sio = socketio.Client()
|
|
120
|
+
# request_id -> _Pending, guarded by _lock because _on_response runs on
|
|
121
|
+
# the client's background thread while request() runs on the caller's.
|
|
122
|
+
self._pending: dict[str, _Pending] = {}
|
|
123
|
+
self._lock = threading.Lock()
|
|
124
|
+
self.sio.on("response", self._on_response)
|
|
125
|
+
|
|
126
|
+
def _on_response(self, data: dict) -> None:
|
|
127
|
+
request_id = data.get("request_id")
|
|
128
|
+
if request_id is None:
|
|
129
|
+
return
|
|
130
|
+
with self._lock:
|
|
131
|
+
pending = self._pending.get(request_id)
|
|
132
|
+
if pending is not None:
|
|
133
|
+
pending.result = data.get("results")
|
|
134
|
+
pending.event.set()
|
|
135
|
+
|
|
136
|
+
def connect(self) -> None:
|
|
137
|
+
self.sio.connect(f"{self.url}:{self.port}")
|
|
138
|
+
|
|
139
|
+
def disconnect(self) -> None:
|
|
140
|
+
self.sio.disconnect()
|
|
141
|
+
|
|
142
|
+
def request(
|
|
143
|
+
self, route: str, type: str, value: Any, timeout: float = 5.0
|
|
144
|
+
) -> Any:
|
|
145
|
+
"""Send a typed `value` to Max and return the `results` from its reply.
|
|
146
|
+
|
|
147
|
+
Blocks the calling thread until Max replies (or `timeout` elapses).
|
|
148
|
+
`route` is the Max outlet selector the value is routed to (e.g. "test"
|
|
149
|
+
for `route test`). `type` is one of int / float / symbol / list / dict.
|
|
150
|
+
Raises TypeError or ValueError if `route`/`value` are invalid (see
|
|
151
|
+
`_validate_route` / `_validate`), or TimeoutError if Max does not
|
|
152
|
+
reply within `timeout` seconds.
|
|
153
|
+
"""
|
|
154
|
+
_validate_route(route)
|
|
155
|
+
value = _validate(type, value)
|
|
156
|
+
request_id = str(uuid.uuid4())
|
|
157
|
+
pending = _Pending()
|
|
158
|
+
with self._lock:
|
|
159
|
+
self._pending[request_id] = pending
|
|
160
|
+
try:
|
|
161
|
+
self.sio.emit(
|
|
162
|
+
"request",
|
|
163
|
+
{"route": route, "type": type, "value": value, "request_id": request_id},
|
|
164
|
+
)
|
|
165
|
+
if not pending.event.wait(timeout):
|
|
166
|
+
raise TimeoutError(f"No response from Max within {timeout}s")
|
|
167
|
+
return pending.result
|
|
168
|
+
finally:
|
|
169
|
+
with self._lock:
|
|
170
|
+
self._pending.pop(request_id, None)
|
|
171
|
+
|
|
172
|
+
def emit(self, route: str, type: str, value: Any) -> None:
|
|
173
|
+
"""Fire-and-forget: send a typed `value` to Max without awaiting a reply.
|
|
174
|
+
|
|
175
|
+
`route`, `type`, and `value` are validated exactly as in `request`.
|
|
176
|
+
Uses a separate `emit` wire event so it never enters the server's
|
|
177
|
+
request/response FIFO — that keeps `request` correlation clean even when
|
|
178
|
+
the emitted value's `route` has no reply path wired. There is no
|
|
179
|
+
return value and no timeout: delivery is best-effort.
|
|
180
|
+
"""
|
|
181
|
+
_validate_route(route)
|
|
182
|
+
value = _validate(type, value)
|
|
183
|
+
self.sio.emit(
|
|
184
|
+
"emit",
|
|
185
|
+
{"route": route, "type": type, "value": value},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- Module-level convenience: lazy singleton so callers can just call it. ---
|
|
190
|
+
|
|
191
|
+
_client: Optional[MaxClient] = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def request(route: str, type: str, value: Any, timeout: float = 5.0) -> Any:
|
|
195
|
+
"""Send a typed `value` to a Max `route` and return the `results`.
|
|
196
|
+
|
|
197
|
+
Connects the shared client on first use. `route` is the Max outlet
|
|
198
|
+
selector the value is routed to (e.g. "test").
|
|
199
|
+
"""
|
|
200
|
+
global _client
|
|
201
|
+
if _client is None:
|
|
202
|
+
client = MaxClient()
|
|
203
|
+
client.connect() # if this raises, leave the singleton unset to retry
|
|
204
|
+
_client = client
|
|
205
|
+
return _client.request(route, type, value, timeout)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def emit(route: str, type: str, value: Any) -> None:
|
|
209
|
+
"""Fire-and-forget send of a typed `value` to a Max `route`; no reply.
|
|
210
|
+
|
|
211
|
+
Same route/type/value validation as `request`, but returns immediately
|
|
212
|
+
without awaiting a response. Connects the shared client on first use.
|
|
213
|
+
"""
|
|
214
|
+
global _client
|
|
215
|
+
if _client is None:
|
|
216
|
+
client = MaxClient()
|
|
217
|
+
client.connect() # if this raises, leave the singleton unset to retry
|
|
218
|
+
_client = client
|
|
219
|
+
_client.emit(route, type, value)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def disconnect() -> None:
|
|
223
|
+
"""Close the shared connection, if one was opened."""
|
|
224
|
+
global _client
|
|
225
|
+
if _client is not None:
|
|
226
|
+
_client.disconnect()
|
|
227
|
+
_client = None
|