Skip to content

Commit 8cbf912

Browse files
authored
Add support for OSC bundles (#453)
1 parent ab4f03d commit 8cbf912

2 files changed

Lines changed: 155 additions & 83 deletions

File tree

software/desktop/osc_8mu.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import re
3030
import socket
3131
import struct
32+
import threading
3233
import time
3334

3435

@@ -49,6 +50,7 @@ def __init__(
4950
controls: dict[int, int],
5051
scale: float = 1.0,
5152
debug: bool = False,
53+
use_bundle: bool = False,
5254
):
5355
"""
5456
Create the interface between 8mu and EuroPi
@@ -59,6 +61,7 @@ def __init__(
5961
@param europi_namespace The OSC namespace that the destination EuroPi is using
6062
@param controls A dict that maps MIDI controls to CV1-6
6163
@param debug Enable additional debugging output
64+
@param use_bundle Enable sending data as an OSC Bundle instead of individual packets
6265
6366
@exception ValueError if port is out of range, or IP address is invalid
6467
@exception FileNotFoundError if the 8mu was not found in the MIDI inputs
@@ -77,6 +80,10 @@ def __init__(
7780

7881
self.scale = scale
7982
self.controls = controls
83+
self.use_bundle = use_bundle
84+
85+
self.midi_readings_lock = threading.Lock()
86+
self.midi_readings = {}
8087

8188
if not self.europi_namespace.startswith("/"):
8289
self.europi_namespace = f"/{self.europi_namespace}"
@@ -105,14 +112,10 @@ def on_8mu_slider(self, msg: mido.Message):
105112
cv_out = self.controls[msg.control]
106113
osc_value = msg.value / 127.0 * self.scale # convert to 0-1 float
107114
address = f"{self.europi_namespace}/cv{cv_out}"
108-
packet = self.encode_packet(address, osc_value)
109115

110-
try:
111-
if self.debug:
112-
print(f"{self.europi_namespace}/cv{cv_out} -> {osc_value}")
113-
self.osc_socket.sendto(packet, (self.europi_ip, self.osc_port))
114-
except Exception as err:
115-
print(err)
116+
self.midi_readings_lock.acquire()
117+
self.midi_readings[address] = osc_value
118+
self.midi_readings_lock.release()
116119

117120
def encode_packet(self, address: str, value: float) -> bytearray:
118121
"""
@@ -150,7 +153,35 @@ def pad_length(arr):
150153

151154
def spin(self):
152155
while True:
153-
time.sleep(0.001)
156+
time.sleep(0.01)
157+
158+
packets = []
159+
self.midi_readings_lock.acquire()
160+
for address in self.midi_readings.keys():
161+
packets.append(self.encode_packet(address, self.midi_readings[address]))
162+
self.midi_readings_lock.release()
163+
164+
if self.use_bundle:
165+
# bundle header + all-zero timestamp
166+
bundle = "#bundle\0\0\0\0\0\0\0\0\0".encode("utf-8")
167+
for p in packets:
168+
bundle += len(p).to_bytes(length=4, byteorder="big")
169+
bundle += p
170+
171+
try:
172+
if self.debug:
173+
print(f"Sending bundle {bundle}")
174+
self.osc_socket.sendto(bundle, (self.europi_ip, self.osc_port))
175+
except Exception as err:
176+
print(err)
177+
else:
178+
for p in packets:
179+
try:
180+
if self.debug:
181+
print(f"Sending packet {p}")
182+
self.osc_socket.sendto(p, (self.europi_ip, self.osc_port))
183+
except Exception as err:
184+
print(err)
154185

155186

156187
def main():
@@ -186,6 +217,13 @@ def main():
186217
default="192.168.4.1",
187218
help="EuroPi's IP address. Default: 192.168.4.1",
188219
)
220+
parser.add_argument(
221+
"-b",
222+
"--bundle",
223+
dest="bundle",
224+
action="store_true",
225+
help="Send data as a single OSC bundle instead of individual packets",
226+
)
189227
parser.add_argument(
190228
"-s",
191229
"--scale",
@@ -277,6 +315,7 @@ def main():
277315
},
278316
scale=args.scale,
279317
debug=args.debug,
318+
use_bundle=args.bundle,
280319
)
281320

