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

#! /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.