"""Mixin class providing MIDI and OSC control-message methods for PatternBuilder.
This module is not intended to be used directly. ``PatternMidiMixin``
is inherited by ``PatternBuilder`` in ``pattern_builder.py``.
"""
import typing
import pymididefs.cc
import pymididefs.rpn
import subsequence.constants
import subsequence.easing
import subsequence.pattern
[docs]
class PatternMidiMixin:
"""MIDI control, OSC, and note-correlated pitch bend methods for PatternBuilder.
All methods here operate on ``self._pattern`` (a ``Pattern`` instance),
which is set by ``PatternBuilder.__init__``.
"""
# ── Instance attributes provided by PatternBuilder at runtime ────────
_pattern: subsequence.pattern.Pattern
_default_grid: int
_cc_name_map: typing.Optional[typing.Dict[str, int]]
_nrpn_name_map: typing.Optional[typing.Dict[str, int]]
if typing.TYPE_CHECKING:
import subsequence.pattern_builder # noqa: F401 — type-checking only
def _resolve_cc (self, control: typing.Union[int, str]) -> int: ...
def _resolve_nrpn (self, parameter: typing.Union[int, str]) -> int: ...
def _resolve_rpn (self, parameter: typing.Union[int, str]) -> int: ...
# ── Shared ramp helper ──────────────────────────────────────────────────
def _ramp_pulses (
self,
beat_start: float,
beat_end: float,
start: float,
end: float,
shape: typing.Union[str, subsequence.easing.EasingFn],
resolution: int,
event_fn: typing.Callable[[int, float], None],
) -> None:
"""Walk from beat_start to beat_end, calling event_fn(pulse, value) at each step.
Shared inner loop for ``cc_ramp()``, ``pitch_bend_ramp()``, and ``osc_ramp()``.
``event_fn`` receives the pulse position and the linearly-interpolated (then
eased) value, and is responsible for creating and appending the event.
"""
pulse_start = int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE)
pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE)
span = pulse_end - pulse_start
if span <= 0:
return
if resolution < 1:
raise ValueError("resolution must be at least 1 pulse")
easing_fn = subsequence.easing.get_easing(shape)
pulse = pulse_start
while pulse <= pulse_end:
t = (pulse - pulse_start) / span
eased_t = easing_fn(t)
interpolated = start + (end - start) * eased_t
event_fn(pulse, interpolated)
pulse += resolution
# ── CC messages ─────────────────────────────────────────────────────────
[docs]
def cc (self, control: typing.Union[int, str], value: int, beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder":
"""
Send a single CC message at a beat position.
Parameters:
control: MIDI CC number (0–127), or a string name resolved
via the pattern's ``cc_name_map``.
value: CC value (0–127); out-of-range values are clamped.
beat: Beat position within the pattern.
"""
cc_num: int = self._resolve_cc(control)
pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
# Clamp to the 7-bit CC range like every sibling (cc_ramp / program_change
# / pitch_bend) so a computed out-of-range value is corrected here rather
# than silently dropped and logged when mido rejects it at dispatch time.
clamped_value = max(0, min(127, int(round(value))))
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'control_change',
control = cc_num,
value = clamped_value
)
)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
[docs]
def cc_ramp (
self,
control: typing.Union[int, str],
start: int,
end: int,
beat_start: float = 0.0,
beat_end: typing.Optional[float] = None,
resolution: int = 1,
shape: typing.Union[str, subsequence.easing.EasingFn] = "linear"
) -> "subsequence.pattern_builder.PatternBuilder":
"""
Interpolate a CC value over a beat range.
Parameters:
control: MIDI CC number (0–127), or a string name resolved
via the pattern's ``cc_name_map``.
start: Starting CC value (0–127).
end: Ending CC value (0–127).
beat_start: Beat position to begin the ramp.
beat_end: Beat position to end the ramp. Defaults to pattern length.
resolution: Pulses between CC messages (1 = every pulse, ~20ms at 120 BPM).
Higher values (e.g. 2 or 4) reduce MIDI traffic density but may sound
stepped at slow tempos.
shape: Easing curve — a name string (e.g. ``"exponential"``) or any
callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``.
See :mod:`subsequence.easing` for available shapes.
"""
cc_num: int = self._resolve_cc(control)
if beat_end is None:
beat_end = self._pattern.length
def _event (pulse: int, val: float) -> None:
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'control_change',
control = cc_num,
value = max(0, min(127, int(round(val))))
)
)
self._ramp_pulses(beat_start, beat_end, float(start), float(end), shape, resolution, _event)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
# ── Pitch bend ──────────────────────────────────────────────────────────
[docs]
def pitch_bend (self, value: float, beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder":
"""
Send a single pitch bend message at a beat position.
Parameters:
value: Pitch bend amount, normalised from -1.0 to 1.0.
beat: Beat position within the pattern.
"""
midi_value = max(-8192, min(8191, int(round(value * 8192))))
pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'pitchwheel',
value = midi_value
)
)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
[docs]
def pitch_bend_ramp (
self,
start: float,
end: float,
beat_start: float = 0.0,
beat_end: typing.Optional[float] = None,
resolution: int = 1,
shape: typing.Union[str, subsequence.easing.EasingFn] = "linear"
) -> "subsequence.pattern_builder.PatternBuilder":
"""
Interpolate pitch bend over a beat range.
Parameters:
start: Starting pitch bend (-1.0 to 1.0).
end: Ending pitch bend (-1.0 to 1.0).
beat_start: Beat position to begin the ramp.
beat_end: Beat position to end the ramp. Defaults to pattern length.
resolution: Pulses between pitch bend messages (1 = every pulse).
Higher values (e.g. 2 or 4) reduce MIDI traffic density but may sound
stepped at slow tempos.
shape: Easing curve — a name string (e.g. ``"ease_out"``) or any
callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``.
See :mod:`subsequence.easing` for available shapes.
"""
if beat_end is None:
beat_end = self._pattern.length
def _event (pulse: int, val: float) -> None:
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'pitchwheel',
value = max(-8192, min(8191, int(round(val * 8192))))
)
)
self._ramp_pulses(beat_start, beat_end, start, end, shape, resolution, _event)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
# ── RPN / NRPN parameter control ────────────────────────────────────────
# RPN (Registered) and NRPN (Non-Registered) Parameter Numbers are the
# standard MIDI conventions for addressing parameters beyond the 128 CC
# slots, with optional 14-bit value precision. Both are sequences of
# regular control_change messages co-scheduled at the same pulse:
# CC 99 / 98 NRPN parameter MSB / LSB (or 101 / 100 for RPN)
# CC 6 / 38 Data Entry MSB / LSB
# CC 101=127, 100=127 NULL — defensive deselect
# The Sequencer's MidiEvent.sequence tie-breaker preserves emission order
# at the same pulse, so the synth assigns the value to the right parameter.
def _append_param_select (self, pulse: int, parameter: int, msb_cc: int, lsb_cc: int) -> None:
"""Emit the two-CC parameter-select pair (NRPN: 99/98, RPN: 101/100).
Events are emitted on the pattern's channel — leaving ``CcEvent.channel``
unset (None) lets the sequencer fall through to ``pattern.channel``
at dispatch time, which is the normal behaviour for every other CC
method on this mixin.
"""
param_msb, param_lsb = pymididefs.cc.pack_14bit(parameter)
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'control_change',
control = msb_cc,
value = param_msb,
)
)
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'control_change',
control = lsb_cc,
value = param_lsb,
)
)
def _append_data_entry (self, pulse: int, value: int, fine: bool) -> None:
"""Emit Data Entry MSB (and LSB if fine=True) for a parameter value."""
if fine:
value_msb, value_lsb = pymididefs.cc.pack_14bit(value)
else:
if not 0 <= value <= 127:
raise ValueError(f"NRPN/RPN value must be 0–127 when fine=False, got {value}")
value_msb = value
value_lsb = None
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'control_change',
control = pymididefs.cc.DATA_ENTRY_MSB,
value = value_msb,
)
)
if value_lsb is not None:
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'control_change',
control = pymididefs.cc.DATA_ENTRY_LSB,
value = value_lsb,
)
)
def _validate_ramp_endpoints (self, start: int, end: int, fine: bool) -> None:
"""Reject out-of-range NRPN/RPN ramp endpoints up front.
Mirrors the strict behaviour of ``_append_data_entry`` for one-shots
so a typo (e.g. forgetting ``fine=True`` with a 14-bit value) raises
immediately rather than silently clamping at every ramp step.
"""
limit = 16383 if fine else 127
for label, value in (("start", start), ("end", end)):
if not 0 <= value <= limit:
raise ValueError(f"NRPN/RPN ramp {label} must be 0–{limit} (fine={fine}), got {value}")
def _append_null_reset (self, pulse: int) -> None:
"""Emit the RPN NULL sentinel (CC 101=127, CC 100=127) to deselect.
Defensive practice: prevents a stray later CC 6 / 38 from being
applied to whichever parameter was last selected on this channel.
"""
null_msb, null_lsb = pymididefs.cc.pack_14bit(pymididefs.rpn.NULL_PARAMETER)
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'control_change',
control = pymididefs.cc.RPN_MSB,
value = null_msb,
)
)
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'control_change',
control = pymididefs.cc.RPN_LSB,
value = null_lsb,
)
)
[docs]
def nrpn (
self,
parameter: typing.Union[int, str],
value: int,
beat: float = 0.0,
fine: bool = False,
null_reset: bool = True,
) -> "subsequence.pattern_builder.PatternBuilder":
"""
Send a single NRPN parameter write at a beat position.
NRPN (Non-Registered Parameter Number) addresses synth-specific
parameters that don't fit into the 128 standard CC slots — Sequential,
Korg, Roland, Elektron and others use it heavily for filter cutoff,
envelope amounts, oscillator detune, and similar deep parameters.
Many such parameters need values beyond 0–127 (e.g. 0–1023, 0–254);
set ``fine=True`` for full 14-bit precision.
Emitted on the pattern's MIDI channel. To target a different channel
(e.g. a per-channel RPN config), define a separate pattern on that
channel or use ``composition.trigger(channel=…)`` for a one-shot.
Parameters:
parameter: 14-bit NRPN parameter number (0–16383), or a string
resolved via the pattern's ``nrpn_name_map``.
value: Parameter value. 0–127 if ``fine=False``; 0–16383 if
``fine=True``.
beat: Beat position within the pattern.
fine: If True, send 14-bit value via Data Entry MSB+LSB
(CC 6 + CC 38). If False (default), send only Data Entry
MSB — sufficient for the common 0–127 range.
null_reset: If True (default), follow with the RPN null sentinel
to deselect the active parameter and prevent stray later
CC 6 / 38 messages from hitting it.
Example:
```python
# Sequential Take 5 fine-tune (14-bit, range 0–1400)
p.nrpn(9, 700, fine=True)
# Roland JV-1080 reverb level (7-bit)
p.nrpn(0x0140, 80)
```
"""
param = self._resolve_nrpn(parameter)
pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
self._append_param_select(pulse, param, pymididefs.cc.NRPN_MSB, pymididefs.cc.NRPN_LSB)
self._append_data_entry(pulse, value, fine)
if null_reset:
self._append_null_reset(pulse)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
[docs]
def rpn (
self,
parameter: typing.Union[int, str],
value: int,
beat: float = 0.0,
fine: bool = False,
null_reset: bool = True,
) -> "subsequence.pattern_builder.PatternBuilder":
"""
Send a single RPN parameter write at a beat position.
RPN (Registered Parameter Number) addresses the small standardised
set of parameters defined by the MIDI specification — pitch bend
range, master tuning, modulation depth — supported by virtually any
MIDI synth. String names resolve via ``pymididefs.rpn.RPN_MAP``
out of the box, no map needed.
Standard RPN names: ``pitch_bend_sensitivity``,
``channel_fine_tuning``, ``channel_coarse_tuning``,
``tuning_program_select``, ``tuning_bank_select``,
``modulation_depth_range``.
Emitted on the pattern's MIDI channel.
Parameters:
parameter: 14-bit RPN parameter number (0–16383), or one of the
standard string names above.
value: Parameter value. 0–127 if ``fine=False``; 0–16383 if
``fine=True``. Pitch bend sensitivity uses MSB = semitones
and LSB = cents, so set ``fine=True`` for sub-semitone control.
beat: Beat position within the pattern.
fine: If True, send 14-bit value via Data Entry MSB+LSB.
null_reset: If True (default), follow with the RPN null sentinel.
Example:
```python
# Set pitch bend range to ±12 semitones
p.rpn("pitch_bend_sensitivity", 12)
# 4 semitones plus 50 cents
p.rpn("pitch_bend_sensitivity", 4 * 128 + 50, fine=True)
```
"""
param = self._resolve_rpn(parameter)
pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
self._append_param_select(pulse, param, pymididefs.cc.RPN_MSB, pymididefs.cc.RPN_LSB)
self._append_data_entry(pulse, value, fine)
if null_reset:
self._append_null_reset(pulse)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
[docs]
def nrpn_ramp (
self,
parameter: typing.Union[int, str],
start: int,
end: int,
beat_start: float = 0.0,
beat_end: typing.Optional[float] = None,
resolution: int = 4,
shape: typing.Union[str, subsequence.easing.EasingFn] = "linear",
fine: bool = True,
null_reset: bool = True,
) -> "subsequence.pattern_builder.PatternBuilder":
"""
Interpolate an NRPN value over a beat range.
The parameter is selected once at ``beat_start``; subsequent steps
emit only Data Entry messages. Synths track the most recently
selected NRPN per the spec, so re-selecting per step would just
waste bandwidth. If ``null_reset=True`` the RPN null sentinel is
appended once at ``beat_end``.
**Mid-ramp parameter persistence:** between ``beat_start`` and
``beat_end`` the synth still has this NRPN selected. Avoid issuing
``p.cc(6, …)`` or ``p.cc(38, …)`` on the same channel during the
ramp window — they would land on the ramped parameter rather than
acting as plain data-entry CCs.
Bandwidth note: with ``fine=True`` (default) every step emits two
CCs. Default ``resolution=4`` is one update every four pulses
(~83 ms at 120 BPM, where one pulse is ~21 ms), which keeps the bus
lightly loaded. Increase
``resolution`` (e.g. ``8``) on slow DIN-MIDI links if you hear
other messages getting delayed.
Emitted on the pattern's MIDI channel.
Parameters:
parameter: 14-bit NRPN parameter number, or a string resolved
via the pattern's ``nrpn_name_map``.
start: Starting value (0–16383 when ``fine=True``, 0–127 when False).
end: Ending value.
beat_start: Beat position to begin the ramp.
beat_end: Beat position to end the ramp. Defaults to pattern length.
resolution: Pulses between Data Entry messages (default 4).
shape: Easing curve — string name or callable [0, 1] → [0, 1].
fine: If True (default), use full 14-bit Data Entry MSB+LSB.
null_reset: If True (default), append the null sentinel at the
end of the ramp (not per step).
"""
param = self._resolve_nrpn(parameter)
self._validate_ramp_endpoints(start, end, fine)
if beat_end is None:
beat_end = self._pattern.length
pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE)
self._append_param_select(int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE), param, pymididefs.cc.NRPN_MSB, pymididefs.cc.NRPN_LSB)
def _event (pulse: int, val: float) -> None:
# Clamp guards against custom easing callables that overshoot [0, 1].
if fine:
value = max(0, min(16383, int(round(val))))
else:
value = max(0, min(127, int(round(val))))
self._append_data_entry(pulse, value, fine)
self._ramp_pulses(beat_start, beat_end, float(start), float(end), shape, resolution, _event)
if null_reset:
self._append_null_reset(pulse_end)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
[docs]
def rpn_ramp (
self,
parameter: typing.Union[int, str],
start: int,
end: int,
beat_start: float = 0.0,
beat_end: typing.Optional[float] = None,
resolution: int = 4,
shape: typing.Union[str, subsequence.easing.EasingFn] = "linear",
fine: bool = True,
null_reset: bool = True,
) -> "subsequence.pattern_builder.PatternBuilder":
"""Interpolate an RPN value over a beat range.
Identical to :meth:`nrpn_ramp` but uses CC 101 / 100 for parameter
selection. String names resolve via ``pymididefs.rpn.RPN_MAP``.
The same mid-ramp persistence note applies: avoid plain ``p.cc(6, …)``
on this channel during the ramp window.
"""
param = self._resolve_rpn(parameter)
self._validate_ramp_endpoints(start, end, fine)
if beat_end is None:
beat_end = self._pattern.length
pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE)
self._append_param_select(int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE), param, pymididefs.cc.RPN_MSB, pymididefs.cc.RPN_LSB)
def _event (pulse: int, val: float) -> None:
# Clamp guards against custom easing callables that overshoot [0, 1].
if fine:
value = max(0, min(16383, int(round(val))))
else:
value = max(0, min(127, int(round(val))))
self._append_data_entry(pulse, value, fine)
self._ramp_pulses(beat_start, beat_end, float(start), float(end), shape, resolution, _event)
if null_reset:
self._append_null_reset(pulse_end)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
# ── Program change and SysEx ─────────────────────────────────────────────
[docs]
def program_change (
self,
program: int,
beat: float = 0.0,
bank_msb: typing.Optional[int] = None,
bank_lsb: typing.Optional[int] = None,
) -> "subsequence.pattern_builder.PatternBuilder":
"""Send a Program Change message, optionally preceded by bank select.
Switches the instrument patch on this pattern's MIDI channel.
Program numbers follow the General MIDI numbering (0–127, where
e.g. 0 = Acoustic Grand Piano, 40 = Violin, 33 = Electric Bass).
To select a patch in a specific bank, provide ``bank_msb`` and/or
``bank_lsb``. The bank select CC messages (CC 0 for MSB, CC 32 for
LSB) are sent at the same beat position immediately before the
program change, in the order the synthesizer expects.
Parameters:
program: Program (patch) number (0–127).
beat: Beat position within the pattern (default 0.0).
bank_msb: Bank select coarse (CC 0), 0–127. ``None`` = omit.
bank_lsb: Bank select fine (CC 32), 0–127. ``None`` = omit.
Example:
```python
@composition.pattern(channel=1, beats=4)
def strings (p):
# GM — no bank needed
p.program_change(48)
# Roland JV-1080 bank 1, patch 48
p.program_change(48, bank_msb=81, bank_lsb=0)
# Change patch only at the first bar of each section
if p.section.bar == 0:
p.program_change(48, bank_msb=1)
```
"""
pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
if bank_msb is not None:
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'control_change',
control = 0,
value = max(0, min(127, int(round(bank_msb)))),
)
)
if bank_lsb is not None:
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'control_change',
control = 32,
value = max(0, min(127, int(round(bank_lsb)))),
)
)
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'program_change',
value = max(0, min(127, int(round(program)))),
)
)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
[docs]
def sysex (self, data: typing.Union[bytes, typing.List[int]], beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder":
"""
Send a System Exclusive (SysEx) message at a beat position.
SysEx messages allow deep integration with synthesizers and other
hardware: patch dumps, parameter control, and vendor-specific commands.
The ``data`` argument should contain only the inner payload bytes,
without the surrounding ``0xF0`` / ``0xF7`` framing — mido adds those
automatically.
Parameters:
data: SysEx payload as ``bytes`` or a list of integers (0–127).
beat: Beat position within the pattern (default 0.0).
Example:
```python
# GM System On — reset a GM-compatible device to defaults
p.sysex([0x7E, 0x7F, 0x09, 0x01])
```
"""
pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'sysex',
data = bytes(data)
)
)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
# ── OSC messages ─────────────────────────────────────────────────────────
[docs]
def osc (self, address: str, *args: typing.Any, beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder":
"""
Send an OSC message at a beat position.
Requires ``composition.osc()`` to be called before ``composition.play()``.
If no OSC server is configured the event is silently dropped.
Parameters:
address: OSC address path (e.g. ``"/mixer/fader/1"``).
``*args``: OSC arguments — float, int, str, or bytes.
beat: Beat position within the pattern (default 0.0).
Example:
```python
# Enable a chorus effect at beat 2
p.osc("/fx/chorus/enable", 1, beat=2.0)
# Set a mixer pan value immediately
p.osc("/mixer/pan/1", -0.5)
```
"""
pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
self._pattern.osc_events.append(
subsequence.pattern.OscEvent(
pulse = pulse,
address = address,
args = args
)
)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
[docs]
def osc_ramp (
self,
address: str,
start: float,
end: float,
beat_start: float = 0.0,
beat_end: typing.Optional[float] = None,
resolution: int = 4,
shape: typing.Union[str, subsequence.easing.EasingFn] = "linear"
) -> "subsequence.pattern_builder.PatternBuilder":
"""
Interpolate an OSC float value over a beat range.
Generates one OSC message per ``resolution`` pulses, sending the
interpolated value to ``address`` at each step. Useful for smoothly
automating mixer faders, effect parameters, and other continuous controls
on a remote machine.
Requires ``composition.osc()`` to be called before ``composition.play()``.
If no OSC server is configured the events are silently dropped.
Parameters:
address: OSC address path (e.g. ``"/mixer/fader/1"``).
start: Starting float value.
end: Ending float value.
beat_start: Beat position to begin the ramp (default 0.0).
beat_end: Beat position to end the ramp. Defaults to pattern length.
resolution: Pulses between OSC messages (default 4 — approximately
6 messages per beat at 120 BPM, which is smooth for fader
automation while keeping UDP traffic light). Use ``resolution=1``
for pulse-level precision.
shape: Easing curve — a name string (e.g. ``"ease_in"``) or any
callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``.
See :mod:`subsequence.easing` for available shapes.
Example:
```python
# Fade a mixer fader up over 4 beats
p.osc_ramp("/mixer/fader/1", start=0.0, end=1.0)
# Ease in a reverb send over the last 2 beats
p.osc_ramp("/fx/reverb/wet", 0.0, 0.8, beat_start=2, beat_end=4, shape="ease_in")
```
"""
if beat_end is None:
beat_end = self._pattern.length
def _event (pulse: int, val: float) -> None:
self._pattern.osc_events.append(
subsequence.pattern.OscEvent(
pulse = pulse,
address = address,
args = (val,)
)
)
self._ramp_pulses(beat_start, beat_end, start, end, shape, resolution, _event)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
# ── Note-correlated pitch bend ────────────────────────────────────────────
def _generate_bend_events (
self,
start_value: float,
end_value: float,
pulse_start: int,
pulse_end: int,
resolution: int,
shape: typing.Union[str, subsequence.easing.EasingFn],
) -> None:
"""Generate a series of pitchwheel CcEvents between two pulse positions.
This is the shared inner loop used by ``bend()``, ``portamento()``, and
``slide()``. Appends events directly to ``self._pattern.cc_events``.
Parameters:
start_value: Normalised bend at the start of the ramp (-1.0 to 1.0).
end_value: Normalised bend at the end of the ramp (-1.0 to 1.0).
pulse_start: Absolute pulse position to start the ramp.
pulse_end: Absolute pulse position to end the ramp.
resolution: Number of pulses between consecutive events.
shape: Easing curve name or callable.
"""
span = pulse_end - pulse_start
if span <= 0:
return
if resolution < 1:
raise ValueError("resolution must be at least 1 pulse")
easing_fn = subsequence.easing.get_easing(shape)
pulse = pulse_start
while pulse <= pulse_end:
t = (pulse - pulse_start) / span
eased_t = easing_fn(t)
interpolated = start_value + (end_value - start_value) * eased_t
midi_value = max(-8192, min(8191, int(round(interpolated * 8192))))
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = pulse,
message_type = 'pitchwheel',
value = midi_value,
)
)
pulse += resolution
[docs]
def bend (
self,
note: int,
amount: float,
start: float = 0.0,
end: float = 1.0,
shape: typing.Union[str, subsequence.easing.EasingFn] = "linear",
resolution: int = 1,
) -> "subsequence.pattern_builder.PatternBuilder":
"""Bend a specific note by index.
Generates a pitch bend ramp that covers a fraction of the target note's
duration, then resets to 0.0 at the next note's onset. Call this
*after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations are final.
Parameters:
note: Note index (0 = first, -1 = last, etc.).
amount: Target bend normalised to -1.0..1.0 (positive = up).
With a standard ±2-semitone pitch wheel range, 0.5 = 1 semitone.
start: Fraction of the note's duration at which the ramp begins
(0.0 = note onset, default).
end: Fraction of the note's duration at which the ramp ends
(1.0 = note end, default).
shape: Easing curve — a name string (e.g. ``"ease_in"``) or any
callable mapping [0, 1] → [0, 1]. Defaults to ``"linear"``.
resolution: Pulses between pitch bend messages.
Raises:
IndexError: If *note* is out of range for the current pattern.
Example:
```python
p.sequence(steps=[0, 4, 8, 12], pitches=midi_notes.E1)
p.legato(0.95)
# Bend the last note up one semitone (with ±2 st range), easing in
p.bend(note=-1, amount=0.5, shape="ease_in")
# Bend the second note down, starting halfway through
p.bend(note=1, amount=-0.3, start=0.5)
```
"""
if not self._pattern.steps:
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
sorted_positions = sorted(self._pattern.steps.keys())
# Resolve note index (supports negative indexing)
position = sorted_positions[note]
note_idx = note if note >= 0 else len(sorted_positions) + note
# Duration: use the longest note at this step
step = self._pattern.steps[position]
note_duration = max(n.duration for n in step.notes)
# Clamp start/end fractions and compute pulse range for the ramp
start_clamped = max(0.0, min(1.0, start))
end_clamped = max(0.0, min(1.0, end))
bend_start_pulse = position + int(note_duration * start_clamped)
bend_end_pulse = position + int(note_duration * end_clamped)
self._generate_bend_events(0.0, amount, bend_start_pulse, bend_end_pulse, resolution, shape)
# Reset bend at the next note's onset. For the last note that is the
# NEXT cycle's first onset (total + first), not pulse 0 - a bend tail
# spilling past the cycle end was cancelled mid-flight by a pulse-0
# reset, leaving the next cycle's first note bent.
if note_idx < len(sorted_positions) - 1:
reset_pulse = sorted_positions[note_idx + 1]
else:
total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE)
reset_pulse = total_pulses + sorted_positions[0]
reset_midi = max(-8192, min(8191, int(round(0.0 * 8192))))
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = reset_pulse,
message_type = 'pitchwheel',
value = reset_midi,
)
)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
[docs]
def portamento (
self,
time: float = 0.15,
shape: typing.Union[str, subsequence.easing.EasingFn] = "linear",
resolution: int = 1,
bend_range: typing.Optional[float] = 2.0,
wrap: bool = True,
) -> "subsequence.pattern_builder.PatternBuilder":
"""Glide between all consecutive notes using pitch bend.
Generates a pitch bend ramp in the tail of each note, bending toward
the next note's pitch, then resets at the next note's onset. Call this
*after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations are final.
Most effective on mono instruments where pitch bend is per-channel.
Parameters:
time: Fraction of each note's duration used for the glide
(default 0.15 — last 15% of the note).
shape: Easing curve. Defaults to ``"linear"``.
resolution: Pulses between pitch bend messages.
bend_range: Instrument's pitch wheel range in semitones
(default 2.0 — standard ±2 st). Pairs with intervals larger
than this value are skipped. Pass ``None`` to disable range
checking and always generate the bend (large intervals are
clamped to ±1.0).
wrap: If ``True`` (default), glide from the last note toward the
first note of the next cycle.
Example:
```python
p.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43])
p.legato(0.95)
# Gentle glide across all note transitions
p.portamento(time=0.15, shape="ease_in_out")
# Wide bend range (synth set to ±12 semitones)
p.portamento(time=0.2, bend_range=12)
# No range limit — bend as far as MIDI allows
p.portamento(time=0.1, bend_range=None)
```
"""
if not self._pattern.steps:
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
sorted_positions = sorted(self._pattern.steps.keys())
n = len(sorted_positions)
def _lowest_pitch (pos: int) -> int:
return min(note.pitch for note in self._pattern.steps[pos].notes)
def _longest_duration (pos: int) -> int:
return max(note.duration for note in self._pattern.steps[pos].notes)
for i in range(n):
a_pos = sorted_positions[i]
is_last = (i == n - 1)
if is_last:
if not wrap:
continue
b_pos = sorted_positions[0]
else:
b_pos = sorted_positions[i + 1]
interval = _lowest_pitch(b_pos) - _lowest_pitch(a_pos)
if bend_range is not None and abs(interval) > bend_range:
continue
normaliser = bend_range if bend_range is not None else 2.0
amount = max(-1.0, min(1.0, interval / normaliser))
a_duration = _longest_duration(a_pos)
glide_start_pulse = a_pos + int(a_duration * (1.0 - time))
glide_end_pulse = a_pos + a_duration
self._generate_bend_events(0.0, amount, glide_start_pulse, glide_end_pulse, resolution, shape)
# Reset at the destination note's onset. For the wrap-around pair
# that is the NEXT cycle's first onset (total + first), not pulse 0
# - a glide spilling past the cycle end was cancelled mid-flight by
# the pulse-0 reset, leaving the first note fully bent.
if is_last:
total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE)
reset_pulse = total_pulses + sorted_positions[0]
else:
reset_pulse = b_pos
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = reset_pulse,
message_type = 'pitchwheel',
value = 0,
)
)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
[docs]
def slide (
self,
notes: typing.Optional[typing.List[int]] = None,
steps: typing.Optional[typing.List[int]] = None,
time: float = 0.15,
shape: typing.Union[str, subsequence.easing.EasingFn] = "linear",
resolution: int = 1,
bend_range: typing.Optional[float] = 2.0,
wrap: bool = True,
extend: bool = True,
) -> "subsequence.pattern_builder.PatternBuilder":
"""TB-303-style selective slide into specific notes.
Like ``portamento()`` but only applies to flagged destination notes.
Specify target notes by index (``notes=[1, 3]``) or by step grid
position (``steps=[4, 12]``). If ``extend=True`` (default) the
preceding note's duration is extended to meet the slide target, matching
the 303's behaviour where slide notes do not retrigger.
Call this *after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations
are final.
Parameters:
notes: List of note indices to slide *into* (0 = first).
Supports negative indexing. Mutually exclusive with *steps*.
steps: List of step grid indices to slide *into*.
Converted to pulse positions using ``self._default_grid``.
Mutually exclusive with *notes*.
time: Fraction of the preceding note's duration used for the glide.
shape: Easing curve. Defaults to ``"linear"``.
resolution: Pulses between pitch bend messages.
bend_range: Instrument's pitch wheel range in semitones
(default 2.0). Pairs with larger intervals are skipped.
Pass ``None`` to disable range checking.
wrap: If ``True`` (default), include a wrap-around slide from the
last note back toward the first.
extend: If ``True`` (default), extend the preceding note's duration
to reach the slide target's onset — 303-style legato through
the glide.
Raises:
ValueError: If neither *notes* nor *steps* is provided.
Example:
```python
p.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43])
p.legato(0.95)
# Slide into the 2nd and 4th notes
p.slide(notes=[1, 3], time=0.2, shape="ease_in")
# Same using step grid indices
p.slide(steps=[4, 12], time=0.2, shape="ease_in")
# Slide without extending the preceding note
p.slide(notes=[1, 3], extend=False)
```
"""
if notes is None and steps is None:
raise ValueError("slide() requires either 'notes' or 'steps'")
if not self._pattern.steps:
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
sorted_positions = sorted(self._pattern.steps.keys())
total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE)
n = len(sorted_positions)
# Resolve flagged pulse positions
if notes is not None:
flagged: typing.Set[int] = set()
for idx in notes:
flagged.add(sorted_positions[idx])
else:
# steps is not None. Resolve each grid step to the SAME pulse the
# placement methods use — int(step * (length / grid) * PPQ) — so the
# flag lands on the note even when the grid doesn't divide the bar
# evenly. Floored uniform spacing (total_pulses // grid) drifts out of
# alignment on non-divisor grids, silently flagging nothing.
step_beats = self._pattern.length / self._default_grid
flagged = set()
for s in (steps or []):
flagged.add(int(s * step_beats * subsequence.constants.MIDI_QUARTER_NOTE))
def _lowest_pitch (pos: int) -> int:
return min(note.pitch for note in self._pattern.steps[pos].notes)
def _longest_duration (pos: int) -> int:
return max(note.duration for note in self._pattern.steps[pos].notes)
for i in range(n):
a_pos = sorted_positions[i]
is_last = (i == n - 1)
if is_last:
if not wrap:
continue
b_pos = sorted_positions[0]
else:
b_pos = sorted_positions[i + 1]
# Only generate glide if the destination is flagged
if b_pos not in flagged:
continue
interval = _lowest_pitch(b_pos) - _lowest_pitch(a_pos)
if bend_range is not None and abs(interval) > bend_range:
continue
normaliser = bend_range if bend_range is not None else 2.0
amount = max(-1.0, min(1.0, interval / normaliser))
# Optionally extend preceding note to meet the target onset (303 style)
if extend:
if is_last:
gap = (total_pulses - a_pos) + sorted_positions[0]
else:
gap = b_pos - a_pos
for note in self._pattern.steps[a_pos].notes:
note.duration = gap
# Read the duration AFTER any extension so the glide occupies the
# tail of the note as actually played and lands on the target
# onset. (Reading it before the extend block made the bend jump
# near the note's start and then hold flat - the opposite of a
# slide.)
a_duration = _longest_duration(a_pos)
glide_start_pulse = a_pos + int(a_duration * (1.0 - time))
glide_end_pulse = a_pos + a_duration
self._generate_bend_events(0.0, amount, glide_start_pulse, glide_end_pulse, resolution, shape)
# Reset at the destination note's onset. For the wrap-around pair
# the destination is the NEXT cycle's first onset (total_pulses +
# first onset): resetting at pulse 0 fired while a spilled glide
# was still in flight, so the destination note played fully bent.
reset_pulse = b_pos if not is_last else total_pulses + sorted_positions[0]
self._pattern.cc_events.append(
subsequence.pattern.CcEvent(
pulse = reset_pulse,
message_type = 'pitchwheel',
value = 0,
)
)
return typing.cast("subsequence.pattern_builder.PatternBuilder", self)