Skip to content

Commit c6699ff

Browse files
author
Simon Holliday
committed
- Add sidechain "duck" helper
1 parent 450bc47 commit c6699ff

2 files changed

Lines changed: 134 additions & 0 deletions

File tree

subsequence/pattern_builder.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import subsequence.chords
66
import subsequence.constants
77
import subsequence.constants.velocity
8+
import subsequence.easing
89
import subsequence.groove
910
import subsequence.intervals
1011
import subsequence.pattern
@@ -895,6 +896,95 @@ def velocity_shape (self, low: int = subsequence.constants.velocity.VELOCITY_SHA
895896
note.velocity = int(low + (high - low) * vdc_value)
896897
return self
897898

899+
def duck_map (
900+
self,
901+
steps: typing.Iterable[int],
902+
floor: float = 0.0,
903+
grid: typing.Optional[int] = None,
904+
) -> typing.List[float]:
905+
906+
"""
907+
Build a per-step velocity multiplier list for sidechain-style ducking.
908+
909+
Returns a list of floats, one per grid step: ``floor`` at each trigger
910+
step in ``steps``, ``1.0`` everywhere else. Pass the result to
911+
``p.data`` for another pattern to read, then apply with
912+
``p.scale_velocities()``.
913+
914+
Parameters:
915+
steps: Grid indices that trigger ducking (e.g. kick hit positions).
916+
floor: Multiplier written at trigger steps. ``0.0`` = full silence,
917+
``1.0`` = no effect. Values in between give partial ducking.
918+
grid: Grid resolution (defaults to ``p.grid``).
919+
920+
Returns:
921+
``List[float]`` of length ``grid``.
922+
923+
Example::
924+
925+
# Full duck on kick hits
926+
p.data["kick_sc"] = p.duck_map(kick_steps)
927+
928+
# Softer duck
929+
p.data["kick_sc"] = p.duck_map(kick_steps, floor=0.3)
930+
931+
# Velocity-proportional: deeper duck for harder kicks
932+
p.data["kick_sc"] = p.duck_map(kick_steps, floor=1.0 - (velocity / 127))
933+
"""
934+
935+
if grid is None:
936+
grid = self._default_grid
937+
938+
trigger = set(steps)
939+
return [floor if s in trigger else 1.0 for s in range(grid)]
940+
941+
def scale_velocities (
942+
self,
943+
factors: typing.Sequence[float],
944+
grid: typing.Optional[int] = None,
945+
) -> "PatternBuilder":
946+
947+
"""
948+
Scale note velocities by a per-step multiplier list.
949+
950+
Each note's velocity is multiplied by the factor at the corresponding
951+
grid step index. A factor of ``1.0`` leaves the velocity unchanged;
952+
``0.0`` silences the note; ``0.5`` halves it.
953+
954+
Parameters:
955+
factors: Per-step multipliers, one float per grid step.
956+
Values outside ``[0.0, 1.0]`` are valid — result is clamped to
957+
``[0, 127]`` after scaling.
958+
grid: Grid resolution (defaults to ``p.grid``). Must match the
959+
length of ``factors``.
960+
961+
Returns:
962+
``self`` for fluent chaining.
963+
964+
Example::
965+
966+
# Sidechain ducking: silence bass on kick steps, full volume elsewhere.
967+
kick_steps = {0, 4, 8, 12}
968+
p.data["kick_sc"] = [0.0 if s in kick_steps else 1.0 for s in range(p.grid)]
969+
970+
# In the bass pattern:
971+
p.scale_velocities(p.data.get("kick_sc", [1.0] * p.grid))
972+
"""
973+
974+
if grid is None:
975+
grid = self._default_grid
976+
977+
step_duration = self._pattern.length / grid
978+
pulses_per_step = step_duration * subsequence.constants.MIDI_QUARTER_NOTE
979+
980+
for pulse, step in self._pattern.steps.items():
981+
idx = int(round(pulse / pulses_per_step))
982+
if 0 <= idx < len(factors):
983+
for note in step.notes:
984+
note.velocity = max(0, min(127, int(note.velocity * factors[idx])))
985+
986+
return self
987+
898988
def randomize (
899989
self,
900990
timing: float = 0.03,

tests/test_pattern_builder.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,50 @@ def test_fill_invalid_step_raises () -> None:
120120
builder.fill(60, spacing=-1)
121121

122122

123+
def test_duck_map_builds_multiplier_list () -> None:
124+
125+
"""
126+
duck_map should return floor at trigger steps and 1.0 elsewhere.
127+
"""
128+
129+
_, builder = _make_builder(default_grid=4)
130+
131+
result = builder.duck_map(steps=[0, 2], floor=0.0)
132+
133+
assert result == [0.0, 1.0, 0.0, 1.0]
134+
135+
136+
def test_duck_map_partial_floor () -> None:
137+
138+
"""
139+
duck_map should write the given floor value, not just 0.0.
140+
"""
141+
142+
_, builder = _make_builder(default_grid=4)
143+
144+
result = builder.duck_map(steps=[1], floor=0.5)
145+
146+
assert result == [1.0, 0.5, 1.0, 1.0]
147+
148+
149+
def test_scale_velocities_applies_factors () -> None:
150+
151+
"""
152+
scale_velocities should multiply each note's velocity by the per-step factor.
153+
"""
154+
155+
pattern, builder = _make_builder(default_grid=4, length=4)
156+
157+
builder.sequence(steps=[0, 1, 2, 3], pitches=60, velocities=100, durations=0.1)
158+
builder.scale_velocities([0.0, 0.5, 1.0, 1.0])
159+
160+
pps = subsequence.constants.MIDI_QUARTER_NOTE
161+
162+
assert pattern.steps[0].notes[0].velocity == 0
163+
assert pattern.steps[pps].notes[0].velocity == 50
164+
assert pattern.steps[pps * 2].notes[0].velocity == 100
165+
166+
123167
def test_chord_places_all_tones () -> None:
124168

125169
"""

0 commit comments

Comments
 (0)