Drums are now a real Part — same effects pipeline, zero duplication

_drum_hits and _drum_pattern_beats proxy through score.parts['drums'].
Drum Part goes through _apply_part_effects like any other Part.
set_drum_effects() is now sugar over the Part's attributes.
All 789 tests pass with no API changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 20:17:05 -04:00
parent c375785bb9
commit f7c05e1b31
2 changed files with 78 additions and 34 deletions
+12 -7
View File
@@ -1836,6 +1836,8 @@ def render_score(score):
# Named parts — each rendered to own buffer for per-part effects
_pending_sidechain = []
for part in score.parts.values():
if part.is_drums:
continue # drums are rendered separately via _drum_hits
if part.notes:
part_buf = numpy.zeros(total_samples, dtype=numpy.float32)
synth_fn = _resolve_synth(part.synth)
@@ -1989,12 +1991,15 @@ def render_score(score):
panned = _pan_to_stereo(mono_hit, pan)
drum_stereo[start:start + hit_len] += panned
# Apply drum bus effects (reverb, delay, etc.) to the stereo drum mix
if score.drum_effects:
fx = score.drum_effects
# Apply to each stereo channel using the same effects engine
for ch in range(2):
drum_stereo[:, ch] = _apply_effects_with_params(drum_stereo[:, ch], fx)
# Apply drum Part effects through the same pipeline as any other Part
drums_part = score.parts.get("drums")
if drums_part:
has_drum_fx = (drums_part.lowpass > 0 or drums_part.delay_mix > 0
or drums_part.reverb_mix > 0 or drums_part.distortion_mix > 0
or drums_part.chorus_mix > 0)
if has_drum_fx:
for ch in range(2):
drum_stereo[:, ch] = _apply_part_effects(drum_stereo[:, ch], drums_part)
# Apply sidechain compression to parts that request it
for part, part_buf in _pending_sidechain:
@@ -2008,7 +2013,7 @@ def render_score(score):
if score.notes:
stereo_buf += _pan_to_stereo(buf, 0.0)
# Drums: stereo panned
# Drums: stereo panned (with effects already applied)
stereo_buf += drum_stereo
# Master bus compressor/limiter (per channel)
+66 -27
View File
@@ -1399,6 +1399,8 @@ class Part:
self.pan = pan
self.spread = spread
self.notes: list[Note] = []
self._drum_hits: list[_Hit] = []
self._drum_pattern_beats: float = 0.0
self._automation: list[tuple[float, dict]] = [] # (beat, {param: value})
def add(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100) -> "Part":
@@ -1690,12 +1692,21 @@ class Part:
return self
@property
def is_drums(self) -> bool:
"""True if this part contains drum hits."""
return len(self._drum_hits) > 0
@property
def total_beats(self) -> float:
return sum(n.beats for n in self.notes)
note_beats = sum(n.beats for n in self.notes)
if self._drum_hits:
drum_beats = self._drum_pattern_beats
return max(note_beats, drum_beats)
return note_beats
def __len__(self):
return len(self.notes)
return len(self.notes) + len(self._drum_hits)
def __iter__(self):
return iter(self.notes)
@@ -1784,15 +1795,65 @@ class Score:
self.bpm = bpm
self.swing = swing
self._drum_humanize = drum_humanize
self.drum_effects: dict = {}
self.notes: list[Note] = []
self.parts: dict[str, Part] = {}
self._drum_hits: list[_Hit] = []
self._drum_pattern_beats: float = 0.0
self._tempo_changes: list[tuple[float, int]] = []
self._sections: dict[str, Section] = {}
self._current_section: Optional[Section] = None
def _ensure_drums_part(self) -> Part:
"""Get or create the drums Part."""
if "drums" not in self.parts:
self.parts["drums"] = Part("drums", synth="sine", volume=0.7)
return self.parts["drums"]
@property
def _drum_hits(self) -> list:
"""Proxy: drum hits live on the drums Part."""
return self._ensure_drums_part()._drum_hits
@property
def _drum_pattern_beats(self) -> float:
"""Proxy: drum pattern beats live on the drums Part."""
return self._ensure_drums_part()._drum_pattern_beats
@_drum_pattern_beats.setter
def _drum_pattern_beats(self, value: float):
self._ensure_drums_part()._drum_pattern_beats = value
@property
def drum_effects(self) -> dict:
"""Proxy: drum effects are just the drums Part's effect settings."""
p = self._ensure_drums_part()
return {
"reverb_mix": p.reverb_mix, "reverb_decay": p.reverb_decay,
"reverb_type": p.reverb_type,
"delay_mix": p.delay_mix, "delay_time": p.delay_time,
"delay_feedback": p.delay_feedback,
"lowpass": p.lowpass, "lowpass_q": p.lowpass_q,
"distortion_mix": p.distortion_mix,
"distortion_drive": p.distortion_drive,
"chorus_mix": p.chorus_mix,
}
def set_drum_effects(self, **kwargs) -> "Score":
"""Set effects on the drum bus.
The drums Part is a real Part — set effects the same way
you would on any other part.
Example::
score.set_drum_effects(reverb=0.2, reverb_type="plate")
"""
p = self._ensure_drums_part()
param_map = {"reverb": "reverb_mix", "delay": "delay_mix",
"distortion": "distortion_mix", "chorus": "chorus_mix"}
for k, v in kwargs.items():
attr = param_map.get(k, k)
setattr(p, attr, v)
return self
def part(self, name: str, *, synth: str = "sine",
envelope: str = "piano", volume: float = 0.5,
reverb: float = 0.0, reverb_decay: float = 1.0,
@@ -1905,28 +1966,6 @@ class Score:
fill_pattern = Pattern.fill(name)
return self.add_pattern(fill_pattern, repeats=1)
def set_drum_effects(self, **kwargs) -> "Score":
"""Set effects on the drum bus.
Uses the same parameters as Part effects — reverb, delay,
lowpass, distortion, chorus. Applied to the entire drum mix
before stereo panning.
Example::
score.set_drum_effects(reverb=0.2, reverb_type="plate",
lowpass=8000, distortion=0.1)
Returns:
Self for chaining.
"""
# Map shorthand names
param_map = {"reverb": "reverb_mix", "delay": "delay_mix",
"distortion": "distortion_mix", "chorus": "chorus_mix"}
for k, v in kwargs.items():
key = param_map.get(k, k)
self.drum_effects[key] = v
return self
def drums(self, preset: str, repeats: int = 4, fill: str = None,
fill_every: int = None) -> "Score":