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.
342 lines
16 KiB
342 lines
16 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/>.
|
|
"""
|
|
|
|
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
|
|
|
|
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.
|
|
|
|
|