Add Part class for multi-voice Score arrangements

- Part: named voice with synth, envelope, and volume settings
- Score.part() creates and registers parts
- Score.add_pattern() for cleaner drum pattern attachment
- render_score() renders all parts + drums into one buffer
- play_score() updated to use the new multi-part renderer
- Backwards compatible: Score.add() still works for simple cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 11:20:26 -04:00
parent 994c4e244a
commit e72ef4a6a7
4 changed files with 338 additions and 49 deletions
+3 -3
View File
@@ -8,12 +8,12 @@ from .scales import TonedScale, Key, PROGRESSIONS
from .chords import Chord, Fretboard, analyze_progression
from .charts import CHARTS, Fingering, charts_for_fretboard
from .rhythm import Duration, TimeSignature, Rest, Score, DrumSound, Pattern
from .rhythm import Duration, TimeSignature, Rest, Score, Part, DrumSound, Pattern
from .rhythm import Note as RhythmNote # rhythm.Note (tone + duration pairing)
try:
from .play import (play, save, save_midi, play_progression, play_pattern,
play_score, Synth, Envelope)
play_score, render_score, Synth, Envelope)
except OSError:
play = None
save = None
@@ -34,6 +34,6 @@ __all__ = [
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
"play", "save", "save_midi", "play_progression", "play_pattern",
"play_score", "Synth", "Envelope",
"Duration", "TimeSignature", "RhythmNote", "Rest", "Score",
"Duration", "TimeSignature", "RhythmNote", "Rest", "Score", "Part",
"DrumSound", "Pattern",
]
+87 -26
View File
@@ -533,43 +533,78 @@ def play_pattern(pattern, repeats=1, bpm=120):
sd.wait()
def play_score(score):
"""Play an entire Score through the speakers.
def _resolve_synth(name):
"""Map synth name string to wave function."""
return {"sine": sine_wave, "saw": sawtooth_wave,
"triangle": triangle_wave}.get(name, sine_wave)
Renders both tonal notes (tones/chords) and drum hits, mixed
together. This is the function to use when you've built a Score
that combines a chord progression with a drum pattern.
def _resolve_envelope(name):
"""Map envelope name string to Envelope enum value tuple."""
_map = {e.name.lower(): e.value for e in Envelope}
return _map.get(name, Envelope.PIANO.value)
def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
synth_fn, envelope_tuple, volume, bpm):
"""Render a list of Notes into an existing buffer at the correct positions."""
a, d, s, r = envelope_tuple
beat_pos = 0.0
for note in notes:
if note.tone is not None:
start = int(beat_pos * samples_per_beat)
dur_ms = note.beats * 60_000 / bpm
n_samples = int(SAMPLE_RATE * dur_ms / 1000)
if start + n_samples > total_samples:
n_samples = total_samples - start
if n_samples > 0:
# Get pitches
if hasattr(note.tone, 'tones'):
waves = [synth_fn(t.pitch(), n_samples=n_samples)
for t in note.tone.tones]
else:
waves = [synth_fn(note.tone.pitch(), n_samples=n_samples)]
mixed = sum(w.astype(numpy.float32) for w in waves) / SAMPLE_PEAK
if a > 0 or d > 0 or s < 1.0 or r > 0:
mixed = _apply_envelope(mixed, a, d, s, r)
end = min(start + len(mixed), total_samples)
buf[start:end] += mixed[:end - start] * volume
beat_pos += note.beats
def render_score(score):
"""Render a Score to a float32 audio buffer.
Mixes all parts (named and default), plus drum hits, into a
single normalized buffer.
Args:
score: A :class:`Score` object with notes and/or drum hits.
score: A :class:`Score` object.
Example::
>>> from pytheory import Pattern, Key, Duration, Score
>>> key = Key("A", "minor")
>>> score = Pattern.preset("bossa nova").to_score(repeats=4, bpm=140)
>>> for chord in key.progression("i", "iv", "V", "i"):
... score.add(chord, Duration.WHOLE)
>>> play_score(score)
Returns:
Float32 numpy array of audio samples.
"""
samples_per_beat = int(SAMPLE_RATE * 60.0 / score.bpm)
total_beats = score.total_beats
total_samples = int(total_beats * samples_per_beat)
buf = numpy.zeros(total_samples, dtype=numpy.float32)
# Render tonal notes
beat_pos = 0.0
for note in score.notes:
if note.tone is not None:
start = int(beat_pos * samples_per_beat)
dur_ms = note.beats * 60_000 / score.bpm
rendered = _render(note.tone, t=dur_ms, envelope=Envelope.PIANO)
rendered_f32 = rendered.astype(numpy.float32) / SAMPLE_PEAK
end = min(start + len(rendered_f32), total_samples)
buf[start:end] += rendered_f32[:end - start] * 0.5
beat_pos += note.beats
# Default notes (backwards-compatible .add() calls)
if score.notes:
_render_notes_to_buf(
score.notes, buf, samples_per_beat, total_samples,
sine_wave, Envelope.PIANO.value, 0.5, score.bpm)
# Render drum hits
# Named parts
for part in score.parts.values():
if part.notes:
synth_fn = _resolve_synth(part.synth)
env_tuple = _resolve_envelope(part.envelope)
_render_notes_to_buf(
part.notes, buf, samples_per_beat, total_samples,
synth_fn, env_tuple, part.volume, score.bpm)
# Drum hits
for hit in score._drum_hits:
start = int(hit.position * samples_per_beat)
if start >= total_samples:
@@ -585,6 +620,32 @@ def play_score(score):
if peak > 0:
buf = buf / peak * 0.9
return buf
def play_score(score):
"""Play an entire Score through the speakers.
Renders drums, default notes, and all named parts — each with
its own synth voice and envelope — mixed into one audio buffer.
Args:
score: A :class:`Score` object with notes, parts, and/or drum hits.
Example::
>>> from pytheory import Pattern, Key, Duration, Score
>>> key = Key("A", "minor")
>>> score = Score("4/4", bpm=140)
>>> score.add_pattern(Pattern.preset("bossa nova"), repeats=4)
>>> chords = score.part("chords", synth="sine", envelope="pad")
>>> lead = score.part("lead", synth="saw", envelope="pluck")
>>> for chord in key.progression("i", "iv", "V", "i"):
... chords.add(chord, Duration.WHOLE)
>>> lead.add("E5", Duration.QUARTER).add("D5", Duration.QUARTER)
>>> play_score(score)
"""
buf = render_score(score)
sd.play(buf, SAMPLE_RATE)
sd.wait()
+137 -19
View File
@@ -167,12 +167,7 @@ class Pattern:
A Score containing drum hits as MIDI percussion notes.
"""
score = Score(self.time_signature_str, bpm=bpm)
for r in range(repeats):
offset = r * self.beats
for hit in self.hits:
score._drum_hits.append(
_Hit(hit.sound, hit.position + offset, hit.velocity))
score._drum_pattern_beats += self.beats
score.add_pattern(self, repeats=repeats)
return score
# ── Presets ───────────────────────────────────────────────────────
@@ -884,15 +879,87 @@ Pattern._PRESETS["maracatu"] = dict(
)
class Part:
"""A named voice within a Score, with its own synth and envelope.
Parts allow layering multiple instruments — lead, bass, pads, etc. —
each with independent synth settings, mixed together on playback.
Don't instantiate directly — use ``Score.part()`` instead.
Example::
score = Score("4/4", bpm=140)
lead = score.part("lead", synth="saw", envelope="pluck")
lead.add("E5", Duration.QUARTER).add("D5", Duration.EIGHTH)
bass = score.part("bass", synth="triangle", envelope="pluck")
bass.add("A2", Duration.HALF)
"""
def __init__(self, name: str, *, synth: str = "sine",
envelope: str = "piano", volume: float = 0.5):
self.name = name
self.synth = synth
self.envelope = envelope
self.volume = volume
self.notes: list[Note] = []
def add(self, tone_or_string, duration=Duration.QUARTER) -> "Part":
"""Add a note. Accepts Tone/Chord objects or note strings like ``"E5"``.
Returns self for chaining.
"""
if isinstance(tone_or_string, str):
from .tones import Tone
tone_or_string = Tone.from_string(tone_or_string, system="western")
self.notes.append(Note(tone=tone_or_string, duration=duration))
return self
def rest(self, duration=Duration.QUARTER) -> "Part":
"""Add a rest. Returns self for chaining."""
self.notes.append(Note(tone=None, duration=duration))
return self
@property
def total_beats(self) -> float:
return sum(n.beats for n in self.notes)
def __len__(self):
return len(self.notes)
def __iter__(self):
return iter(self.notes)
def __repr__(self):
return (f"<Part {self.name!r} synth={self.synth} "
f"{len(self.notes)} notes {self.total_beats:.1f} beats>")
class Score:
"""A sequence of notes with a time signature and tempo.
"""A multi-part arrangement with drums, chords, and instrument voices.
Usage::
A Score combines:
score = Score("4/4", bpm=120)
score.add(Tone.from_string("C4"), Duration.QUARTER)
score.add(Tone.from_string("E4"), Duration.QUARTER)
score.rest(Duration.HALF)
- **Drum patterns** via ``add_pattern()``
- **Chord/tone notes** via ``add()`` (backwards-compatible default part)
- **Named parts** via ``part()`` — each with its own synth and envelope
Example::
score = Score("4/4", bpm=140)
score.add_pattern(Pattern.preset("bossa nova"), repeats=4)
chords = score.part("chords", synth="sine", envelope="pad")
lead = score.part("lead", synth="saw", envelope="pluck")
bass = score.part("bass", synth="triangle", envelope="pluck")
for chord in key.progression("i", "iv", "V", "i"):
chords.add(chord, Duration.WHOLE)
lead.add("E5", Duration.QUARTER).add("D5", Duration.EIGHTH)
bass.add("A2", Duration.HALF).add("D2", Duration.HALF)
play_score(score)
"""
def __init__(self, time_signature="4/4", bpm=120):
@@ -902,23 +969,71 @@ class Score:
self.time_signature = time_signature
self.bpm = bpm
self.notes: list[Note] = []
self.parts: dict[str, Part] = {}
self._drum_hits: list[_Hit] = []
self._drum_pattern_beats: float = 0.0
def part(self, name: str, *, synth: str = "sine",
envelope: str = "piano", volume: float = 0.5) -> Part:
"""Create a named part with its own synth voice.
Args:
name: Part name (e.g. ``"lead"``, ``"bass"``, ``"pads"``).
synth: Waveform — ``"sine"``, ``"saw"``, or ``"triangle"``.
envelope: ADSR preset name — ``"piano"``, ``"pluck"``,
``"pad"``, ``"organ"``, ``"bell"``, ``"strings"``,
``"staccato"``, or ``"none"``.
volume: Mix level from 0.0 to 1.0 (default 0.5).
Returns:
A :class:`Part` object. Add notes with ``.add()`` and ``.rest()``.
Example::
lead = score.part("lead", synth="saw", envelope="pluck")
lead.add("C5", Duration.QUARTER).add("E5", Duration.QUARTER)
"""
p = Part(name, synth=synth, envelope=envelope, volume=volume)
self.parts[name] = p
return p
def add_pattern(self, pattern, repeats: int = 1) -> "Score":
"""Add a drum pattern to this score.
Args:
pattern: A :class:`Pattern` object.
repeats: Number of times to repeat.
Returns:
Self for chaining.
"""
for r in range(repeats):
offset = self._drum_pattern_beats + r * pattern.beats
for hit in pattern.hits:
self._drum_hits.append(
_Hit(hit.sound, hit.position + offset, hit.velocity))
self._drum_pattern_beats += repeats * pattern.beats
return self
def add(self, tone_or_chord, duration=Duration.QUARTER) -> "Score":
"""Add a note. Returns self for chaining."""
"""Add a note to the default (unnamed) part.
For simple scores without named parts. Returns self for chaining.
"""
self.notes.append(Note(tone=tone_or_chord, duration=duration))
return self
def rest(self, duration=Duration.QUARTER) -> "Score":
"""Add a rest. Returns self for chaining."""
"""Add a rest to the default part. Returns self for chaining."""
self.notes.append(Note(tone=None, duration=duration))
return self
@property
def total_beats(self) -> float:
note_beats = sum(n.beats for n in self.notes)
return max(note_beats, self._drum_pattern_beats)
beats = [sum(n.beats for n in self.notes), self._drum_pattern_beats]
for p in self.parts.values():
beats.append(p.total_beats)
return max(beats) if beats else 0.0
@property
def measures(self) -> float:
@@ -932,15 +1047,18 @@ class Score:
return self.total_beats * ms_per_beat
def __len__(self):
return len(self.notes)
return len(self.notes) + sum(len(p) for p in self.parts.values())
def __iter__(self):
return iter(self.notes)
def __repr__(self):
part_info = ""
if self.parts:
part_info = f" {len(self.parts)} parts"
return (
f"<Score {self.time_signature} {self.bpm}bpm "
f"{len(self.notes)} notes {self.measures:.1f} measures>"
f"<Score {self.time_signature} {self.bpm}bpm"
f"{part_info} {self.measures:.1f} measures>"
)
def save_midi(self, path, velocity=100):
+111 -1
View File
@@ -4970,7 +4970,6 @@ def test_score_repr():
r = repr(score)
assert "4/4" in r
assert "120bpm" in r
assert "4 notes" in r
assert "1.0 measures" in r
@@ -5135,3 +5134,114 @@ def test_render_pattern_different_tempos():
slow = _render_pattern(p, bpm=60)
fast = _render_pattern(p, bpm=240)
assert len(slow) > len(fast) # slower = more samples
# ── Part and multi-part Score ──────────────────────────────────────────────
def test_part_creation():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="saw", envelope="pluck")
assert lead.name == "lead"
assert lead.synth == "saw"
assert lead.envelope == "pluck"
assert "lead" in score.parts
def test_part_add_string():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.add("C5", Duration.QUARTER)
assert len(lead) == 1
assert lead.notes[0].tone.name == "C"
assert lead.notes[0].tone.octave == 5
def test_part_add_chaining():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
result = lead.add("C5", Duration.QUARTER).add("E5", Duration.QUARTER).rest(Duration.HALF)
assert result is lead
assert len(lead) == 3
assert lead.total_beats == 4.0
def test_part_total_beats_in_score():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.add("C5", Duration.WHOLE).add("E5", Duration.WHOLE)
assert score.total_beats == 8.0
def test_multiple_parts():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="saw")
bass = score.part("bass", synth="triangle")
lead.add("C5", Duration.WHOLE)
bass.add("C2", Duration.WHOLE).add("G2", Duration.WHOLE)
assert len(score.parts) == 2
assert score.total_beats == 8.0 # bass is longer
def test_score_add_pattern():
from pytheory import Score, Pattern
score = Score("4/4", bpm=120)
score.add_pattern(Pattern.preset("rock"), repeats=2)
assert score._drum_pattern_beats == 8.0
assert len(score._drum_hits) > 0
def test_score_add_pattern_chaining():
from pytheory import Score, Pattern
score = Score("4/4", bpm=120)
result = score.add_pattern(Pattern.preset("rock"), repeats=1)
assert result is score
def test_part_repr():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", synth="saw")
lead.add("C5", Duration.QUARTER)
r = repr(lead)
assert "lead" in r
assert "saw" in r
def test_score_repr_with_parts():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
score.part("lead")
score.part("bass")
r = repr(score)
assert "2 parts" in r
@needs_portaudio
def test_render_score_with_parts():
from pytheory import Score, Duration, Pattern, Key
from pytheory.play import render_score
score = Score("4/4", bpm=120)
score.add_pattern(Pattern.preset("rock"), repeats=2)
chords = score.part("chords", synth="sine", envelope="pad")
lead = score.part("lead", synth="saw", envelope="pluck")
key = Key("C", "major")
for chord in key.progression("I", "V", "vi", "IV"):
chords.add(chord, Duration.HALF)
lead.add("E5", Duration.QUARTER).add("G5", Duration.QUARTER)
buf = render_score(score)
assert len(buf) > 0
assert buf.dtype == numpy.float32
def test_backwards_compat_add():
"""Score.add() still works without named parts."""
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
score.add(Chord.from_symbol("C"), Duration.WHOLE)
assert len(score.notes) == 1
assert score.total_beats == 4.0