Skip to content

Commit ae64c80

Browse files
author
Simon Holliday
committed
- Add MIDI CC realtime forwarding
1 parent e02df03 commit ae64c80

7 files changed

Lines changed: 737 additions & 1 deletion

File tree

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1659,6 +1659,37 @@ def arps (p):
16591659

16601660
CC values are scaled from 0–127 to the `min_val`/`max_val` range and written to `composition.data[key]` on every incoming message. Thread safety is provided by Python's GIL for single dict writes.
16611661

1662+
### Real-time CC forwarding
1663+
1664+
`cc_map()` makes CC values available to patterns at rebuild time - useful for driving generative parameters. For cases where you need the signal to reach your synth immediately (pitch bend from a mod wheel, cutoff from a fader, expression from a pedal), use `cc_forward()` instead:
1665+
1666+
```python
1667+
composition.midi_input("Arturia KeyStep")
1668+
1669+
# Forward CC 1 directly to pitch bend on channel 1 - instant, ~1–5 ms latency
1670+
composition.cc_forward(1, "pitchwheel", output_channel=1)
1671+
1672+
# Reroute CC 1 to CC 74 on the same channel
1673+
composition.cc_forward(1, "cc:74")
1674+
1675+
# Custom transform: scale CC 1 range to CC 74 range 40–100
1676+
import subsequence.midi as midi
1677+
composition.cc_forward(1,
1678+
lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch)
1679+
)
1680+
1681+
# Forward AND map simultaneously - both are active
1682+
composition.cc_map(1, "mod_depth") # value available in patterns via composition.data
1683+
composition.cc_forward(1, "cc:74") # also forwarded in real-time
1684+
```
1685+
1686+
Two dispatch modes:
1687+
1688+
- **`mode="instant"`** *(default)* - sent immediately on the MIDI input callback thread. Latency is ~1–5 ms (driver round-trip only). Not recorded when recording is enabled.
1689+
- **`mode="queued"`** - injected into the sequencer event queue and sent at the next pulse boundary (~0–20 ms at 120 BPM). Properly ordered with note events and **is** recorded when recording is enabled.
1690+
1691+
Built-in preset strings: `"cc"` (identity), `"cc:N"` (remap to CC N), `"pitchwheel"` (scale to ±8192). Pass a callable for full control over output message type and value scaling.
1692+
16621693
### MIDI clock output
16631694

16641695
Make Subsequence the MIDI clock master so hardware can lock to its tempo:

subsequence/composition.py

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,7 @@ def __init__ (
633633
self._clock_follow: bool = False
634634
self._clock_output: bool = False
635635
self._cc_mappings: typing.List[typing.Dict[str, typing.Any]] = []
636+
self._cc_forwards: typing.List[typing.Dict[str, typing.Any]] = []
636637
self.data: typing.Dict[str, typing.Any] = {}
637638
self._osc_server: typing.Optional[subsequence.osc.OscServer] = None
638639
self.conductor = subsequence.conductor.Conductor()
@@ -1396,6 +1397,154 @@ def cc_map (
13961397
})
13971398

13981399