282321
print("Press CTRL+C to terminate")

software/firmware/experimental/osc.py

Lines changed: 108 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -88,80 +88,76 @@ def __init__(self, data: bytes):
8888
data_start = align_next_word(data_start)
8989
i = type_start + 1
9090
d = data_start
91-
while data[i] != 0x00:
92-
t = chr(data[i])
93-
if t == "i":
94-
types.append(int)
95-
n = (data[d] << 24) | (data[d + 1] << 16) | (data[d + 2] << 8) | data[d + 1]
96-
self.values.append(n)
97-
d += 4
98-
elif t == "f":
99-
types.append(float)
100-
n = struct.unpack(">f", data[d : d + 4])[0]
101-
self.values.append(n)
102-
d += 4
103-
elif t == "s" or t == "S": # include the alternate "S" here
104-
types.append(str)
105-
s = ""
106-
string_end = data.index(b"\0", d)
107-
for c in range(d, string_end):
108-
s += chr(data[c])
109-
self.values.append(s)
110-
d = string_end
111-
d = align_next_word(d)
112-
elif t == "b":
113-
# blob; int32 -> n, followed by n bytes
114-
types.append(bytearray)
115-
n = (data[d] << 24) | (data[d + 1] << 16) | (data[d + 2] << 8) | data[d + 1]
116-
d += 4
117-
b = []
118-
for i in range(n):
119-
b.append(data[d + i])
120-
d += n
121-
d = align_next_word(d)
122-
self.values.append(bytearray(b))
123-
elif t == "T" or t == "F":
124-
# zero-byte boolean; skip
125-
pass
126-
elif t == "t":
127-
# 8-byte timestamp; skip
128-
d += 8
129-
elif t == "h":
130-
# 64-bit signed integer
131-
# treat as a normal int
132-
types.append(int)
133-
n = (
134-
(data[d] << 56)
135-
| (data[d + 1] << 48)
136-
| (data[d + 2] << 40)
137-
| (data[d + 3] << 32)
138-
| (data[d + 4] << 24)
139-
| (data[d + 5] << 16)
140-
| (data[d + 6] << 8)
141-
| data[d + 7]
142-
)
143-
self.values.append(n)
144-
d += 8
145-
elif t == "c":
146-
# a single character; treat as a string
147-
types.append(str)
148-
self.values.append(
149-
data[d + 3].decode() # data is in the 4th byte; padded with leading zeros
150-
)
151-
d += 4
152-
elif t == "m":
153-
# 4-byte midi; skip
154-
d += 4
155-
elif t == "N":
156-
# nil; skip
157-
pass
158-
elif t == "I":
159-
# infinity; skip
160-
pass
161-
else:
162-
log_warning(f"Unsupported type {t}", "osc")
91+
try:
92+
while i < len(data) and data[i] != 0x00:
93+
t = chr(data[i])
94+
if t == "i":
95+
types.append(int)
96+
n = int.from_bytes(data[d : d + 4], "big")
97+
self.values.append(n)
98+
d += 4
99+
elif t == "f":
100+
types.append(float)
101+
n = struct.unpack(">f", data[d : d + 4])[0]
102+
self.values.append(n)
103+
d += 4
104+
elif t == "s" or t == "S": # include the alternate "S" here
105+
types.append(str)
106+
s = ""
107+
string_end = data.index(b"\0", d)
108+
for c in range(d, string_end):
109+
s += chr(data[c])
110+
self.values.append(s)
111+
d = string_end
112+
d = align_next_word(d)
113+
elif t == "b":
114+
# blob; int32 -> n, followed by n bytes
115+
types.append(bytearray)
116+
n = int.from_bytes(data[d : d + 4], "big")
117+
d += 4
118+
b = []
119+
for j in range(n):
120+
b.append(data[d + j])
121+
d += n
122+
d = align_next_word(d)
123+
self.values.append(bytearray(b))
124+
elif t == "T" or t == "F":
125+
# zero-byte boolean; skip
126+
pass
127+
elif t == "t":
128+
# 8-byte timestamp; skip
129+
d += 8
130+
elif t == "h":
131+
# 64-bit signed integer
132+
# treat as a normal int
133+
types.append(int)
134+
n = int.from_bytes(data[d : d + 8], "big")
135+
self.values.append(n)
136+
d += 8
137+
elif t == "c":
138+
# a single character; treat as a string
139+
types.append(str)
140+
self.values.append(
141+
data[d + 3].decode() # data is in the 4th byte; padded with leading zeros
142+
)
143+
d += 4
144+
elif t == "m":
145+
# 4-byte midi; skip
146+
d += 4
147+
elif t == "N":
148+
# nil; skip
149+
pass
150+
elif t == "I":
151+
# infinity; skip
152+
pass
153+
else:
154+
log_warning(f"Unsupported type {t}", "osc")
163155

