Barlicity

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

Download: evanstein_barlicity.py

Requires the scamp_extensions package (pip install scamp_extensions).

A large interactive piece built on harmonicity and indispensability (barlicity extension), with multidimensional-scaling visualization in Qt. This was the initial script for the piece, which ultimately became the notated work for piano and electronics that you can view here: https://www.youtube.com/watch?v=xMpET9KKOrw

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

from scamp import *
import sys
from sklearn import manifold
import numpy as np
from scamp_extensions.composers.barlicity import harmonicity, _gaussian_discount, get_indispensability_array
from scamp_extensions.pitch.utilities import midi_to_hertz, hertz_to_midi
import random
from PyQt5 import QtCore, QtWidgets, Qt
import math
from fractions import Fraction


##################################################################################################################
#                                       Setting up some of the guiding variables
##################################################################################################################

SCAN_PERIOD, WIDTH_AVERAGE, WIDTH_VARIATION, WIDTH_VARIATION_PERIOD, WIDTH_START_PHASE = \
    160, 10.0, 5.0, 20, 3*math.pi / 2

PIANO_TEMPO = 140
HARPSICHORD_TEMPO = 840

piano_distillation_timeline = Envelope((0, 1, 0), (120, 120))

##################################################################################################################
#                                             Set up the scale pitches
##################################################################################################################


root_frequency = midi_to_hertz(31)
rationalized_bark_intervals = [Fraction(1, 1), Fraction(2, 1), Fraction(3, 1), Fraction(4, 1), Fraction(5, 1),
                               Fraction(6, 1), Fraction(64, 9), Fraction(25, 3), Fraction(28, 3), Fraction(32, 3),
                               Fraction(12, 1), Fraction(64, 5), Fraction(128, 9), Fraction(63, 4), Fraction(50, 3),
                               Fraction(18, 1), Fraction(20, 1), Fraction(64, 3), Fraction(45, 2), Fraction(24, 1)]
piano_scale = [hertz_to_midi(root_frequency * ratio) for ratio in rationalized_bark_intervals]
harpsichord_scale = [x - 24 for x in piano_scale[11:]] + [x - 12 for x in piano_scale[11:]] + piano_scale[11:]

##################################################################################################################
#                                        Do the multidimensional scaling
##################################################################################################################


mds = manifold.MDS(n_components=2, dissimilarity="precomputed", random_state=152)
harmonic_distances = np.array([
    [abs(1/harmonicity((top / bottom).numerator, (top / bottom).denominator)) for bottom in rationalized_bark_intervals]
    for top in rationalized_bark_intervals
])
mds_points = mds.fit(harmonic_distances).embedding_

# scale those mds_points into the range 0-1000 in x and y coordinates for drawing
point_range = min(x[0] for x in mds_points), max(x[0] for x in mds_points), \
              min(x[1] for x in mds_points), max(x[1] for x in mds_points)
scale_factor = 1000 / max(point_range[1] - point_range[0], point_range[3] - point_range[2])
mds_points *= scale_factor
mds_points += 500


##################################################################################################################
#                                                 Main QT Class
##################################################################################################################


