Source code for subsequence.osc
"""OSC integration for realtime control and state broadcasting.
Start the OSC server by calling ``composition.osc()`` before ``composition.play()``.
The server listens on a UDP port (default 9000) for incoming control messages
and sends state updates to a target host/port (default 127.0.0.1:9001).
Built-in Receive Handlers
─────────────────────────
- ``/bpm <int>``: Set tempo
- ``/mute/<name>``: Mute a pattern
- ``/unmute/<name>``: Unmute a pattern
- ``/data/<key> <value>``: Update shared data (supports int, float, str)
Built-in Send Events
────────────────────
- ``/bar <int>``: On bar change
- ``/chord <string>``: On chord change
- ``/section <string>``: On section change
- ``/bpm <int>``: On tempo change
"""
import asyncio
import logging
import typing
import pythonosc.dispatcher
import pythonosc.osc_server
import pythonosc.udp_client
if typing.TYPE_CHECKING:
from subsequence.composition import Composition
logger = logging.getLogger(__name__)
[docs]
class OscServer:
"""Async OSC server/client for bi-directional communication."""
def __init__ (
self,
composition: "Composition",
receive_port: int = 9000,
send_port: int = 9001,
send_host: str = "127.0.0.1",
receive_host: str = "0.0.0.0"
) -> None:
"""
Wire up the OSC ports and built-in control handlers; call start() to begin listening.
"""
self._composition = composition
self._receive_port = receive_port
self._receive_host = receive_host
self._send_port = send_port
self._send_host = send_host
self._server: typing.Optional[typing.Any] = None
self._transport: typing.Optional[asyncio.BaseTransport] = None
self._client: typing.Optional[pythonosc.udp_client.SimpleUDPClient] = None
self._dispatcher = pythonosc.dispatcher.Dispatcher()
# Register built-in handlers
self._dispatcher.map("/bpm", self._handle_bpm)
self._dispatcher.map("/mute/*", self._handle_mute)
self._dispatcher.map("/unmute/*", self._handle_unmute)
self._dispatcher.map("/data/*", self._handle_data)
[docs]
async def start (self) -> None:
"""Start the OSC server and client."""
# client for sending
self._client = pythonosc.udp_client.SimpleUDPClient(self._send_host, self._send_port)
# server for receiving
self._server = pythonosc.osc_server.AsyncIOOSCUDPServer(
(self._receive_host, self._receive_port),
self._dispatcher,
asyncio.get_event_loop() # type: ignore[arg-type]
)
transport, _ = await self._server.create_serve_endpoint()
self._transport = transport
logger.info(f"OSC listening on :{self._receive_port}, sending to {self._send_host}:{self._send_port}")
[docs]
async def stop (self) -> None:
"""Stop the OSC server and close the outgoing client socket."""
if self._transport:
self._transport.close()
self._transport = None
logger.info("OSC server stopped")
if self._client is not None:
# python-osc's SimpleUDPClient owns a raw socket; close it so a
# stopped composition doesn't keep a zombie sender alive.
try:
self._client._sock.close()
except (AttributeError, OSError):
pass
self._client = None
[docs]
def send (self, address: str, *args: typing.Any) -> None:
"""Send an OSC message."""
if self._client:
try:
self._client.send_message(address, args)
except Exception as e:
logger.warning(f"OSC send error: {e}")
[docs]
def map (self, address: str, handler: typing.Callable) -> None:
"""Register a custom OSC handler."""
self._dispatcher.map(address, handler)
# Handlers
def _handle_bpm (self, address: str, *args: typing.Any) -> None:
"""
Set the composition tempo from an incoming ``/bpm <int>`` message.
"""
if not args:
return
try:
bpm = int(args[0])
self._composition.set_bpm(bpm)
except (ValueError, TypeError):
logger.warning(f"Invalid OSC BPM argument: {args[0]}")
def _handle_mute (self, address: str, *args: typing.Any) -> None:
"""
Silence the pattern named in an incoming ``/mute/<name>`` message.
"""
# address is like /mute/drums
parts = address.split("/")
if len(parts) >= 3:
name = parts[2]
self._composition.mute(name)
def _handle_unmute (self, address: str, *args: typing.Any) -> None:
"""
Bring back the pattern named in an incoming ``/unmute/<name>`` message.
"""
parts = address.split("/")
if len(parts) >= 3:
name = parts[2]
self._composition.unmute(name)
def _handle_data (self, address: str, *args: typing.Any) -> None:
"""
Update a composition.data value from an incoming ``/data/<key> <value>`` message, preserving the existing numeric type.
"""
# address is like /data/intensity
if not args:
return
parts = address.split("/")
if len(parts) >= 3:
key = parts[2]
val = args[0]
if key in self._composition.data:
existing = self._composition.data[key]
if isinstance(existing, (float, int)):
try:
val = float(val) if isinstance(existing, float) else int(val)
except (ValueError, TypeError):
logger.warning(f"OSC /data: failed to cast {val} to numeric for key {key}")
return
self._composition.data[key] = val