164-
i += 1
156+
i += 1
157+
except IndexError:
158+
# If we fall off of the array for any reason, just keep what we have so far.
159+
# This could happen if the data got corrupted in transport.
160+
pass
165161

166162
@property
167163
def values(self) -> list[int | float | str | bytearray]:
@@ -177,6 +173,9 @@ def address(self) -> str:
177173
"""This packet's address"""
178174
return self._address
179175

176+
def __str__(self):
177+
return f"{self.address}: {self.values}"
178+
180179

181180
class OpenSoundServer:
182181
"""
@@ -250,13 +249,47 @@ def wrapper(*args, **kwargs):
250249
self.recv_callback = wrapper
251250
return wrapper
252251

252+
def parse_packets(self, data, result):
253+
"""
254+
Recursively process the raw data, decomposing bundles into an array of packets.
255+
256+
:param[in] data: The raw byte data received over the socket
257+
:param[out] result: An array we can recusively append bundle data to
258+
"""
259+
if len(data) > 8 and data[0:7].decode("utf-8") == "#bundle":
260+
# We're processing a bundle
261+
# The first 8 bytes after the header are the timestamp, which we don't support
262+
# so skip that and go straight to the payload
263+
# ['#', 'b', 'u', 'n', 'd', l', 'e', '\0', t0, t1, t2, t3, t4, t5, t6, t7]
264+
try:
265+
data = data[16:]
266+
while len(data) > 0:
267+
element_length = int.from_bytes(data[0:4], "big")
268+
data = data[4:]
269+
self.parse_packets(data, result)
270+
data = data[element_length:]
271+
except IndexError as err:
272+
# either the length got corrupted, or the packet was partially dropped
273+
# either way, just process what we can and move on
274+
pass
275+
else:
276+
# we're processing a normal packet; add it to the result
277+
packet = OpenSoundPacket(data)
278+
result.append(packet)
279+
280+
@property
281+
def elements(self):
282+
return self._elements
283+
253284
def receive_data(self):
254285
"""Check if we have any new data to process, invoke data_handler as needed"""
255286
while True:
256287
try:
257288
(data, connection) = self.recv_socket.recvfrom(1024)
258-
packet = OpenSoundPacket(data)
259-
self.recv_callback(connection=connection, data=packet)
289+
packets = []
290+
self.parse_packets(data, packets)
291+
for packet in packets:
292+
self.recv_callback(connection=connection, data=packet)
260293
except ValueError as err:
261294
log_warning(f"Failed to process packet: {err}", "osc")
262295
break

0 commit comments

Comments
 (0)