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