diff --git a/CHANGELOG.md b/CHANGELOG.md index 9602f8d..7f2c306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to PyTheory are documented here. +## 0.26.0 + +- **Stereo output** — render_score() now returns stereo (N, 2) arrays +- Add `pan` parameter: -1.0 (left) to 1.0 (right), constant-power panning +- Add `spread` parameter: detuned oscillators spread across L/R channels +- Master bus compressor runs per-channel for stereo +- All playback functions handle stereo natively + ## 0.25.7 - Add `detune` parameter — ±cents oscillator spread on any synth (3 oscillators per note) diff --git a/pyproject.toml b/pyproject.toml index d365e8d..1032846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pytheory" -version = "0.25.7" +version = "0.26.0" description = "Music Theory for Humans" readme = "README.md" license = "MIT" diff --git a/pytheory/__init__.py b/pytheory/__init__.py index 25d47a8..7e8c381 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -1,6 +1,6 @@ """PyTheory: Music Theory for Humans.""" -__version__ = "0.25.7" +__version__ = "0.26.0" from .tones import Tone, Interval from .systems import System, SYSTEMS diff --git a/pytheory/play.py b/pytheory/play.py index 7ff5e5d..0b71fa8 100644 --- a/pytheory/play.py +++ b/pytheory/play.py @@ -1264,6 +1264,26 @@ def _apply_part_effects(samples, part): return _apply_effects_with_params(samples, params) +def _pan_to_stereo(mono, pan=0.0): + """Pan a mono buffer into a stereo (N, 2) array. + + Args: + mono: Float32 1D array. + pan: -1.0 (full left) to 1.0 (full right). 0.0 = center. + + Returns: + Float32 (N, 2) array. + """ + # Constant-power panning (equal loudness across the field) + angle = (pan + 1.0) * 0.25 * numpy.pi # 0 to pi/2 + left_gain = numpy.cos(angle) + right_gain = numpy.sin(angle) + stereo = numpy.zeros((len(mono), 2), dtype=numpy.float32) + stereo[:, 0] = mono * left_gain + stereo[:, 1] = mono * right_gain + return stereo + + def _master_compress(samples, threshold=0.5, ratio=4.0, attack=0.002, release=0.05, makeup=True, limiter=True, sample_rate=SAMPLE_RATE): @@ -1374,7 +1394,7 @@ def _total_samples_from_tempo_map(total_beats, tempo_map): def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples, synth_fn, envelope_tuple, volume, bpm, swing=0.0, tempo_map=None, humanize=0.0, - detune=0.0): + detune=0.0, spread=0.0, stereo_buf=None): """Render a list of Notes into an existing buffer at the correct positions.""" import random as _rnd @@ -1407,14 +1427,25 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples, pitches = [note.tone.pitch()] # Render oscillators waves = [synth_fn(hz, n_samples=n_samples) for hz in pitches] - # Detune: add a second oscillator shifted by ±cents + # Detune: add oscillators shifted by ±cents + detune_up = None + detune_down = None if detune > 0: + up_waves = [] + down_waves = [] for hz in pitches: hz_up = hz * (2 ** (detune / 1200)) hz_down = hz * (2 ** (-detune / 1200)) - waves.append(synth_fn(hz_up, n_samples=n_samples)) - waves.append(synth_fn(hz_down, n_samples=n_samples)) - mixed = sum(w.astype(numpy.float32) for w in waves) / (SAMPLE_PEAK * (1 + (2 if detune > 0 else 0))) + up_waves.append(synth_fn(hz_up, n_samples=n_samples)) + down_waves.append(synth_fn(hz_down, n_samples=n_samples)) + if spread > 0 and stereo_buf is not None: + # Spread: detuned oscillators go to opposite channels + detune_up = sum(w.astype(numpy.float32) for w in up_waves) / SAMPLE_PEAK + detune_down = sum(w.astype(numpy.float32) for w in down_waves) / SAMPLE_PEAK + else: + waves.extend(up_waves + down_waves) + n_osc = len(waves) + mixed = sum(w.astype(numpy.float32) for w in waves) / (SAMPLE_PEAK * max(1, n_osc)) if a > 0 or d > 0 or s < 1.0 or r > 0: mixed = _apply_envelope(mixed, a, d, s, r) # Apply per-note velocity scaling + humanize velocity @@ -1425,6 +1456,18 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples, vel_scale = vel / 127.0 end = min(start + len(mixed), total_samples) buf[start:end] += mixed[:end - start] * volume * vel_scale + # Spread detuned oscillators into stereo L/R + if detune_up is not None and stereo_buf is not None: + spread_amt = spread + up_env = detune_up[:end - start] + down_env = detune_down[:end - start] + if a > 0 or d > 0 or s < 1.0 or r > 0: + up_env = _apply_envelope(up_env.copy(), a, d, s, r) + down_env = _apply_envelope(down_env.copy(), a, d, s, r) + gain = volume * vel_scale * 0.5 + # 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 @@ -1539,7 +1582,7 @@ def render_score(score): score: A :class:`Score` object. Returns: - Float32 numpy array of audio samples. + Float32 stereo numpy array (N, 2). """ # Build tempo map for variable tempo support tempo_map = _build_tempo_map(score) @@ -1552,6 +1595,9 @@ def render_score(score): total_samples = _total_samples_from_tempo_map(total_beats, tempo_map) else: total_samples = int(total_beats * samples_per_beat) + # Stereo master buffer + stereo_buf = numpy.zeros((total_samples, 2), dtype=numpy.float32) + # Mono buffer for backwards-compat rendering buf = numpy.zeros(total_samples, dtype=numpy.float32) # Default notes (backwards-compatible .add() calls) @@ -1583,7 +1629,9 @@ def render_score(score): swing=effective_swing, tempo_map=tempo_map if has_tempo_changes else None, humanize=part.humanize, - detune=part.detune) + detune=part.detune, + spread=part.spread, + stereo_buf=stereo_buf) # Apply effects — segmented if automation exists auto_points = part._get_automation_points() @@ -1620,7 +1668,8 @@ def render_score(score): if getattr(part, 'sidechain', 0) > 0: _pending_sidechain.append((part, part_buf)) else: - buf += part_buf + # Pan mono part into stereo + stereo_buf += _pan_to_stereo(part_buf, part.pan) # Drum hits — render to separate buffer for sidechain trigger drum_buf = numpy.zeros(total_samples, dtype=numpy.float32) @@ -1651,14 +1700,20 @@ def render_score(score): part_buf, drum_buf, amount=part.sidechain, release=part.sidechain_release) - buf += part_buf + stereo_buf += _pan_to_stereo(part_buf, part.pan) - buf += drum_buf + # Default notes (mono, center) + if score.notes: + stereo_buf += _pan_to_stereo(buf, 0.0) - # Master bus compressor/limiter - buf = _master_compress(buf) + # Drums: center + stereo_buf += _pan_to_stereo(drum_buf, 0.0) - return buf + # Master bus compressor/limiter (per channel) + stereo_buf[:, 0] = _master_compress(stereo_buf[:, 0]) + stereo_buf[:, 1] = _master_compress(stereo_buf[:, 1]) + + return stereo_buf def play_score(score): diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index 3ba387d..d828be5 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -1369,7 +1369,9 @@ class Part: humanize: float = 0.0, sidechain: float = 0.0, sidechain_release: float = 0.1, - detune: float = 0.0): + detune: float = 0.0, + pan: float = 0.0, + spread: float = 0.0): self.name = name self.synth = synth self.envelope = envelope @@ -1394,6 +1396,8 @@ class Part: self.chorus_rate = chorus_rate self.chorus_depth = chorus_depth self.detune = detune + self.pan = pan + self.spread = spread self.notes: list[Note] = [] self._automation: list[tuple[float, dict]] = [] # (beat, {param: value}) @@ -1801,7 +1805,9 @@ class Score: humanize: float = 0.0, sidechain: float = 0.0, sidechain_release: float = 0.1, - detune: float = 0.0) -> Part: + detune: float = 0.0, + pan: float = 0.0, + spread: float = 0.0) -> Part: """Create a named part with its own synth voice and effects. Args: @@ -1866,7 +1872,7 @@ class Score: chorus_depth=chorus_depth, swing=swing, humanize=humanize, sidechain=sidechain, sidechain_release=sidechain_release, - detune=detune) + detune=detune, pan=pan, spread=spread) self.parts[name] = p return p diff --git a/uv.lock b/uv.lock index b2b1854..2d79d2d 100644 --- a/uv.lock +++ b/uv.lock @@ -707,7 +707,7 @@ wheels = [ [[package]] name = "pytheory" -version = "0.25.7" +version = "0.26.0" source = { editable = "." } dependencies = [ { name = "numeral" },