#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2021, Nils Hilbricht, Germany ( https://www.hilbricht.net ) This file is part of Patroneo ( https://www.laborejo.org ) Laborejo is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ import logging; logger = logging.getLogger(__name__); logger.info("import") #Standard Library Modules from typing import List, Set, Dict, Tuple from warnings import warn from statistics import median #Third Party Modules from calfbox import cbox NUMBER_OF_STEPS = 8 #for exchange with the GUI: one octave of range per pattern. This is unlikely to change and then will not work immediately, but better to keep it as a named "constant". DEFAULT_VELOCITY = 90 class Pattern(object): """A pattern can be in only one track. In fact having it as its own object is only for code readability A pattern is an unordered list of dicts. Each dict is an step, or a note. {"index": from 0 to parentTrack.parentData.howManyUnits, "factor": float, "pitch": int 0-7, "velocity":int 0-127, } The pitch is determined by an external scale, which is a list of len 7 of midi pitches. Our "pitch" is an index in this list. The scale works in screen coordinates(rows and columns). So usually the highest values comes first. We do force the correct order of on and off for the same pitch by cutting off the previous note. The pattern has always its maximum length. """ def __init__(self, parentTrack, data:List[dict]=None, scale:Tuple[int]=None, simpleNoteNames:List[str]=None): self._prepareBeforeInit() self.parentTrack = parentTrack self.scale = scale if scale else (72, 71, 69, 67, 65, 64, 62, 60) #Scale needs to be set first because on init/load data already depends on it, at least the default scale. The scale is part of the track meta callback. self.data = data if data else list() #For content see docstring. this cannot be the default parameter because we would set the same list for all instances. self.simpleNoteNames = simpleNoteNames if simpleNoteNames else self.parentTrack.parentData.lastUsedNotenames[:] #This is mostly for the GUI or other kinds of representation instead midi notes assert self.simpleNoteNames #2.0 Pitch #Explanation: Why don't we just do 12 steps per ocatve and then leave steps blank to create a scale? #We still want the: User set steps, not pitch idiom. #self.stepsPerOctave = 7 # This is not supposed to go below 7! e.g. Pentatonic scales are done by leaving steps out with self.blankSteps. #self.nrOfSteps = 8 # Needs to be >= stepsPerOctave. stepsPerOctave+1 will, by default, result in a full scale plus its octave. That can of course later be changed. #self.blankSteps = [] # Finally, some of the steps, relative to the octave, will be left blank and mute. This should create a visible gap in the GUI. Use this for pentatonic. self._processAfterInit() def _prepareBeforeInit(self): self._cachedTransposedScale = {} def _processAfterInit(self): self._tonalRange = range(-1*NUMBER_OF_STEPS+1, NUMBER_OF_STEPS) self.averageVelocity = DEFAULT_VELOCITY # cached on each build self._exportCacheVersion = 0 # increased each time the cache is renewed. Can be used to check for changes in the pattern itself. self.exportCache = [] #filled in by self.buildExportCache. Used by parentTrack.export() to send to the GUI self.buildExportCache() self._builtPatternCache = {} #holds a ready cbox pattern for a clip as value. Key is a tuple of hashable parameters. see self.buildPattern def copy(self, newParentTrack): """Return an independent copy of this pattern""" data = [note.copy() for note in self.data] #list of mutable dicts. Dicts have only primitve data types inside scale = self.scale #it is immutable so there is no risk of changing it in place for both patterns at once simpleNoteNames = self.simpleNoteNames[:] #this mutable list always gets replaced completely by setting a new list, but we don't want to take any chances and create a slice copy. result = Pattern(newParentTrack, data, scale, simpleNoteNames) return result @property def scale(self): return self._scale @scale.setter def scale(self, value): """The scale can never be modified in place! Only replace it with a different list. For that reason we keep scale an immutable tuple instead of a list""" self._scale = tuple(value) self.createCachedTonalRange() @property def simpleNoteNames(self): return self._simpleNoteNames @simpleNoteNames.setter def simpleNoteNames(self, value): self._simpleNoteNames = tuple(value) #we keep it immutable, this is safer to avoid accidental linked structures when creating a clone. self.parentTrack.parentData.lastUsedNotenames = self._simpleNoteNames #new default for new tracks def fill(self): """Create a 2 dimensional array""" l = len(self.scale) lst = [] vel = self.averageVelocity for index in range(self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator): for pitchindex in range(l): lst.append({"index":index, "factor": 1, "pitch": pitchindex, "velocity":vel}) self.data = lst def empty(self): self.data = [] def invert(self): l = len(self.scale) lst = [] existing = [(d["index"], d["pitch"]) for d in self.data] vel = self.averageVelocity for index in range(self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator): for pitchindex in range(l): if not (index, pitchindex) in existing: lst.append({"index":index, "factor": 1, "pitch": pitchindex, "velocity":vel}) self.data = lst def getRow(self, pitchindex): """Returns a row of steps, sorted by index/column. Includes the original mutable dictionaries, which can be changed """ return sorted([d for d in self.data if d["pitch"] == pitchindex], key = lambda i: i["index"]) def _putRow(self, pitchindex, rowAsListOfSteps): """Replace a row with the given one""" self.clearRow(pitchindex) self.data.extend(rowAsListOfSteps) def clearRow(self, pitchindex): """pure convenience. This could be done with repeatFromStep on the first empty step""" existingSteps = self.getRow(pitchindex) for step in existingSteps: self.data.remove(step) def _rowAsBooleans(self, pitchindex): """Existence or not""" existingSteps = self.getRow(pitchindex) existingIndices = set(s["index"] for s in existingSteps) result = [False] * self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator for i in existingIndices: result[i] = True return result def _getRowWithNoneFillers(self, pitchindex): existingSteps = self.getRow(pitchindex) result = [None] * self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator for st in existingSteps: result[st["index"]] = st return result def _old_repeatFromStep(self, pitchindex, stepIndex): """Includes the given step. Uses average velocities """ vel = self.averageVelocity rowAsBools = self._rowAsBooleans(pitchindex) toRepeatChunk = rowAsBools[:stepIndex+1] numberOfRepeats, rest = divmod(self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator, stepIndex+1) index = 0 newRow = [] for i in range(numberOfRepeats): for b in toRepeatChunk: if b: newRow.append({"index":index, "factor": 1, "pitch": pitchindex, "velocity":vel}) index += 1 self._putRow(pitchindex, newRow) def repeatFromStep(self, pitchindex, stepIndex): """Includes the given step. Uses original velocities and scale factors """ originalRow = self._getRowWithNoneFillers(pitchindex) toRepeatChunk = originalRow[:stepIndex+1] numberOfRepeats, rest = divmod(self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator, stepIndex+1) newRow = [] for i in range(numberOfRepeats): for st in toRepeatChunk: if st: s = st.copy() s["index"] = len(toRepeatChunk)*i + s["index"] newRow.append(s) self._putRow(pitchindex, newRow) def invertRow(self, pitchindex): vel = self.averageVelocity existingSteps = self.getRow(pitchindex) existingIndices = set(s["index"] for s in existingSteps) for step in existingSteps: self.data.remove(step) for index in range(self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator): if not index in existingIndices: self.data.append({"index":index, "factor": 1, "pitch": pitchindex, "velocity":vel}) def stepByIndexAndPitch(self, index, pitch): for d in self.data: if d["index"] == index and d["pitch"] == pitch: return d return None def createCachedTonalRange(self): """Take the existing scale and generate all possible notes for it. This way we don't need to regenerate them each time the pattern change. This is meant to return a midi pitch. However, not all 128 are possible.""" #We create three full octaves because the code is much easier to write and read. no modulo, no divmod. #We may not present all of them to the user. self._cachedTransposedScale.clear() for step in range(NUMBER_OF_STEPS): self._cachedTransposedScale[step] = self.scale[step] if not step+7 in self._cachedTransposedScale: self._cachedTransposedScale[step+7] = self.scale[step] - 12 #yes, that is correct. We do top-bottom for our steps. if not step-7 in self._cachedTransposedScale: self._cachedTransposedScale[step-7] = self.scale[step] + 12 def buildExportCache(self): """Called by the api directly and once on init/load""" self.exportCache = [] #only used by parentTrack.export() assert self.scale and len(self.scale) == NUMBER_OF_STEPS #from constants for pattern in (p for p in self.data if p["index"] < self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator): # < and not <= because index counts from 0 but howManyUnits counts from 1 note = {} note["pitch"] = pattern["pitch"] note["index"] = pattern["index"] note["factor"] = pattern["factor"] note["velocity"] = pattern["velocity"] note["midipitch"] = self.scale[pattern["pitch"]] #we always report the untransposed note. For a GUI the pattern gets transposed, not the note. note["exceedsPlayback"] = False #This is set by buildPattern. self.exportCache.append(note) #self.averageVelocity = int( sum((n["velocity"] for n in self.data)) / len(self.data) ) if self.data else DEFAULT_VELOCITY self.averageVelocity = int(median(n["velocity"] for n in self.data)) if self.data else DEFAULT_VELOCITY self._exportCacheVersion += 1 #used by build pattern for its cache hash def buildPattern(self, scaleTransposition, halftoneTransposition, howManyUnits, whatTypeOfUnit, subdivisions, patternLengthMultiplicator): """return a cbox pattern ready to insert into a cbox clip. This is the function to communicate with the outside, e.g. the track. All ticks are relativ to the pattern start We cache internally, but for the outside it looks like we generate a new pattern each time on each little note update or transposition change. The cache restores the cbox-pattern, not the note configuration in our python data. It is used for placing the same pattern into multiple measures. But _not_ to return to a note configuration we already had once. If you change the pattern itself the cache is cleared. For that we keep the _exportCacheVersion around. Actually we do not clear the cache, we simply let it sit. #TODO: since exportCacheVersion is basically a very basic hash of the grid status we could use it to also cache buildExportCache. Profile on a slow system first! Relevant for caching: scaleTransposition halftoneTransposition howManyUnits whatTypeOfUnit subdivisions scale (as tuple so it is hashable) self._exportCacheVersion patternLengthMultiplicator _cachedTransposedScale is updated with self.scale changes and therefore already covered. """ cacheHash = (scaleTransposition, halftoneTransposition, howManyUnits, whatTypeOfUnit, subdivisions, tuple(self.scale), self._exportCacheVersion, patternLengthMultiplicator) try: return self._builtPatternCache[cacheHash] except KeyError: pass oneMeasureInTicks = howManyUnits * whatTypeOfUnit oneMeasureInTicks /= subdivisions #subdivisions is 1 by default. bigger values mean shorter durations, which is compensated by the user setting bigger howManyUnits manually. oneMeasureInTicks = int(oneMeasureInTicks) * patternLengthMultiplicator exportPattern = bytes() for noteDict in self.exportCache: index = noteDict["index"] startTick = index * whatTypeOfUnit endTick = startTick + noteDict["factor"] * whatTypeOfUnit startTick /= subdivisions endTick /= subdivisions startTick = int(startTick) endTick = int(endTick) if endTick > oneMeasureInTicks: endTick = oneMeasureInTicks #all note off must end at the end of the pattern noteDict["exceedsPlayback"] = True else: noteDict["exceedsPlayback"] = False velocity = noteDict["velocity"] pitch = self._cachedTransposedScale[noteDict["pitch"] + scaleTransposition] + halftoneTransposition exportPattern += cbox.Pattern.serialize_event(startTick, 0x90, pitch, velocity) # note on exportPattern += cbox.Pattern.serialize_event(endTick-1, 0x80, pitch, velocity) # note off #-1 ticks to create a small logical gap. Does not affect next note on. pattern = cbox.Document.get_song().pattern_from_blob(exportPattern, oneMeasureInTicks) self._builtPatternCache[cacheHash] = pattern return pattern #Save / Load / Export def serialize(self)->dict: return { "scale": self.scale, "data": self.data, "simpleNoteNames": self.simpleNoteNames, } @classmethod def instanceFromSerializedData(cls, parentTrack, serializedData): self = cls.__new__(cls) self._prepareBeforeInit() self.parentTrack = parentTrack #Use setters to trigger side effects like createCachedTonalRange() self.scale = serializedData["scale"] #Scale needs to be set first because on init/load data already depends on it, at least the default scale. The scale is part of the track meta callback. self.data = serializedData["data"] #For content see docstring. self.simpleNoteNames = serializedData["simpleNoteNames"] #This is mostly for the GUI or other kinds of representation instead midi notes self._processAfterInit() return self #No export. Track uses pattern data directly in its own export.