#! /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 DEFAULT_VELOCITY = 90 class Pattern(object): """A pattern can be in only one track. In fact a pattern IS a track. Having it as its own class is only for code readability A pattern is an unordered list of dicts. Each dict is an step, or a note. Only existing steps (Switched On) are in self.data {"index": from 0 to parentTrack.parentData.howManyUnits * stretchfactor. But can be higher, will just not be played or exported., "factor": float, "pitch": int 0-7, "velocity":int 0-127, } The pitch is determined by an external scale, which is a list 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 self._processAfterInit() def _prepareBeforeInit(self): self._cachedTransposedScale = {} def _processAfterInit(self): 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 copyData(self): """Return only the data as copy. Used by other functions and api-Undo""" data = [note.copy() for note in self.data] #list of mutable dicts. Dicts have only primitve data types inside return data def copy(self, newParentTrack): """Return an independent copy of this pattern as Pattern() instance""" data = self.copyData() 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. The number of steps are determined by the scale length """ 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 @property def numberOfSteps(self): return len(self.scale) 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(self.numberOfSteps): 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() patternOnlyCurrentHowManyUnits = (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 patternOnlyCurrentNumberOfSteps = (p for p in patternOnlyCurrentHowManyUnits if p["pitch"] < self.numberOfSteps) for pattern in patternOnlyCurrentNumberOfSteps: note = {} note["pitch"] = pattern["pitch"] note["index"] = pattern["index"] note["factor"] = pattern["factor"] #size multiplier -> longer or short note 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. Shuffle / Swing: subdivisions is the "group size" of the GUI. Default=1. GUI allows 1, 2, 3, 4 only. They are the eights, triplets and 16th notes of a measure, if we assume quarters as the base duration (->whatTypeOfUnit) We imagine shuffle as the point where the first note ends and the second begins. This divider is shifted to the left or right (earlier/later) but the overall duration of the sum remains the same. The start tick of the first note is not touched and the end tick of the second note is not touched. Shuffle is a value between -0.5 and 0.5, where 0 means no difference. 0.5 makes the second note so short it doesn't really exist -0.5 makes the first extremely short. Value experiments: 0.05 is very subtle but if you know that it is there you'll hear it 0.1 is already very audible. 0.15 is smooth, jazzy. Good for subdivisions=2, too sharp for subdivisions=4 0.25 is "hopping", does not feel like swing at all, but "classical" 0.333 is very sharp. A lookup-table function to musically map from -100% to 100% has been provided by the API api.swingPercentToAbsolute """ 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() shuffle = int(self.parentTrack.parentData.swing * whatTypeOfUnit) for noteDict in self.exportCache: index = noteDict["index"] startTick = index * whatTypeOfUnit endTick = startTick + noteDict["factor"] * whatTypeOfUnit startTick /= subdivisions endTick /= subdivisions if subdivisions > 1 and shuffle != 0 and subdivisions % 2 == 0: positionInSubdivisionGroup = index % subdivisions #0 is first note if positionInSubdivisionGroup % 2 == 0: #main beats endTick += shuffle else: #off beats startTick += shuffle 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 + self.parentTrack.midiChannel, pitch, velocity) # note on exportPattern += cbox.Pattern.serialize_event(endTick-1, 0x80 + self.parentTrack.midiChannel, 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.