Nils
6 years ago
7 changed files with 696 additions and 148 deletions
@ -0,0 +1,242 @@ |
|||||
|
#! /usr/bin/env python3 |
||||
|
# -*- coding: utf-8 -*- |
||||
|
""" |
||||
|
Copyright 2018, 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 <http://www.gnu.org/licenses/>. |
||||
|
""" |
||||
|
|
||||
|
|
||||
|
#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, noteNames: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.noteNames = noteNames if noteNames else self.parentTrack.parentScore.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 noteNames(self): |
||||
|
return self._noteNames |
||||
|
|
||||
|
@noteNames.setter |
||||
|
def noteNames(self, value): |
||||
|
self._noteNames = tuple(value) #we keep it immutable, this is safer to avoid accidental linked structures when creating a clone. |
||||
|
self.parentTrack.parentScore.lastUsedNotenames = self._noteNames #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.parentScore.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.parentScore.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.parentScore.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, |
||||
|
"noteNames": self.noteNames, |
||||
|
} |
||||
|
|
||||
|
@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.noteNames = serializedData["noteNames"] #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. |
||||
|
|
Loading…
Reference in new issue