Files
kennethreitz b68cec889f feat(audio): render_scores() — parallel batch rendering across cores
A single render_score is already fast, so the multicore win is in batch
work: exporting an album, rendering many clips, serving several requests.
render_scores(scores, workers=) renders each Score independently on its
own thread; NumPy releases the GIL during the heavy math, so it scales
~2x on a typical machine with zero change to how any Score sounds.

(Within-a-single-render parallelism was measured and rejected: renders are
already ~0.2s and the process-pool spawn + buffer serialization overhead
made it slower, on audio-critical, non-deterministic code.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:13:24 -04:00

470 lines
16 KiB
Python

import pytest
import numpy
import pytheory
from pytheory import Tone, TonedScale, Fretboard, Chord, Key, Note, TET
from pytheory.charts import CHARTS, NamedChord, charts_for_fretboard, QUALITIES
from pytheory.systems import System, SYSTEMS
from pytheory.rhythm import Duration, TimeSignature, Note as RhythmNote, Rest, Score
from _util import (HAS_PORTAUDIO, needs_portaudio, _write_test_wav,
_roundtrip_melody, _render_test_mix, _chords_roundtrip,
_chord_buffer, _progression_score)
def test_from_wav_roundtrips_melody():
names = _roundtrip_melody([("C4", 1), ("E4", 1), ("G4", 1), ("C5", 1)])
assert names == ["C4", "E4", "G4", "C5"]
def test_from_wav_splits_repeated_notes():
names = _roundtrip_melody([("G4", 0.5), ("G4", 0.5), ("G4", 1)])
assert names == ["G4", "G4", "G4"]
def test_from_wav_handles_inharmonic_piano_timbre():
names = _roundtrip_melody([("C4", 1), ("F4", 1), ("A4", 1)],
synth="piano_synth")
assert names == ["C4", "F4", "A4"]
def test_from_wav_bass_register():
names = _roundtrip_melody([("E2", 1), ("G2", 1), ("A2", 2)],
synth="bass_guitar", fmin=40, fmax=400)
assert names == ["E2", "G2", "A2"]
def test_from_wav_quantize_snaps_durations():
import tempfile, os
from pytheory.rhythm import Score
from pytheory.play import render_score
s = Score(bpm=120)
p = s.part("m", synth="sine", volume=0.7)
p.add("C4", 1.04)
p.add("D4", 0.49)
path = tempfile.mktemp(suffix=".wav")
_write_test_wav(render_score(s), path)
try:
out = Score.from_wav(path, quantize=0.25)
finally:
os.unlink(path)
beats = [n.beats for n in out.parts["melody"].notes if n.tone is not None]
assert beats == [1.0, 0.5]
def test_detect_pitch_pure_tone():
from pytheory.audio import detect_pitch
t = numpy.arange(44100) / 44100
sig = numpy.sin(2 * numpy.pi * 440 * t)
times, freqs, voiced = detect_pitch(sig, 44100)
assert voiced.sum() > 50
assert abs(freqs[voiced].mean() - 440) < 1.0
def test_detect_pitch_silence_is_unvoiced():
from pytheory.audio import detect_pitch
sig = numpy.zeros(44100)
times, freqs, voiced = detect_pitch(sig, 44100)
assert not voiced.any()
def test_from_wav_split_recovers_bass_and_melody():
import os
from pytheory.rhythm import Score
path = _render_test_mix()
try:
score = Score.from_wav(path, bpm=110, split=True, quantize=0.5)
finally:
os.unlink(path)
assert set(score.parts) >= {"melody", "bass"}
# Bass: pitch classes must walk A → F in order
bass_pcs = [str(n.tone)[:-1].rstrip("#b")
for n in score.parts["bass"].notes if n.tone is not None]
deduped = [pc for i, pc in enumerate(bass_pcs)
if i == 0 or pc != bass_pcs[i - 1]]
a_idx = deduped.index("A") if "A" in deduped else -1
f_idx = deduped.index("F") if "F" in deduped else -1
assert a_idx != -1 and f_idx != -1 and a_idx < f_idx
# Melody: most detected notes belong to the true lead line
truth = {"A4", "C5", "B4", "G4", "F4", "E4", "C4"}
mel = [str(n.tone) for n in score.parts["melody"].notes
if n.tone is not None]
assert len(mel) >= 6
hits = sum(1 for n in mel if n in truth)
assert hits / len(mel) > 0.6
def test_from_wav_auto_bpm_sets_score_tempo():
import os
from pytheory.rhythm import Score
path = _render_test_mix()
try:
score = Score.from_wav(path) # no bpm — estimate
finally:
os.unlink(path)
assert 55 <= score.bpm <= 224
def test_tuner_analyze_frame_in_tune():
import json
from pytheory.tuner import analyze_frame
t = numpy.arange(4096) / 44100
r = analyze_frame(numpy.sin(2 * numpy.pi * 440 * t), 44100)
assert r["note"] == "A" and r["octave"] == 4
assert abs(r["cents"]) < 2
assert r["in_tune"] is True
json.dumps(r) # must be JSON-serializable for the SSE stream
def test_tuner_analyze_frame_sharp():
from pytheory.tuner import analyze_frame
t = numpy.arange(4096) / 44100
# 440 * 2^(25/1200) ≈ 446.40 Hz — 25 cents sharp of A4
r = analyze_frame(numpy.sin(2 * numpy.pi * 446.40 * t), 44100)
assert r["note"] == "A"
assert 20 < r["cents"] < 30
assert r["in_tune"] is False
def test_tuner_reference_pitch():
from pytheory.tuner import analyze_frame
t = numpy.arange(4096) / 44100
r = analyze_frame(numpy.sin(2 * numpy.pi * 442 * t), 44100,
reference_pitch=442)
assert r["note"] == "A" and abs(r["cents"]) < 2
def test_tuner_noise_returns_none():
from pytheory.tuner import analyze_frame
noise = numpy.random.default_rng(0).uniform(-1, 1, 4096)
assert analyze_frame(noise, 44100) is None
def test_detect_chords_on_rendered_progression():
from pytheory.rhythm import Score
from pytheory.play import render_score
from pytheory import Chord
from pytheory.audio import detect_chords
s = Score(bpm=120)
p = s.part("p", synth="rhodes", volume=0.6)
for sym in ["Am", "F", "C", "G"]:
p.add(Chord.from_symbol(sym), 4)
buf = render_score(s).mean(axis=1).astype(numpy.float64)
track = detect_chords(buf, 44100, bpm=120, beats_per_chord=4)
symbols = [sym for _, _, sym in track]
assert symbols == ["Am", "F", "C", "G"]
def test_from_wav_split_full_arrangement():
import os
from pytheory.rhythm import Score
path = _render_test_mix() # Am, F over rock drums at 110
try:
score = Score.from_wav(path, bpm=110, split=True, quantize=0.5)
finally:
os.unlink(path)
assert set(score.parts) >= {"melody", "bass", "chords", "drums"}
chord_syms = [str(n.tone) for n in score.parts["chords"].notes
if n.tone is not None]
assert any("A minor" in c for c in chord_syms)
assert any("F major" in c for c in chord_syms)
drum_sounds = {h.sound.name for h in score._drum_hits}
assert "CLOSED_HAT" in drum_sounds
assert "SNARE" in drum_sounds or "KICK" in drum_sounds
assert score.detected_key is not None
def test_detect_chords_sevenths():
assert _chords_roundtrip(["Dm7", "G7", "Cmaj7"]) == ["Dm7", "G7", "Cmaj7"]
def test_detect_chords_triads_not_promoted_to_sevenths():
assert _chords_roundtrip(["Am", "F", "C", "G"]) == ["Am", "F", "C", "G"]
def test_detect_chords_sus():
assert _chords_roundtrip(["Csus4", "Am", "G"]) == ["Csus4", "Am", "G"]
def test_detect_chords_inversion_slash():
from pytheory.rhythm import Score
from pytheory.play import render_score
from pytheory import Chord
from pytheory.audio import detect_chords
s = Score(bpm=120)
p = s.part("p", synth="rhodes", volume=0.6)
b = s.part("b", synth="sine", volume=0.6)
for sym, bass in [("C", "C2"), ("C", "E2"), ("G", "G2")]:
p.add(Chord.from_symbol(sym, octave=4), 4)
b.add(bass, 4)
buf = render_score(s).mean(axis=1).astype(numpy.float64)
track = detect_chords(buf, 44100, bpm=120, beats_per_chord=4)
symbols = [sym for _, _, sym in track]
assert symbols == ["C", "C/E", "G"]
def test_detect_chords_beat_aligned_lead_in():
from pytheory.rhythm import Score
from pytheory.play import render_score
from pytheory import Chord
from pytheory.audio import detect_chords
s = Score(bpm=120)
p = s.part("p", synth="rhodes", volume=0.6)
for sym in ["Am", "F", "C", "G"]:
p.add(Chord.from_symbol(sym), 4)
buf = render_score(s).mean(axis=1).astype(numpy.float64)
# Half a beat of near-silence up front — the grid must realign
lead = numpy.zeros(int(44100 * 0.25))
track = detect_chords(numpy.concatenate([lead, buf]), 44100,
bpm=120, beats_per_chord=4)
symbols = [sym for _, _, sym in track]
assert symbols == ["Am", "F", "C", "G"]
assert 0.25 <= track[0][0] <= 0.75 # starts near the real downbeat
def test_detect_chords_short_and_silent_input():
from pytheory.audio import detect_chords
assert detect_chords(numpy.zeros(100), 44100) == []
assert detect_chords(numpy.zeros(44100), 44100) == []
def test_tuner_string_targets():
from pytheory.tuner import string_targets
targets = string_targets("guitar")
assert [n for n, _ in targets] == ["E2", "A2", "D3", "G3", "B3", "E4"]
assert abs(targets[0][1] - 82.41) < 0.01
# Reference pitch shifts every string
high = string_targets("guitar", reference_pitch=442.0)
assert high[0][1] > targets[0][1]
def test_tuner_analyze_frame_locks_to_string():
import json
from pytheory.tuner import analyze_frame, string_targets
t = numpy.arange(8192) / 44100
# 145 Hz — 22 cents flat of the guitar D string (146.83 Hz)
r = analyze_frame(numpy.sin(2 * numpy.pi * 145 * t), 44100,
targets=string_targets("guitar"))
assert r["target"] == "D3"
assert -30 < r["cents"] < -15
assert r["in_tune"] is False
json.dumps(r)
def test_tuner_instrument_widens_range():
from pytheory.tuner import Tuner
t = Tuner(instrument="bass")
assert t.fmin < 41.3 # must reach the low E1 string
assert t.targets[0][0] == "E1"
def test_tuner_unknown_instrument():
from pytheory.tuner import Tuner
with pytest.raises(ValueError):
Tuner(instrument="kazoo")
def test_tuner_ws_frame_encoding():
from pytheory.tuner import _ws_frame
small = _ws_frame(b"x" * 10)
assert small[0] == 0x81 and small[1] == 10
medium = _ws_frame(b"x" * 200)
assert medium[1] == 126
assert int.from_bytes(medium[2:4], "big") == 200
def test_tuner_serve_page_stream_and_websocket():
import base64
import hashlib
import json
import socket
import threading
import time
import types
import urllib.request
from pytheory import tuner as tuner_mod
fake = types.SimpleNamespace(
instrument="guitar",
targets=tuner_mod.string_targets("guitar"),
reference_pitch=440.0,
reading={"freq": 82.4, "note": "E", "octave": 2, "cents": -3.0,
"in_tune": True, "target": "E2", "target_freq": 82.41})
port = 8342
th = threading.Thread(target=tuner_mod.serve, args=(fake,),
kwargs={"port": port, "open_browser": False},
daemon=True)
th.start()
time.sleep(0.6)
page = urllib.request.urlopen(
f"http://localhost:{port}/", timeout=10).read().decode()
assert "strobe" in page
assert '"strings": ["E2", "A2", "D3", "G3", "B3", "E4"]' in page
stream = urllib.request.urlopen(
f"http://localhost:{port}/stream", timeout=10)
line = stream.readline().decode()
stream.close()
assert line.startswith("data: ") and '"target": "E2"' in line
# WebSocket handshake (RFC 6455 sample key) + one frame
ws_key = "dGhlIHNhbXBsZSBub25jZQ=="
expect = base64.b64encode(hashlib.sha1(
(ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()
).digest()).decode()
s = socket.create_connection(("localhost", port), timeout=10)
s.sendall((f"GET /ws HTTP/1.1\r\nHost: localhost\r\n"
f"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {ws_key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n").encode())
buf = b""
while b"\r\n\r\n" not in buf:
buf += s.recv(4096)
head, _, rest = buf.partition(b"\r\n\r\n")
assert b"101" in head.splitlines()[0]
assert f"Sec-WebSocket-Accept: {expect}".encode() in head
buf = rest
while len(buf) < 4:
buf += s.recv(4096)
assert buf[0] == 0x81
if buf[1] == 126:
need, off = int.from_bytes(buf[2:4], "big"), 4
else:
need, off = buf[1], 2
while len(buf) < off + need:
buf += s.recv(4096)
reading = json.loads(buf[off:off + need].decode())
assert reading["target"] == "E2"
s.close()
def test_from_wav_detected_key_am_f_g():
"""An Am-F-G progression is C major / A minor territory —
regression for the name-matching bug that returned A# major."""
import os
import tempfile
import wave as wavemod
from pytheory.rhythm import Score
from pytheory.play import render_score
s = Score(bpm=120)
p = s.part("p", synth="rhodes", volume=0.6)
for sym in ["Am", "F", "G"]:
p.add(Chord.from_symbol(sym), 4)
data = (numpy.clip(render_score(s), -1, 1) * 32767).astype(numpy.int16)
fd, path = tempfile.mkstemp(suffix=".wav")
os.close(fd)
try:
with wavemod.open(path, "wb") as f:
f.setnchannels(2)
f.setsampwidth(2)
f.setframerate(44100)
f.writeframes(data.tobytes())
score = Score.from_wav(path, split=True, quantize=0.5)
finally:
os.unlink(path)
assert str(score.detected_key) in ("C major", "A minor")
def test_identify_chord_triads_and_sevenths():
from pytheory.audio import identify_chord
for sym in ["Am", "C", "E", "Bm", "F#m", "Esus4"]:
r = identify_chord(_chord_buffer(sym), 44100)
assert r is not None and r["symbol"] == sym, (sym, r)
assert r["confidence"] > 0.7
r = identify_chord(_chord_buffer("Dm7", synth="rhodes"), 44100)
assert r["symbol"] == "Dm7"
assert r["notes"] == ["D", "F", "A", "C"]
def test_identify_chord_rejects_single_notes():
from pytheory.audio import identify_chord
from pytheory.rhythm import Score
from pytheory.play import render_score
for note, synth in [("A4", "piano_synth"), ("C3", "saw"),
("A3", "rhodes")]:
s = Score(bpm=120)
p = s.part("p", synth=synth, volume=0.6)
p.add(note, 2)
buf = render_score(s).mean(axis=1).astype(numpy.float64)[:44100]
assert identify_chord(buf, 44100) is None, (note, synth)
def test_identify_chord_rejects_silence_and_noise():
from pytheory.audio import identify_chord
assert identify_chord(numpy.zeros(44100), 44100) is None
noise = numpy.random.default_rng(0).normal(0, 0.1, 44100)
assert identify_chord(noise, 44100) is None
assert identify_chord(numpy.zeros(100), 44100) is None
def test_tuner_chord_mode_buffer_and_state():
from pytheory.tuner import Tuner
t = Tuner(chords=True)
assert t.chords is True
assert t._buf_len == 44100 # 1s buffer for the chromagram
assert t.chord is None
plain = Tuner()
assert plain._buf_len == 4096
def test_tuner_serve_chord_payload():
import json
import threading
import time
import types
import urllib.request
from pytheory import tuner as tuner_mod
fake = types.SimpleNamespace(
instrument=None, targets=None, reference_pitch=440.0, chords=True,
reading={"freq": 220.0, "note": "A", "octave": 3,
"cents": 1.0, "in_tune": True},
chord={"symbol": "Am", "confidence": 0.91,
"notes": ["A", "C", "E"]})
port = 8343
th = threading.Thread(target=tuner_mod.serve, args=(fake,),
kwargs={"port": port, "open_browser": False},
daemon=True)
th.start()
time.sleep(0.6)
page = urllib.request.urlopen(
f"http://localhost:{port}/", timeout=10).read().decode()
assert "chordsym" in page and '"chords": true' in page
stream = urllib.request.urlopen(
f"http://localhost:{port}/stream", timeout=10)
d = json.loads(stream.readline().decode()[6:])
stream.close()
assert d["chord"] == "Am"
assert d["chord_notes"] == ["A", "C", "E"]
assert d["note"] == "A"
def test_render_scores_batch():
"""render_scores returns one correct buffer per score, in order."""
from pytheory import Score, Chord, Duration, render_score, render_scores
import numpy as np
def build(sym):
s = Score("4/4", bpm=120)
p = s.part("p", synth="sine")
for c in [sym, sym]:
p.add(Chord.from_symbol(c), Duration.WHOLE)
return s
scores = [build(s) for s in ["C", "Am", "F", "G"]]
out = render_scores(scores, workers=4)
assert len(out) == 4
# Each result matches the single-score render's shape (stereo, same length).
for s, buf in zip(scores, out):
ref = render_score(s)
assert buf.shape == ref.shape
assert buf.ndim == 2 and buf.shape[1] == 2
assert np.all(np.isfinite(buf))
# workers=1 path also works.
assert len(render_scores(scores, workers=1)) == 4
assert len(render_scores([], workers=4)) == 0