Leaf Loops

Example script (scamp_extensions): examples/Composition & form/Larger-scale form/leaf_loops.py

Download: leaf_loops.zip

Requires the scamp_extensions package (pip install scamp_extensions).

Generative process behind Marc Evanstein’s “Leaf Loops” for violin and viola. The shapes, note attack points, and worm shape for several different leaves are found in the LeafPoints directory.

Topics: Interactivity & visualization › Visualization, Composition & form › Larger-scale form

import pygame
import json
import math
import numpy as np
from shapely.geometry import LineString
from scamp import *
from scamp_extensions.pitch import Scale
import random

# ---------------------------------- Parameters -------------------------------------

WHICH_LEAF = "LeafPoints/dwarfBirch"
INSTRUMENT = "Vibraphone"
WORM_SPEED = 0.4
ROTATION_SPEED = 3  # Adjusted speed for leaf rotation
PITCH_RANGE = 30, 100

# SCALE = Scale.blues(34)
# SCALE = Scale.chromatic(64)
# SCALE = Scale.diatonic(55)
SCALE = Scale.from_pitches([40, 45, 47, 50, 52, 55, 55.86, 57, 59, 60.86, 61.69, 62,
                            64, 65.86, 66.69, 67, 69, 70.86, 71.69, 74, 76.69, 79], cycle=False)


# ---------------------------------- Music Setup -------------------------------------


s = Session().run_as_server()

# vibes = s.new_midi_part(INSTRUMENT, "MIDI Through Port 1", note_on_and_off_only=True)
vibes = s.new_part(INSTRUMENT, "vibraphone", note_on_and_off_only=True)


def get_pitch_from_length(dist):
    return SCALE.round(
        PITCH_RANGE[1] - dist * (PITCH_RANGE[1] - PITCH_RANGE[0])
    )

# ----------------------------------- Animation ---------------------------------------

WIDTH = 1200
DOT_WIDTH = 18
NOTE_LINE_WIDTH = 9
LEAF_OUTLINE_LINE_WIDTH = 6
LEAF_MINER_LINE_WIDTH = 10
PLAYBACK_DOT_SWELL_FACTOR = 2
PLAYBACK_DOT_SWELL_DECAY_FACTOR = 0.92

# Load configurations
def load_json(filepath):
    with open(filepath, 'r') as f:
        return json.load(f)


# Load the leaf ring, worm curve, and attack points
leaf_ring_coords = load_json(f"{WHICH_LEAF}.json")
try:
    worm_curve_coords = load_json(f"{WHICH_LEAF}Worm.json")
except FileNotFoundError:
    worm_curve_coords = [[0.5, 0.5], [0.50001, 0.50001]]

# Convert to shapely LineString for interpolation
leaf_ring = LineString(leaf_ring_coords)
worm_curve = LineString(worm_curve_coords)
# worm_curve = LineString([(0.5, 0.8), (0.5, 0.8001)])


attack_points = load_json(f"{WHICH_LEAF}AttackPoints.json")
#attack_points = [leaf_ring.length * (i + 1)/ 400 for i in range(400)]


# Initialize Pygame
pygame.init()
screen = pygame.display.set_mode((WIDTH, WIDTH))
pygame.display.set_caption('Leaf Animation')
clock = pygame.time.Clock()

# Colors
WHITE = (255, 255, 255)
BLACK = (19, 19, 19)
LEAF_COLOR = (0, 0, 255)
WORM_COLOR = (255, 0, 0)
LINE_COLOR = (0, 128, 0)

start_time = pygame.time.get_ticks()
leaf_progress = 0
worm_progress = 0
next_attack_idx = 0

playback_dot_swell_factor = 1


def render_bg_surface(upscale_factor=4):
    surface = pygame.Surface((WIDTH * upscale_factor, WIDTH * upscale_factor), pygame.SRCALPHA)

    # Draw leaf and worm paths
    pygame.draw.lines(surface, WHITE, True, [(int(x * WIDTH* upscale_factor), int(y * WIDTH* upscale_factor))
                                             for x, y in leaf_ring_coords], LEAF_OUTLINE_LINE_WIDTH * upscale_factor)
    pygame.draw.lines(surface, (100, 100, 100), False, [(int(x * WIDTH* upscale_factor), int(y * WIDTH* upscale_factor))
                                                        for x, y in worm_curve_coords], LEAF_MINER_LINE_WIDTH * upscale_factor)

    return pygame.transform.smoothscale(surface, (WIDTH, WIDTH))


leaf_bg = render_bg_surface(4)


def play_note():
    global playback_dot_swell_factor
    playback_dot_swell_factor = PLAYBACK_DOT_SWELL_FACTOR
    vibes.end_all_notes()
    pitch = get_pitch_from_length(math.hypot(
        (worm_position[0] - leaf_position[0]), (worm_position[1] - leaf_position[1])
    ))
    vibes.start_note(pitch, random.uniform(0.5, 0.9))


# Main loop
running = True
while running:
    screen.fill(BLACK)
    current_time = (pygame.time.get_ticks() - start_time) / 1000  # In seconds

    # Update positions on leaf and worm paths
    leaf_progress = (leaf_progress + ROTATION_SPEED * clock.get_time() / 1000) % leaf_ring.length
    worm_progress = (worm_progress + WORM_SPEED * clock.get_time() / 1000) % worm_curve.length

    # Leaf position on leaf ring
    leaf_position = leaf_ring.interpolate(leaf_progress).coords[0]
    leaf_pos = (int(leaf_position[0] * WIDTH), int(leaf_position[1] * WIDTH))

    # Worm position on worm curve
    worm_position = worm_curve.interpolate(worm_progress).coords[0]
    worm_pos = (int(worm_position[0] * WIDTH), int(worm_position[1] * WIDTH))

    # Draw background
    screen.blit(leaf_bg, (0, 0))

    # Draw line between leaf and worm
    pygame.draw.line(screen, LINE_COLOR, leaf_pos, worm_pos, NOTE_LINE_WIDTH)

    # Draw dots for leaf and worm positions
    pygame.draw.circle(screen, LEAF_COLOR, leaf_pos, DOT_WIDTH * playback_dot_swell_factor)
    pygame.draw.circle(screen, WORM_COLOR, worm_pos, DOT_WIDTH)

    # Check if we crossed an attack point
    attack_point = attack_points[next_attack_idx]
    if abs(leaf_progress - attack_point) < ROTATION_SPEED * 0.05:
        # Trigger a visual effect (e.g., change colors or line width)
        next_attack_idx = (next_attack_idx + 1) % len(attack_points)
        play_note()

    # Event handling
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    if playback_dot_swell_factor > 1:
        playback_dot_swell_factor = max(PLAYBACK_DOT_SWELL_DECAY_FACTOR * playback_dot_swell_factor, 1)

    pygame.display.flip()
    clock.tick(60)  # Cap to 60 FPS

pygame.quit()