#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2019 , 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
#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 dicts is an step , or a note .
{ " index " : int 0 - timesigLength , " 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 . parentScore . 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
@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 . parentScore . 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 . parentScore . 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 . parentScore . 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 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 . parentScore . 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 . _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 - 1 ) # 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.