mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user