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