Source code for subsequence.groove

"""
Groove templates — repeating timing and velocity feels applied to quantized patterns.

Exports the public Groove class: build one by hand, from a swing percentage,
or from an Ableton ``.agr`` file, then apply it with ``p.groove(template)``.
"""

from __future__ import annotations

import dataclasses
import typing
import xml.etree.ElementTree

import subsequence.constants

if typing.TYPE_CHECKING:
	import subsequence.pattern


@dataclasses.dataclass
[docs] class Groove: """ A timing/velocity template applied to quantized grid positions. A groove is a repeating pattern of per-step timing offsets and optional velocity adjustments aligned to a rhythmic grid. Apply it as a post-build transform with ``p.groove(template)`` to give a pattern its characteristic feel — swing, shuffle, MPC-style pocket, or anything extracted from an Ableton ``.agr`` file. Parameters: offsets: Timing offset per grid slot, in beats. Repeats cyclically. Positive values delay the note; negative values push it earlier. grid: Grid size in beats (0.25 = 16th notes, 0.5 = 8th notes). velocities: Optional velocity scale per grid slot (1.0 = unchanged). Repeats cyclically alongside offsets. Example:: # Ableton-style 57% swing on 16th notes groove = Groove.swing(percent=57) # Custom groove with timing and velocity groove = Groove( grid=0.25, offsets=[0.0, +0.02, 0.0, -0.01], velocities=[1.0, 0.7, 0.9, 0.6], ) """ offsets: typing.List[float] grid: float = 0.25 velocities: typing.Optional[typing.List[float]] = None def __post_init__ (self) -> None: if not self.offsets: raise ValueError("offsets must not be empty") if self.grid <= 0: raise ValueError("grid must be positive") if self.velocities is not None and not self.velocities: raise ValueError("velocities must not be empty (use None for no velocity adjustment)") @staticmethod
[docs] def swing (percent: float = 57.0, grid: float = 0.25) -> "Groove": """ Create a swing groove from a percentage. 50% is straight (no swing). 67% is approximately triplet swing. 57% is a moderate shuffle — the Ableton default. Parameters: percent: Swing amount (50–75 is the useful range). grid: Grid size in beats (0.25 = 16ths, 0.5 = 8ths). """ if percent < 50.0 or percent > 99.0: raise ValueError("swing percent must be between 50 and 99") pair_duration = grid * 2 offset = (percent / 100.0 - 0.5) * pair_duration return Groove(offsets=[0.0, offset], grid=grid)
@staticmethod
[docs] def from_agr (path: str) -> "Groove": """ Import timing and velocity data from an Ableton .agr groove file. An ``.agr`` file is an XML document containing a MIDI clip whose note positions encode the groove's rhythmic feel. This method reads those note start times and velocities and converts them into the ``Groove`` dataclass format (per-step offsets and velocity scales). **What is extracted:** - ``Time`` attribute of each ``MidiNoteEvent`` → timing offsets relative to ideal grid positions. - ``Velocity`` attribute of each ``MidiNoteEvent`` → velocity scaling (normalised to the highest velocity in the file). - ``TimingAmount`` from the Groove element → pre-scales the timing offsets (100 = full, 70 = 70% of the groove's timing). - ``VelocityAmount`` from the Groove element → pre-scales velocity deviation (100 = full groove velocity, 0 = no velocity changes). The resulting ``Groove`` reflects the file author's intended strength. Use ``strength=`` when applying to further adjust. **What is NOT imported:** ``RandomAmount`` (use ``p.randomize()`` separately for random jitter) and ``QuantizationAmount`` (not applicable - Subsequence notes are already grid-quantized by construction). Other ``MidiNoteEvent`` fields (``Duration``, ``VelocityDeviation``, ``OffVelocity``, ``Probability``) are also ignored. Parameters: path: Path to the .agr file. """ tree = xml.etree.ElementTree.parse(path) root = tree.getroot() # Find the MIDI clip clip = root.find(".//MidiClip") if clip is None: raise ValueError(f"No MidiClip found in {path}") # Get clip length current_end = clip.find("CurrentEnd") if current_end is None: raise ValueError(f"No CurrentEnd found in {path}") clip_length = float(current_end.get("Value", "4")) # Read Groove Pool blend parameters groove_elem = root.find(".//Groove") timing_amount = 100.0 velocity_amount = 100.0 if groove_elem is not None: timing_el = groove_elem.find("TimingAmount") if timing_el is not None: timing_amount = float(timing_el.get("Value", "100")) velocity_el = groove_elem.find("VelocityAmount") if velocity_el is not None: velocity_amount = float(velocity_el.get("Value", "100")) timing_scale = timing_amount / 100.0 velocity_scale = velocity_amount / 100.0 # Extract note events sorted by time events = clip.findall(".//MidiNoteEvent") if not events: raise ValueError(f"No MidiNoteEvent elements found in {path}") times: typing.List[float] = [] velocities_raw: typing.List[float] = [] for event in events: times.append(float(event.get("Time", "0"))) velocities_raw.append(float(event.get("Velocity", "127"))) # Sort as PAIRS - sorting times alone desynced each offset from its # note's velocity whenever the XML listed events out of time order. paired = sorted(zip(times, velocities_raw)) times = [t for t, _ in paired] velocities_raw = [v for _, v in paired] note_count = len(times) # Infer grid from clip length and note count grid = clip_length / note_count # Calculate offsets from ideal grid positions, scaled by TimingAmount offsets: typing.List[float] = [] for i, time in enumerate(times): ideal = i * grid offsets.append((time - ideal) * timing_scale) # Calculate velocity scales (relative to max velocity in the file), # blended toward 1.0 by VelocityAmount max_vel = max(velocities_raw) has_velocity_variation = any(v != max_vel for v in velocities_raw) groove_velocities: typing.Optional[typing.List[float]] = None if has_velocity_variation and max_vel > 0: raw_scales = [v / max_vel for v in velocities_raw] # velocity_scale=1.0 → full groove velocity; 0.0 → all 1.0 (no change) groove_velocities = [1.0 + (s - 1.0) * velocity_scale for s in raw_scales] # If blending has removed all variation, set to None if all(abs(v - 1.0) < 1e-9 for v in groove_velocities): groove_velocities = None return Groove(offsets=offsets, grid=grid, velocities=groove_velocities)
[docs] def apply_groove ( steps: typing.Dict[int, "subsequence.pattern.Step"], groove: Groove, pulses_per_quarter: int = subsequence.constants.MIDI_QUARTER_NOTE, strength: float = 1.0, ) -> typing.Dict[int, "subsequence.pattern.Step"]: """ Apply a groove template to a step dictionary keyed by pulse positions. Notes close to a grid position are shifted by the groove's offset for that slot. Notes between grid positions are left untouched. Parameters: steps: Step dictionary (pulse → Step). groove: The groove template to apply. pulses_per_quarter: Internal MIDI clock resolution (default 24). strength: How much of the groove to apply (0.0–1.0). 0.0 leaves all timing and velocity unchanged; 1.0 applies the full groove. Intermediate values blend between the two, equivalent to Ableton’s TimingAmount / VelocityAmount dials. """ if not 0.0 <= strength <= 1.0: raise ValueError("strength must be between 0.0 and 1.0") grid_pulses = groove.grid * pulses_per_quarter if grid_pulses <= 0: return dict(steps) half_grid = grid_pulses / 2.0 num_offsets = len(groove.offsets) num_velocities = len(groove.velocities) if groove.velocities else 0 new_steps: typing.Dict[int, subsequence.pattern.Step] = {} for old_pulse, step in steps.items(): # Find nearest grid position grid_index = round(old_pulse / grid_pulses) ideal_pulse = grid_index * grid_pulses # Only groove notes that sit close to a grid position; notes deliberately # placed between grid lines (flams, pushes) keep both their timing AND # velocity. The window is ±25% of a cell (half_grid * 0.5) — narrow on # purpose, so off-grid expression survives a quantised groove. if abs(old_pulse - ideal_pulse) > half_grid * 0.5: new_pulse = old_pulse else: slot = grid_index % num_offsets # Blend from the note's OWN pulse toward the groove target so # strength=0.0 truly leaves timing untouched. (Blending from # ideal_pulse quantised away in-window micro-timing — e.g. from # randomize() — at every strength, including 0.) groove_target = ideal_pulse + groove.offsets[slot] * pulses_per_quarter new_pulse = int(round(old_pulse + (groove_target - old_pulse) * strength)) new_pulse = max(0, new_pulse) # Velocity scaling applies only to grooved (on-grid) notes, for the # same reason — an off-grid note shouldn't pick up a slot's accent. if groove.velocities and num_velocities > 0: vel_slot = grid_index % num_velocities # Blend between 1.0 (no effect) and the groove's scale (full effect) vel_scale = 1.0 + (groove.velocities[vel_slot] - 1.0) * strength step = _scale_step_velocity(step, vel_scale) if new_pulse not in new_steps: new_steps[new_pulse] = subsequence.pattern.Step() new_steps[new_pulse].notes.extend(step.notes) return new_steps
def _scale_step_velocity (step: "subsequence.pattern.Step", scale: float) -> "subsequence.pattern.Step": """ Return a copy of the step with scaled velocities. """ import subsequence.pattern new_notes = [] for note in step.notes: new_notes.append(dataclasses.replace( note, velocity=max(1, min(127, int(round(note.velocity * scale)))) )) return subsequence.pattern.Step(notes=new_notes)