diff --git a/README.md b/README.md index 145da9a..e5b8043 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Each track is a `.py` file. Run it to hear it. 9. **The Temple** — A Phrygian (Bhairavi), 65 BPM, shruti just intonation, A=432 Hz. Devotional layers inside a vast stone chamber. Singing bowls, tambura drone, harmonium, sitar, bansuri, tabla. The reverb is the instrument. Everything enters slowly, sings together, then dissolves back into silence. 10. **The Dialogue** — E Phrygian (Bhairavi), 75 BPM, shruti just intonation, A=432 Hz. Sitar (human) and theremin (machine) start alone, find each other through call-and-response, and become something neither could be on their own. House beat enters when they find the groove. 11. **Voltage** — F minor, 138 BPM. Raw oscillators, nothing else. Sine sub, saw lead, pulse counter-rhythm. Rhythm is pitch. Saw arp solo, pulse arp solo, 32nd note chaos at the peak. Aggressive, monophonic, electric. +12. **The Observatory** — G minor, 112 BPM. Chapel harmonies broadcast through shortwave static. Radio hiss, singing bowl, square-wave organ, choir, saw arp, supersaw halo pad, theremin signal melody. Patient house pulse arrives at bar 33. ## Usage diff --git a/play.py b/play.py index a1baddc..3babcd5 100644 --- a/play.py +++ b/play.py @@ -50,6 +50,7 @@ ALBUM_ORDER = [ "the_temple.py", "the_dialogue.py", "voltage.py", + "the_observatory.py", ] diff --git a/tracks/the_observatory.py b/tracks/the_observatory.py new file mode 100644 index 0000000..6ab20ad --- /dev/null +++ b/tracks/the_observatory.py @@ -0,0 +1,383 @@ +""" +THE OBSERVATORY — chapel harmonies broadcast through shortwave static. +An organ learns to levitate. By the time the kick arrives, +the room is already in orbit. + +G minor, 112 BPM. Radio hiss, bell, organ, choir, arp, +sub bass, and a patient house pulse. +""" + +from pytheory import Key, Duration, Score, play_score +from pytheory.rhythm import DrumSound + +key = Key("G", "minor") +s = key.scale # G A Bb C D Eb F + +G = s[0] +A = s[1] +Bb = s[2] +C = s[3] +D = s[4] +Eb = s[5] +F = s[6] + +score = Score("4/4", bpm=112) + +K = DrumSound.KICK +CL = DrumSound.CLAP +CH = DrumSound.CLOSED_HAT +OH = DrumSound.OPEN_HAT + +prog = key.progression("i", "VI", "III", "VII") # Gm - Eb - Bb - F +roots = [G.add(-24), Eb.add(-24), Bb.add(-24), F.add(-24)] + +# ═══════════════════════════════════════════════════════════════════ +# STRUCTURE (96 bars, ~5.1 minutes at 112 BPM): +# Bars 1-8: Shortwave hiss + bell. Empty room, open sky. +# Bars 9-16: Organ enters — first human architecture. +# Bars 17-32: Arp appears, still no drums. The orbit begins. +# Bars 33-48: Kick/sub/clap enter — lift. +# Bars 49-64: Choir + signal melody — the windows open. +# Bars 65-80: Peak — full transmission, bright and weightless. +# Bars 81-88: Drums thin out. Organ and choir suspend. +# Bars 89-96: Static, bell, and one last heartbeat. +# ═══════════════════════════════════════════════════════════════════ + +# ── RADIO HISS — the sky itself ─────────────────────────────────── +radio = score.part("radio", synth="noise", envelope="pad", volume=0.04, + reverb=0.85, reverb_type="taj_mahal", + lowpass=900) +radio.lfo("lowpass", rate=0.012, min=300, max=1800, bars=96, shape="sine") +radio.lfo("volume", rate=0.02, min=0.02, max=0.06, bars=96, shape="triangle") + +for _ in range(96): + radio.add(G, Duration.WHOLE, velocity=25) + +# ── BOWL — section markers, like light reflecting off metal ────── +bowl = score.part("bowl", instrument="singing_bowl", volume=0.42, + reverb=1.0, reverb_type="taj_mahal", + delay=0.25, delay_time=0.875, delay_feedback=0.35) + +marker_bars = {1: 78, 9: 72, 17: 68, 33: 75, 49: 72, 65: 76, 81: 60, 89: 54} +for bar in range(1, 97): + if bar in marker_bars: + note = G.add(-24) if bar < 65 else D.add(-12) + bowl.add(note, Duration.WHOLE, velocity=marker_bars[bar]) + else: + bowl.rest(Duration.WHOLE) + +# ── ORGAN — square-wave chapel, warm but slightly unreal ────────── +organ = score.part("organ", synth="square", envelope="organ", volume=0.14, + reverb=0.78, reverb_type="taj_mahal", + chorus=0.25, chorus_rate=0.18, chorus_depth=0.01, + lowpass=1800) + +low_prog = [c.transpose(-12) for c in prog] +mid_prog = prog +high_prog = [c.transpose(12) for c in prog] + +# Bars 1-8: silence +for _ in range(8): + organ.rest(Duration.WHOLE) + +# Bars 9-16: first statement, low and slow +for _ in range(2): + for chord in low_prog: + organ.add(chord, Duration.WHOLE, velocity=52) + +# Bars 17-32: stronger, still no pulse beneath it +for _ in range(4): + for chord in low_prog: + organ.add(chord, Duration.WHOLE, velocity=60) + +# Bars 33-64: fully present +for _ in range(8): + for chord in mid_prog: + organ.add(chord, Duration.WHOLE, velocity=72) + +# Bars 65-80: brighter voicings at the peak +for _ in range(4): + for chord in high_prog: + organ.add(chord, Duration.WHOLE, velocity=66) + +# Bars 81-88: suspended, looking down at the earth +for _ in range(2): + for chord in [mid_prog[0], mid_prog[1], mid_prog[0], mid_prog[3]]: + organ.add(chord, Duration.WHOLE, velocity=54) + +# Bars 89-96: fading architecture +for chord, vel in zip(low_prog * 2, [50, 44, 40, 36, 30, 24, 16, 8]): + organ.add(chord, Duration.WHOLE, velocity=vel) + +# ── CHOIR — the air inside the room starts singing ──────────────── +choir = score.part("choir", instrument="choir", volume=0.11, + reverb=0.95, reverb_type="taj_mahal", + chorus=0.35, chorus_rate=0.08, chorus_depth=0.012) + +# Bars 1-32: silence +for _ in range(32): + choir.rest(Duration.WHOLE) + +# Bars 33-48: arrives behind the drums, soft +for _ in range(4): + for chord in prog: + choir.add(chord, Duration.WHOLE, velocity=38) + +# Bars 49-80: fully open +for _ in range(8): + for chord in prog: + choir.add(chord, Duration.WHOLE, velocity=48) + +# Bars 81-88: held breath +for _ in range(2): + for chord in [prog[0], prog[1], prog[0], prog[3]]: + choir.add(chord, Duration.WHOLE, velocity=36) + +# Bars 89-96: gone +for _ in range(8): + choir.rest(Duration.WHOLE) + +# ── HALO PAD — the light around the chord, not the chord itself ── +halo = score.part("halo", synth="supersaw", envelope="pad", volume=0.08, + reverb=0.65, reverb_type="taj_mahal", + chorus=0.45, chorus_rate=0.2, chorus_depth=0.008, + lowpass=1200) +halo.lfo("lowpass", rate=0.01, min=500, max=2800, bars=96, shape="triangle") + +# Bars 1-48: silence +for _ in range(48): + halo.rest(Duration.WHOLE) + +# Bars 49-80: glow at the peak +for _ in range(8): + for chord in prog: + halo.add(chord, Duration.WHOLE, velocity=42) + +# Bars 81-96: silence +for _ in range(16): + halo.rest(Duration.WHOLE) + +# ── ARP — the signal lock, patient and inevitable ──────────────── +arp = score.part("arp", synth="saw", envelope="pluck", volume=0.35, + reverb=0.3, delay=0.35, delay_time=0.234, + delay_feedback=0.42, lowpass=1300, detune=5, + humanize=0.03) +arp.lfo("lowpass", rate=0.009, min=900, max=5200, bars=96, shape="saw") + +P1 = [ + G.add(12), None, D.add(12), None, + Bb, None, F.add(12), None, + G.add(12), None, D.add(12), None, + C.add(12), Bb, F, D, +] + +P2 = [ + G.add(12), D.add(12), F.add(12), D.add(12), + Eb.add(12), D.add(12), Bb, D.add(12), + G.add(12), D.add(12), F.add(12), D.add(12), + C.add(12), Bb, F, D, +] + + +def arp_bar(notes, vel): + for note in notes: + if note is None: + arp.rest(Duration.SIXTEENTH) + else: + arp.add(note, Duration.SIXTEENTH, velocity=vel) + + +# Bars 1-16: silence +for _ in range(16): + arp.rest(Duration.WHOLE) + +# Bars 17-32: emerges quietly +for _ in range(8): + arp_bar(P1, 50) + arp_bar(P2, 54) + +# Bars 33-48: steady and confident +for _ in range(8): + arp_bar(P1, 64) + arp_bar(P2, 68) + +# Bars 49-64: brighter +for _ in range(8): + arp_bar(P1, 76) + arp_bar(P2, 80) + +# Bars 65-80: peak — no more hesitation +for _ in range(8): + arp_bar(P2, 88) + arp_bar(P1, 92) + +# Bars 81-88: thinning out +for _ in range(4): + arp_bar(P1, 62) + arp_bar(P2, 58) + +# Bars 89-96: silence +for _ in range(8): + arp.rest(Duration.WHOLE) + +# ── SUB — the floor finally arrives beneath the chapel ─────────── +sub = score.part("sub", synth="sine", envelope="pad", volume=0.28, + lowpass=120, distortion=0.12, distortion_drive=2.5, + sub_osc=0.4, sidechain=0.25) + +# Bars 1-32: silence +for _ in range(32): + sub.rest(Duration.WHOLE) + +# Bars 33-80: root motion, simple and physical +for _ in range(12): + for root in roots: + sub.add(root, Duration.HALF, velocity=92) + sub.rest(Duration.HALF) + +# Bars 81-88: just the home note, held +for _ in range(8): + sub.add(G.add(-24), Duration.HALF, velocity=72) + sub.rest(Duration.HALF) + +# Bars 89-96: fading heartbeat +for vel in [66, 58, 50, 42, 34, 26, 16, 0]: + if vel > 0: + sub.add(G.add(-24), Duration.HALF, velocity=vel) + sub.rest(Duration.HALF) + else: + sub.rest(Duration.WHOLE) + +# ── KICK — the patient release ──────────────────────────────────── +kick = score.part("kick", volume=0.55, humanize=0.03) + +# Bars 1-32: none +for _ in range(32): + kick.rest(Duration.WHOLE) + +# Bars 33-80: four on the floor +for _ in range(48): + for _beat in range(4): + kick.hit(K, Duration.QUARTER, velocity=114) + +# Bars 81-88: still there, but more human now +for _ in range(8): + kick.hit(K, Duration.QUARTER, velocity=95) + kick.rest(Duration.QUARTER) + kick.hit(K, Duration.QUARTER, velocity=88) + kick.rest(Duration.QUARTER) + +# Bars 89-96: last pulses +for vel in [88, 82, 76, 68, 58, 46, 32, 18]: + kick.hit(K, Duration.QUARTER, velocity=vel) + kick.rest(Duration.DOTTED_HALF) + +# ── CLAP — 2 and 4, but never aggressive ───────────────────────── +clap = score.part("clap", volume=0.28, reverb=0.18, humanize=0.04) + +# Bars 1-32: silence +for _ in range(32): + clap.rest(Duration.WHOLE) + +# Bars 33-80: standard house backbeat +for _ in range(48): + clap.rest(Duration.QUARTER) + clap.hit(CL, Duration.QUARTER, velocity=96) + clap.rest(Duration.QUARTER) + clap.hit(CL, Duration.QUARTER, velocity=98) + +# Bars 81-96: silence +for _ in range(16): + clap.rest(Duration.WHOLE) + +# ── HATS — offbeat shimmer ──────────────────────────────────────── +hats = score.part("hats", volume=0.22, humanize=0.04) + +# Bars 1-32: silence +for _ in range(32): + hats.rest(Duration.WHOLE) + +# Bars 33-80: offbeat 8ths, occasional open hat on the turn +for bar in range(48): + for beat in range(4): + hats.rest(Duration.EIGHTH) + sound = OH if beat == 3 and bar % 4 == 3 else CH + vel = 74 if sound == CH else 68 + hats.hit(sound, Duration.EIGHTH, velocity=vel) + +# Bars 81-88: thinner +for _ in range(8): + for _beat in range(4): + hats.rest(Duration.EIGHTH) + hats.hit(CH, Duration.EIGHTH, velocity=52) + +# Bars 89-96: silence +for _ in range(8): + hats.rest(Duration.WHOLE) + +# ── SIGNAL — the melody finally resolves the transmission ──────── +signal = score.part("signal", instrument="theremin", volume=0.16, + reverb=0.82, reverb_type="taj_mahal", + delay=0.28, delay_time=0.375, delay_feedback=0.32, + humanize=0.05) + +# Bars 1-48: silence +for _ in range(48): + signal.rest(Duration.WHOLE) + +phrase_a = [ + (D.add(12), Duration.HALF, 70, 0.2), + (F.add(12), Duration.QUARTER, 74, 0.0), + (G.add(12), Duration.QUARTER, 78, 0.35), + (Bb.add(12), Duration.HALF, 80, -0.2), + (A.add(12), Duration.QUARTER, 68, 0.0), + (G.add(12), Duration.QUARTER, 72, 0.0), + (F.add(12), Duration.HALF, 70, 0.15), + (Eb.add(12), Duration.QUARTER, 68, 0.0), + (D.add(12), Duration.QUARTER, 66, -0.1), + (G.add(12), Duration.WHOLE, 78, 0.0), +] + +phrase_b = [ + (G.add(12), Duration.QUARTER, 78, 0.0), + (Bb.add(12), Duration.QUARTER, 82, 0.15), + (D.add(24), Duration.HALF, 86, -0.2), + (F.add(24), Duration.HALF, 84, 0.0), + (Eb.add(24), Duration.QUARTER, 78, 0.0), + (D.add(24), Duration.QUARTER, 76, 0.0), + (Bb.add(12), Duration.HALF, 72, -0.15), + (A.add(12), Duration.QUARTER, 68, 0.0), + (G.add(12), Duration.QUARTER, 66, 0.0), + (F.add(12), Duration.WHOLE, 74, 0.0), +] + +# Bars 49-64: first message +for _ in range(4): + for note, dur, vel, bend in phrase_a: + signal.add(note, dur, velocity=vel, bend=bend) + +# Bars 65-80: stronger reply +for _ in range(4): + for note, dur, vel, bend in phrase_b: + signal.add(note, dur, velocity=vel, bend=bend) + +# Bars 81-96: silence +for _ in range(16): + signal.rest(Duration.WHOLE) + +# ═══════════════════════════════════════════════════════════════════ +import sys + +print(f"Key: {key}") +print(f"BPM: 112") +print(f"Parts: {list(score.parts.keys())}") +print(f"Duration: {score.duration_ms / 1000:.1f}s | {score.measures} measures") + +if "--live" in sys.argv: + print("Playing THE OBSERVATORY (live engine)...") + from pytheory_live.live import LiveEngine + engine = LiveEngine(buffer_size=1024) + engine.play_score(score) +else: + print("Playing THE OBSERVATORY...") + play_score(score)