You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

341 lines
15 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2020, 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
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 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
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):
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 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
for i in existingIndices:
result[i] = True
return result
def _getRowWithNoneFillers(self, pitchindex):
existingSteps = self.getRow(pitchindex)
result = [None] * self.parentTrack.parentData.howManyUnits
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, 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, 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):
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): # < 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):
"""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) # 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.