1400+
@staticmethod
1401+
def _make_cc_forward_transform (
1402+
output: typing.Union[str, typing.Callable],
1403+
cc: int,
1404+
output_channel: typing.Optional[int],
1405+
) -> typing.Callable:
1406+
1407+
"""Build a transform callable from a preset string or user-supplied callable.
1408+
1409+
The returned callable has signature ``(value: int, channel: int) -> Optional[mido.Message]``
1410+
where ``channel`` is the 0-indexed incoming channel.
1411+
"""
1412+
1413+
import mido as _mido
1414+
1415+
def _out_ch (incoming: int) -> int:
1416+
return output_channel if output_channel is not None else incoming
1417+
1418+
if callable(output):
1419+
if output_channel is None:
1420+
return output
1421+
def _wrapped (value: int, channel: int) -> typing.Optional[typing.Any]:
1422+
msg = output(value, channel)
1423+
if msg is not None and output_channel is not None:
1424+
# Rebuild message with overridden channel
1425+
return _mido.Message(msg.type, channel=output_channel, **{
1426+
k: v for k, v in msg.__dict__.items() if k != 'channel'
1427+
})
1428+
return msg
1429+
return _wrapped
1430+
1431+
if output == 'cc':
1432+
def _cc_identity (value: int, channel: int) -> typing.Any:
1433+
return _mido.Message('control_change', channel=_out_ch(channel), control=cc, value=value)
1434+
return _cc_identity
1435+
1436+
if output.startswith('cc:'):
1437+
try:
1438+
target_cc = int(output[3:])
1439+
except ValueError:
1440+
raise ValueError(f"cc_forward(): invalid preset '{output}' — expected 'cc:N' where N is 0–127")
1441+
if not 0 <= target_cc <= 127:
1442+
raise ValueError(f"cc_forward(): CC number {target_cc} out of range 0–127")
1443+
def _cc_remap (value: int, channel: int) -> typing.Any:
1444+
return _mido.Message('control_change', channel=_out_ch(channel), control=target_cc, value=value)
1445+
return _cc_remap
1446+
1447+
if output == 'pitchwheel':
1448+
def _pitchwheel (value: int, channel: int) -> typing.Any:
1449+
pitch = int(value / 127 * 16383) - 8192
1450+
return _mido.Message('pitchwheel', channel=_out_ch(channel), pitch=pitch)
1451+
return _pitchwheel
1452+
1453+
raise ValueError(
1454+
f"cc_forward(): unknown preset '{output}'. "
1455+
"Use 'cc', 'cc:N' (e.g. 'cc:74'), 'pitchwheel', or a callable."
1456+
)
1457+
1458+
1459+
def cc_forward (
1460+
self,
1461+
cc: int,
1462+
output: typing.Union[str, typing.Callable],
1463+
*,
1464+
channel: typing.Optional[int] = None,
1465+
output_channel: typing.Optional[int] = None,
1466+
mode: str = "instant",
1467+
) -> None:
1468+
1469+
"""
1470+
Forward an incoming MIDI CC to the MIDI output in real-time.
1471+
1472+
Unlike ``cc_map()`` which writes incoming CC values to ``composition.data``
1473+
for use at pattern rebuild time, ``cc_forward()`` routes the signal
1474+
directly to the MIDI output — bypassing the pattern cycle entirely.
1475+
1476+
Both ``cc_map()`` and ``cc_forward()`` may be registered for the same CC
1477+
number; they operate independently.
1478+
1479+
Parameters:
1480+
cc: Incoming CC number to listen for (0–127).
1481+
output: What to send. Either a **preset string**:
1482+
1483+
- ``"cc"`` — identity forward, same CC number and value.
1484+
- ``"cc:N"`` — forward as CC number N (e.g. ``"cc:74"``).
1485+
- ``"pitchwheel"`` — scale 0–127 to -8192..8191 and send as pitch bend.
1486+
1487+
Or a **callable** with signature
1488+
``(value: int, channel: int) -> Optional[mido.Message]``.
1489+
Return a fully formed ``mido.Message`` to send, or ``None`` to suppress.
1490+
``channel`` is 0-indexed (the incoming channel).
1491+
channel: If given, only respond to CC messages on this channel.
1492+
Uses the same numbering convention as ``cc_map()``.
1493+
``None`` matches any channel (default).
1494+
output_channel: Override the output channel. ``None`` uses the
1495+
incoming channel. Uses the same numbering convention as ``pattern()``.
1496+
mode: Dispatch mode:
1497+
1498+
- ``"instant"`` *(default)* — send immediately on the MIDI input
1499+
callback thread. Lowest latency (~1–5 ms). Instant forwards are
1500+
**not** recorded when recording is enabled.
1501+
- ``"queued"`` — inject into the sequencer event queue and send at
1502+
the next pulse boundary (~0–20 ms at 120 BPM). Queued forwards
1503+
**are** recorded when recording is enabled.
1504+
1505+
Example:
1506+
```python
1507+
comp.midi_input("Arturia KeyStep")
1508+
1509+
# CC 1 → CC 1 (identity, instant)
1510+
comp.cc_forward(1, "cc")
1511+
1512+
# CC 1 → pitch bend on channel 1, queued (recordable)
1513+
comp.cc_forward(1, "pitchwheel", output_channel=1, mode="queued")
1514+
1515+
# CC 1 → CC 74, custom channel
1516+
comp.cc_forward(1, "cc:74", output_channel=2)
1517+
1518+
# Custom transform — remap CC range 0–127 to CC 74 range 40–100
1519+
import subsequence.midi as midi
1520+
comp.cc_forward(1, lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch))
1521+
1522+
# Forward AND map to data simultaneously — both active on the same CC
1523+
comp.cc_map(1, "mod_wheel")
1524+
comp.cc_forward(1, "cc:74")
1525+
```
1526+
"""
1527+
1528+
if not 0 <= cc <= 127:
1529+
raise ValueError(f"cc_forward(): cc {cc} out of range 0–127")
1530+
1531+
if mode not in ('instant', 'queued'):
1532+
raise ValueError(f"cc_forward(): mode must be 'instant' or 'queued', got '{mode}'")
1533+
1534+
resolved_in_channel = self._resolve_channel(channel) if channel is not None else None
1535+
resolved_out_channel = self._resolve_channel(output_channel) if output_channel is not None else None
1536+
1537+
transform = self._make_cc_forward_transform(output, cc, resolved_out_channel)
1538+
1539+
self._cc_forwards.append({
1540+
'cc': cc,
1541+
'channel': resolved_in_channel,
1542+
'output_channel': resolved_out_channel,
1543+
'mode': mode,
1544+
'transform': transform,
1545+
})
1546+
1547+
13991548
def live (self, port: int = 5555) -> None:
14001549

