diff --git a/pytheory/__init__.py b/pytheory/__init__.py index 0f2f319..7169884 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -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", ] diff --git a/pytheory/play.py b/pytheory/play.py index c7f94a2..3c6a715 100644 --- a/pytheory/play.py +++ b/pytheory/play.py @@ -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() diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index 7e1851f..757dc81 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -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"") + + 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"" + f"" ) def save_midi(self, path, velocity=100): diff --git a/test_pytheory.py b/test_pytheory.py index 0857a31..ffcc1fd 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -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