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.
447 lines
20 KiB
447 lines
20 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2022, 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
|
|
|
|
#Template Modules
|
|
from template.calfbox import cbox
|
|
|
|
DEFAULT_VELOCITY = 90
|
|
|
|
class Pattern(object):
|
|
"""A pattern can be in only one track.
|
|
In fact a pattern IS a track.
|
|
Having it as its own class is only for code readability
|
|
|
|
A pattern is an unordered list of dicts.
|
|
Each dict is an step, or a note.
|
|
Only existing steps (Switched On) are in self.data
|
|
{"index": from 0 to parentTrack.parentData.howManyUnits * stretchfactor. But can be higher, will just not be played or exported.,
|
|
"factor": float,
|
|
"pitch": int 0-maxPitch,
|
|
"velocity":int 0-127,
|
|
"split": int 1-n, (GUI restricts n=9)
|
|
}
|
|
|
|
The pitch is determined by an external scale, which is a list 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.
|
|
|
|
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
|
|
assert self.simpleNoteNames
|
|
|
|
self._processAfterInit()
|
|
|
|
def _prepareBeforeInit(self):
|
|
self._cachedTransposedScale = {}
|
|
|
|
def _processAfterInit(self):
|
|
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 copyData(self):
|
|
"""Return only the data as copy.
|
|
Used by other functions and api-Undo"""
|
|
data = [note.copy() for note in self.data] #list of mutable dicts. Dicts have only primitve data types inside
|
|
return data
|
|
|
|
def copy(self, newParentTrack):
|
|
"""Return an independent copy of this pattern as Pattern() instance"""
|
|
data = self.copyData()
|
|
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.
|
|
|
|
The number of steps are determined by the scale length
|
|
"""
|
|
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
|
|
|
|
@property
|
|
def numberOfSteps(self):
|
|
return len(self.scale)
|
|
|
|
|
|
def fill(self):
|
|
"""Create a 2 dimensional array"""
|
|
l = len(self.scale)
|
|
lst = []
|
|
vel = self.averageVelocity
|
|
for index in range(self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator):
|
|
for pitchindex in range(l):
|
|
lst.append({"index":index, "factor": 1, "pitch": pitchindex, "velocity":vel, "split":1})
|
|
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 * self.parentTrack.patternLengthMultiplicator):
|
|
for pitchindex in range(l):
|
|
if not (index, pitchindex) in existing:
|
|
lst.append({"index":index, "factor": 1, "pitch": pitchindex, "velocity":vel, "split":1})
|
|
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 * self.parentTrack.patternLengthMultiplicator
|
|
for i in existingIndices:
|
|
result[i] = True
|
|
return result
|
|
|
|
def _getRowWithNoneFillers(self, pitchindex):
|
|
existingSteps = self.getRow(pitchindex)
|
|
result = [None] * self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator
|
|
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 * self.parentTrack.patternLengthMultiplicator, 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, "split":1})
|
|
index += 1
|
|
|
|
self._putRow(pitchindex, newRow)
|
|
|
|
def repeatFromStep(self, pitchindex, stepIndex):
|
|
"""Includes the given step.
|
|
Uses original velocities, splits and scale factors
|
|
"""
|
|
originalRow = self._getRowWithNoneFillers(pitchindex)
|
|
toRepeatChunk = originalRow[:stepIndex+1]
|
|
numberOfRepeats, rest = divmod(self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator, 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 * self.parentTrack.patternLengthMultiplicator):
|
|
if not index in existingIndices:
|
|
self.data.append({"index":index, "factor": 1, "pitch": pitchindex, "velocity":vel, "split":1})
|
|
|
|
|
|
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(self.numberOfSteps):
|
|
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()
|
|
|
|
patternOnlyCurrentHowManyUnits = (p for p in self.data if p["index"] < self.parentTrack.parentData.howManyUnits * self.parentTrack.patternLengthMultiplicator) # < and not <= because index counts from 0 but howManyUnits counts from 1
|
|
patternOnlyCurrentNumberOfSteps = (p for p in patternOnlyCurrentHowManyUnits if p["pitch"] < self.numberOfSteps)
|
|
|
|
for pattern in patternOnlyCurrentNumberOfSteps:
|
|
note = {}
|
|
note["pitch"] = pattern["pitch"]
|
|
note["index"] = pattern["index"]
|
|
note["factor"] = pattern["factor"] #size multiplier -> longer or short note
|
|
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["split"] = pattern["split"]
|
|
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, patternLengthMultiplicator, stepDelay, augmentationFactor):
|
|
"""return a cbox pattern ready to insert into a cbox clip.
|
|
|
|
This is called for every measure in a track. If you change the pattern it is called
|
|
for each existing modification once (transposition, stepDelay, AugmentFactor)
|
|
|
|
This is the function to communicate with the outside, e.g. the track.
|
|
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.
|
|
|
|
All ticks are relative to the pattern start
|
|
|
|
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
|
|
patternLengthMultiplicator
|
|
stepDelay
|
|
augmentationFactor
|
|
|
|
_cachedTransposedScale is updated with self.scale changes and therefore already covered.
|
|
|
|
|
|
Shuffle / Swing:
|
|
subdivisions is the "group size" of the GUI. Default=1. GUI allows 1, 2, 3, 4 only.
|
|
They are the eights, triplets and 16th notes of a measure, if we assume quarters as the base duration (->whatTypeOfUnit)
|
|
We imagine shuffle as the point where the first note ends and the second begins.
|
|
This divider is shifted to the left or right (earlier/later) but the overall duration of the sum remains the same.
|
|
The start tick of the first note is not touched and the end tick of the second note is not touched.
|
|
|
|
Shuffle is a value between -0.5 and 0.5, where 0 means no difference.
|
|
0.5 makes the second note so short it doesn't really exist
|
|
-0.5 makes the first extremely short.
|
|
|
|
Value experiments:
|
|
0.05 is very subtle but if you know that it is there you'll hear it
|
|
0.1 is already very audible.
|
|
0.15 is smooth, jazzy. Good for subdivisions=2, too sharp for subdivisions=4
|
|
0.25 is "hopping", does not feel like swing at all, but "classical"
|
|
0.333 is very sharp.
|
|
|
|
A lookup-table function to musically map from -100% to 100% has been provided by the API
|
|
api.swingPercentToAbsolute
|
|
|
|
"""
|
|
cacheHash = (scaleTransposition, halftoneTransposition, howManyUnits, whatTypeOfUnit, subdivisions, tuple(self.scale), self._exportCacheVersion, patternLengthMultiplicator, stepDelay, augmentationFactor)
|
|
try:
|
|
return self._builtPatternCache[cacheHash]
|
|
except KeyError:
|
|
pass
|
|
|
|
#The following is only executed if the pattern was not cached yet
|
|
|
|
#If uncertain if the cache works print cacheHash to see what is really different. This function is called more than once per pattern sometimes, which is correct.
|
|
#print (cacheHash)
|
|
|
|
howManyUnits = howManyUnits * patternLengthMultiplicator
|
|
oneMeasureInTicks = howManyUnits * whatTypeOfUnit
|
|
oneMeasureInTicks /= subdivisions #subdivisions is 1 by default. bigger values mean shorter durations, which is compensated by the user setting bigger howManyUnits manually.
|
|
|
|
exportPattern = bytes()
|
|
|
|
shuffle = int(self.parentTrack.parentData.swing * whatTypeOfUnit)
|
|
|
|
#If we diminished and want to repeat the sub-pattern, create virtual extra notes.
|
|
virtualNotes = []
|
|
inverseAugmentFactor = round(0.5 + 1 / augmentationFactor)
|
|
if self.parentTrack.repeatDiminishedPatternInItself and augmentationFactor < 1:
|
|
for noteDict in self.exportCache:
|
|
for i in range(2, inverseAugmentFactor+1): #not from 1 because we already have the original notes in there with factor 1.
|
|
#indices too big will be filtered out below by absolute ticks
|
|
cp = noteDict.copy()
|
|
cp["index"] = cp["index"] + howManyUnits * (i-1)
|
|
virtualNotes.append(cp)
|
|
|
|
for noteDict in self.exportCache + virtualNotes:
|
|
if self.parentTrack.stepDelayWrapAround:
|
|
index = (noteDict["index"] + stepDelay) % howManyUnits
|
|
assert index >= 0, index
|
|
else:
|
|
index = noteDict["index"] + stepDelay
|
|
if index < 0 or index >= howManyUnits * inverseAugmentFactor:
|
|
continue #skip lost step
|
|
|
|
startTick = index * whatTypeOfUnit
|
|
endTick = startTick + noteDict["factor"] * whatTypeOfUnit
|
|
startTick /= subdivisions
|
|
endTick /= subdivisions
|
|
|
|
if subdivisions > 1 and shuffle != 0 and subdivisions % 2 == 0:
|
|
positionInSubdivisionGroup = index % subdivisions #0 is first note
|
|
if positionInSubdivisionGroup % 2 == 0: #main beats
|
|
endTick += shuffle
|
|
else: #off beats
|
|
startTick += shuffle
|
|
|
|
startTick = int(startTick * augmentationFactor)
|
|
endTick = int(endTick * augmentationFactor)
|
|
|
|
#Prevent augmented notes to start and hang when exceeding the pattern-length
|
|
if startTick >= oneMeasureInTicks-1: #-1 is important!!! Without it we will get hanging notes with e.g. factor 1.333
|
|
continue #do not create a note, at all
|
|
|
|
if endTick > oneMeasureInTicks:
|
|
endTick = oneMeasureInTicks #all note off must end at the end of the pattern
|
|
if endTick / augmentationFactor > oneMeasureInTicks: #this is only for the visuals, the GUI. For them it did not exceed, it displays the original step.
|
|
noteDict["exceedsPlayback"] = True
|
|
else:
|
|
noteDict["exceedsPlayback"] = False
|
|
else:
|
|
noteDict["exceedsPlayback"] = False
|
|
|
|
velocity = noteDict["velocity"]
|
|
pitch = self._cachedTransposedScale[noteDict["pitch"] + scaleTransposition] + halftoneTransposition
|
|
if pitch > 127:
|
|
pitch = 127
|
|
elif pitch < 0:
|
|
pitch = 0
|
|
|
|
|
|
#Split.
|
|
splitAmount = noteDict["split"]
|
|
for split in range(splitAmount):
|
|
dur = endTick-startTick
|
|
st = int(startTick + (split * dur / splitAmount)) #note on
|
|
end = int(st + dur/splitAmount -1) #note off
|
|
|
|
exportPattern += cbox.Pattern.serialize_event(st, 0x90 + self.parentTrack.midiChannel, pitch, velocity) # note on
|
|
exportPattern += cbox.Pattern.serialize_event(end-1, 0x80 + self.parentTrack.midiChannel, 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.
|
|
for noteDict in self.data:
|
|
if not "split" in noteDict: #loading pre 2.4.0 file
|
|
noteDict["split"] = 1
|
|
|
|
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.
|
|
|