#! /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 - 7 ,
" velocity " : int 0 - 127 ,
}
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 } )
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 } )
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 } )
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 * 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 } )
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 [ " 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 16 th 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
exportPattern + = cbox . Pattern . serialize_event ( startTick , 0x90 + self . parentTrack . midiChannel , pitch , velocity ) # note on
exportPattern + = cbox . Pattern . serialize_event ( endTick - 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.
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.