From 11e4417c627785caa23be08dcf84739db7e86260 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sat, 28 Mar 2026 11:38:41 -0400 Subject: [PATCH] =?UTF-8?q?Part.hold()=20=E2=80=94=20polyphonic=20overlap?= =?UTF-8?q?=20on=20a=20single=20part?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hold() adds a note without advancing the beat position, so the next note starts at the same time. Enables: piano sustain (bass rings while melody plays), drone notes under melody, held chords with moving lines. Two lines in the renderer: skip beat_pos advance when _hold is set. Co-Authored-By: Claude Opus 4.6 (1M context) --- pytheory/play.py | 7 +++++-- pytheory/rhythm.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/pytheory/play.py b/pytheory/play.py index 7222740..3f6a4c0 100644 --- a/pytheory/play.py +++ b/pytheory/play.py @@ -4200,7 +4200,9 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples, # Right channel gets up-detuned, left gets down-detuned stereo_buf[start:end, 1] += up_env * gain * spread_amt stereo_buf[start:end, 0] += down_env * gain * spread_amt - beat_pos += note.beats + # hold() notes don't advance the beat position + if not getattr(note, '_hold', False): + beat_pos += note.beats def _render_legato_to_buf(notes, buf, samples_per_beat, total_samples, @@ -4242,7 +4244,8 @@ def _render_legato_to_buf(notes, buf, samples_per_beat, total_samples, events.append((start, end, hz, vel)) else: events.append((start, end, 0, vel)) # rest - beat_pos += note.beats + if not getattr(note, '_hold', False): + beat_pos += note.beats if not events: return diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index 25d6ce5..f865a1a 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -426,6 +426,7 @@ class Note: bend: float = 0.0 bend_type: str = "smooth" # "smooth" (log), "linear", "late" lyric: str = "" # syllable for vocal synth + _hold: bool = False # if True, don't advance beat position @property def beats(self) -> float: @@ -2223,6 +2224,35 @@ class Part: bend_type=bend_type, lyric=lyric)) return self + def hold(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100, + bend: float = 0.0, bend_type: str = "smooth", lyric: str = "") -> "Part": + """Add a note without advancing the beat position. + + The note plays at the current position but the next note + starts at the *same* time — enabling polyphonic overlap + on a single part. + + Use this for: piano sustain pedal (bass note rings while + melody plays above), guitar strumming with individual + string timing, held drone notes under a melody. + + Example:: + + >>> piano = score.part("piano", instrument="piano") + >>> piano.hold("C3", Duration.WHOLE) # bass rings for 4 beats + >>> piano.add("E4", Duration.HALF) # starts at same time as C3 + >>> piano.add("G4", Duration.HALF) # starts at beat 2 + """ + if isinstance(tone_or_string, str): + from .tones import Tone + tone_or_string = Tone.from_string(tone_or_string, system=self._system) + if isinstance(duration, (int, float)): + duration = _RawDuration(duration) + self.notes.append(Note(tone=tone_or_string, duration=duration, + velocity=velocity, bend=bend, + bend_type=bend_type, lyric=lyric, _hold=True)) + return self + def set(self, **params) -> "Part": """Change effect parameters at the current beat position.