14011550
"""
@@ -2145,8 +2294,9 @@ async def _run (self) -> None:
21452294
loop = asyncio.get_running_loop(),
21462295
)
21472296

2148-
# Share CC input mappings and a reference to composition.data with the sequencer.
2297+
# Share CC input mappings, forwards, and a reference to composition.data with the sequencer.
21492298
self._sequencer.cc_mappings = self._cc_mappings
2299+
self._sequencer.cc_forwards = self._cc_forwards
21502300
self._sequencer._composition_data = self.data
21512301

21522302
# Derive child RNGs from the master seed so each component gets

subsequence/midi.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""
2+
Lightweight MIDI message constructors.
3+
4+
Provides factory functions that return ``mido.Message`` objects without
5+
requiring users to import or interact with mido directly. Intended for use
6+
with ``composition.cc_forward()`` callable transforms and any other context
7+
where a ``mido.Message`` is needed as a return value.
8+
9+
Example::
10+
11+
import subsequence.midi as midi
12+
13+
composition.cc_forward(1,
14+
lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch)
15+
)
16+
"""
17+
18+
import typing
19+
import mido
20+
21+
22+
def cc (
23+
control: int,
24+
value: int,
25+
channel: int = 0,
26+
) -> mido.Message:
27+
28+
"""Create a MIDI Control Change message.
29+
30+
Parameters:
31+
control: CC number (0–127).
32+
value: CC value (0–127).
33+
channel: MIDI channel (0-indexed, 0–15). Defaults to 0.
34+
35+
Returns:
36+
A ``mido.Message`` of type ``control_change``.
37+
38+
Example:
39+
```python
40+
import subsequence.midi as midi
41+
42+
# Forward CC 1 to CC 74, scaling range to 40–100
43+
composition.cc_forward(1,
44+
lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch)
45+
)
46+
```
47+
"""
48+
49+
return mido.Message('control_change', channel=channel, control=control, value=value)
50+
51+
52+
def pitchwheel (
53+
pitch: int,
54+
channel: int = 0,
55+
) -> mido.Message:
56+
57+
"""Create a MIDI Pitch Wheel message.
58+
59+
Parameters:
60+
pitch: Pitch bend value (-8192 to 8191). 0 is centre (no bend).
61+
channel: MIDI channel (0-indexed, 0–15). Defaults to 0.
62+
63+
Returns:
64+
A ``mido.Message`` of type ``pitchwheel``.
65+
66+
Example:
67+
```python
68+
import subsequence.midi as midi
69+
70+
# Forward CC 1 as pitch bend, scaled to upper half only (0 to +8191)
71+
composition.cc_forward(1,
72+
lambda v, ch: midi.pitchwheel(int(v / 127 * 8191), channel=ch)
73+
)
74+
```
75+
"""
76+
77+
return mido.Message('pitchwheel', channel=channel, pitch=pitch)
78+
79+
80+
def program_change (
81+
program: int,
82+
channel: int = 0,
83+
) -> mido.Message:
84+
85+
"""Create a MIDI Program Change message.
86+
87+
Parameters:
88+
program: Program number (0–127).
89+
channel: MIDI channel (0-indexed, 0–15). Defaults to 0.
90+
91+
Returns:
92+
A ``mido.Message`` of type ``program_change``.
93+
"""
94+
95+
return mido.Message('program_change', channel=channel, program=program)

0 commit comments

Comments
 (0)