#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2019, 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 . """ #Standard Library Modules from typing import List, Set, Dict, Tuple from warnings import warn #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 dicts is an step, or a note. {"index":int 0-timesigLength, "factor": float, "pitch", int 0-7, "velocity":int 0-127} The pitch is determined is by a 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. It is not possible to create overlapping sounds with different pitches. 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 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 @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): 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): 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 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): # < 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._exportCacheVersion += 1 #used by build pattern for its cache hash def buildPattern(self, scaleTransposition, halftoneTransposition, howManyUnits, whatTypeOfUnit, subdivisions): """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 _cachedTransposedScale is updated with self.scale changes and therefore already covered. """ cacheHash = (scaleTransposition, halftoneTransposition, howManyUnits, whatTypeOfUnit, subdivisions, tuple(self.scale), self._exportCacheVersion) try: return self._builtPatternCache[cacheHash] except KeyError: pass oneMeasureInTicks = howManyUnits * whatTypeOfUnit oneMeasureInTicks /= subdivisions #subdivisions is 1 by default. bigger values mean shorter values, which is compensated by the user setting bigger howManyUnits manually. oneMeasureInTicks = int(oneMeasureInTicks) 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-1) # 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.