Source code for kf.loops

"""Generators to simulate 'MIDI tape loops'.  The viewer classes can be found in QtConcentricLoops."""
# Inspired by Brian Eno's "Music for Airports" and
# "JavaScript Systems Music" https://teropa.info/blog/2016/07/28/javascript-systems-music.html#brian-enoambient-1-music-for-airports-2-11978

################################################################
# Written in 2019 by Garth Zeglin <garthz@cmu.edu>

# To the extent possible under law, the author has dedicated all copyright
# and related and neighboring rights to this software to the public domain
# worldwide. This software is distributed without any warranty.

# You should have received a copy of the CC0 Public Domain Dedication along with this software.
# If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.

################################################################
import math, logging
import numpy as np

# set up logger for module
log = logging.getLogger('loops')

# filter out most logging; the default is NOTSET which passes along everything
# log.setLevel(logging.WARNING)

################################################################
[docs]class ConcentricLoops(object): """Simulation of a set of concentric tape loops, each moving at a specified constant linear velocity.""" def __init__(self, channels=7): self.channels = channels # the following use a notional millimeter unit for intuition; the actual scaling is irrelevant self.radii = 25 + 12 * np.arange(self.channels) self.lengths = (2*math.pi) * self.radii self.positions = np.zeros(self.channels) self.speeds = 25.0 * np.ones(self.channels) self.t = 0.0 self.callback = None return
[docs] def connect_cycle_listener(self, callback): """Connect a callback to receive events when a loop completes a cycle. The function will receive arguments (channel, time) where channel is a zero-based integer index and time is in simulated seconds. """ self.callback = callback
[docs] def set_speed(self, axis, speed): """Set the speeds of one or more generator channels. :param axis: either a integer axis number or list of axis numbers :param speed: either a scalar speed or a list of speeds """ if isinstance(axis, int): self.speeds[axis] = speed else: for i, s in zip(axis, speed): self.speeds[i] = s
[docs] def angles(self): """Return an iterable of the current tape loop marker angles (in degrees) for rendering.""" return 360 * self.positions/self.lengths
[docs] def update_for_interval(self, interval): """Run the simulator for the given interval, generating callbacks for any events created within the interval.""" # advance each tape loop the full increment new_positions = self.positions + interval*self.speeds # detect one or more events on each tape loop, delivering the callbacks in time order while any(new_positions > self.lengths): for c in range(self.channels): # if this loop has moved past the end, possibly more than once if new_positions[c] > self.lengths[c]: # calculate the distance past the last timestamp at which each event occurred dp = self.lengths[c] - self.positions[c] if self.speeds[c] > 0: dt = dp / self.speeds[c] if self.callback is not None: self.callback(c, self.t + dt) self.positions[c] -= self.lengths[c] new_positions[c] -= self.lengths[c] self.positions = new_positions self.t += interval return