class Barlicity(QtWidgets.QMainWindow):
    def __init__(self, points):
        super(Barlicity, self).__init__()

        # set up the scene
        scene = QtWidgets.QGraphicsScene(self)
        view = QtWidgets.QGraphicsView(scene)
        self.setCentralWidget(view)

        window_size = int(QtWidgets.QDesktopWidget().availableGeometry(self).size().height() * 0.8)
        self.resize(Qt.QSize(window_size, window_size))
        view.setSceneRect(QtCore.QRectF(0, 0, 1000, 1000))
        view.scale(window_size/1100, window_size/1100)

        # create and position the scanner circle
        self.circle = QtWidgets.QGraphicsEllipseItem(QtCore.QRectF(-50, -50, 100, 100))
        self.circle.setOpacity(0.6)
        self.circle.setBrush(Qt.QColor(0, 100, 255))
        # these two variables are set from the scamp threads, and then they are read during the "do_frame" method (which
        # operates in Qt land) and used to actually position the circle. GUI actions can only be on the main QT thread.
        self.circle_center = (500, 500)
        self.circle_width = scale_factor * (WIDTH_AVERAGE + WIDTH_VARIATION * math.sin(WIDTH_START_PHASE))
        # Having defined them, since we're on the Qt thread right now, we position the circle
        self.circle.setPos(*self.circle_center)
        self.circle.setRect(-self.circle_width / 2, -self.circle_width / 2, self.circle_width, self.circle_width)
        scene.addItem(self.circle)

        # the scanner moves around between the different points; this keeps track of where it is
        self.scanner_index = 0
        self.set_scanner_position(0, 0)

        view.setMouseTracking(True)
        def mme(evt):
            self.start()
        view.mousePressEvent = mme

        # create all of the points
        self.points_graphics = []
        for i, point in enumerate(points):
            self.points_graphics.append(
                scene.addEllipse(Qt.QRectF(point[0] - 5, point[1] - 5, 10, 10), brush=Qt.QColor(0, 0, 0))
            )
            text = scene.addText(str(i))
            text.setPos(point[0] - 30 * len(str(i)) / 2, point[1])

        self.contained_points = []
        # this checks which points are currently in the scanner and puts their indices in self.contained_points
        self.check_contained_points()

        # set up the Qt repaint to happen every 10 milliseconds by calling self.do_frame
        self.repaint_timer = QtCore.QTimer(self)
        self.repaint_timer.timeout.connect(self.do_frame)
        self.repaint_timer.start(10)
        self.repaint()

        # These scamp objects are defined when the window is shown
        self.session = self.piano = self.harpsichord = None

    def showEvent(self, a0):
        # when the window is shown, we set up scamp
        super().showEvent(a0)

    def start(self):
        self.session = Session().run_as_server()
        self.harpsichord = self.session.new_part("harpsichord")
        self.piano = self.session.new_part("piano")
        self.session.fork(self.run_scanner, name="SCANNER_CLOCK")
        piano_clock = self.session.fork(self.piano_part, initial_tempo=PIANO_TEMPO, name="PIANO_CLOCK")
        self.session.fork(self.harpsichord_part, initial_tempo=HARPSICHORD_TEMPO, name="PIANO_CLOCK")
        self.session.start_transcribing(clock=piano_clock)

    def closeEvent(self, a0):
        # when the window is closed, we stop transcribing and create a score
        super().closeEvent(a0)
        # the session only exists if the user clicked to start it
        if self.session is not None:
            self.session.kill()
        # self.session.stop_transcribing().to_score(
        #     time_signature="3/4", title="Barlicity (raw)", composer="Marc Evanstein").show_xml()

    def piano_part(self):
        piano_indispensabilities = get_indispensability_array(((3, 2), 3, 2), normalize=True)
        last_piano_note_pool = None
        self.piano.send_midi_cc(64, 1.0)
        while True:
            which_beat = int(round((get_beat() * 2) % len(piano_indispensabilities)))
            this_indispensability = piano_indispensabilities[which_beat]
            note_pool = [piano_scale[i] for i in self.contained_points]

            if last_piano_note_pool != note_pool:
                self.piano.send_midi_cc(64, 0.0)
                self.piano.send_midi_cc(64, 1.0)

            last_piano_note_pool = note_pool

            syncopation_prob = 1 - ((self.circle_width / scale_factor - WIDTH_AVERAGE) / (2 * WIDTH_VARIATION) + 0.5)
            piano_distillation_factor = piano_distillation_timeline.value_at(get_time())

            # spensability is either indispensability or dispensability, depending on whether it's syncopated
            spensability = this_indispensability if random.random() < syncopation_prob else 1 - this_indispensability

            if len(note_pool) > 0 and spensability >= piano_distillation_factor:
                # distillation allows only the most "spensible" beats to play, and to play with bigger chords
                # at distillation_factor 0, the number of notes should be 1,
                # at distillation_factor 1 and spensibility 1, it should by all the notes
                num_notes = int(1 + round(piano_distillation_factor * spensability * (len(note_pool) - 1)))
                first_note_index = int(0.999 * spensability * (len(note_pool) - num_notes + 1))

                volume = 0.4 + 0.5 * this_indispensability
                self.piano.play_chord(note_pool[first_note_index: first_note_index + num_notes], volume, 0.5)
            else:
                wait(0.5)

    def harpsichord_part(self):
        harpsichord_indispensabilities = get_indispensability_array((2, 3, (3, 2)), normalize=True)

        running = False
        scale_index = None
        direction = None
        octave_transposition = 0

        while True:
            runniness = 0.02 + 0.98 * piano_distillation_timeline.value_at(get_time())
            which_beat = int(round((get_beat()) % len(harpsichord_indispensabilities)))
            this_indispensability = harpsichord_indispensabilities[which_beat]

            if not running:
                # if not running, a high runniness and a high indispensibility will tend to start a run
                if random.random() < runniness * this_indispensability:
                    running = True
                    octave_transposition = Barlicity.get_an_octave_transposition(get_time())
                    # play the first note "on the beat"
                    scale_index = random.randrange(len(harpsichord_scale) // 3) + len(harpsichord_scale) // 3
                    direction = random.choice([-1, 1])
                    self.harpsichord.play_note(harpsichord_scale[scale_index] + octave_transposition, 0.9, 1.0)
                else:
                    wait(1)
            else:
                # play the last note "on the beat"
                scale_index += direction
                self.harpsichord.play_note(harpsichord_scale[scale_index] + octave_transposition, 0.9, 1.0)

                if random.random() * 1.5 < this_indispensability or \
                        scale_index == 0 or scale_index == len(harpsichord_scale) - 1:
                    # change run direction if on an important beat or at the top or bottom
                    direction *= -1

                # if running, a low runniness and a high indispensibility will tend to stop a run
                if random.random() < (1 - runniness) * this_indispensability:
                    running = False

    @staticmethod
    def get_an_octave_transposition(time_passed):
        most_octaves_transposed = min(int((time_passed / 140.0) * 2), 2)

        transposition = random.choice([-1, 1]) * random.choice(
            [max(most_octaves_transposed - 1, 0), most_octaves_transposed])
        return transposition * 12

    def set_scanner_position(self, t, dt):
        self.circle_width = scale_factor * (
                WIDTH_AVERAGE + WIDTH_VARIATION *
                math.sin(2 * math.pi * t / WIDTH_VARIATION_PERIOD + WIDTH_START_PHASE)
        )

        total_multiplier = 0
        new_location = np.array([0.0, 0.0])
        for i, point in enumerate(mds_points):
            index_distance = min((i - self.scanner_index) % len(mds_points),
                                 (self.scanner_index - i) % len(mds_points))
            index_discount = _gaussian_discount(index_distance, 0, 0.45)
            total_multiplier += index_discount
            new_location[0] += point[0] * index_discount
            new_location[1] += point[1] * index_discount

        new_location /= total_multiplier
        scanner_location = new_location
        self.circle_center = scanner_location
        self.scanner_index = (self.scanner_index + dt * len(mds_points) / SCAN_PERIOD) % len(mds_points)

    def run_scanner(self):
        while True:
            wait(0.05)
            self.set_scanner_position(self.session.time, 0.05)

    def do_frame(self):
        self.circle.setPos(*self.circle_center)
        self.circle.setRect(-self.circle_width/2, -self.circle_width/2, self.circle_width, self.circle_width)
        self.check_contained_points()
        super().repaint()

    def check_contained_points(self):
        contained_points = []
        for i, point in enumerate(self.points_graphics):
            if self.circle.collidesWithItem(point):
                contained_points.append(i)
        self.contained_points = contained_points


app = QtWidgets.QApplication(sys.argv)

w = Barlicity(mds_points)
w.show()

app.exec_()