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.
219 lines
10 KiB
219 lines
10 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net )
|
|
|
|
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
|
|
|
|
This application 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
|
|
|
|
#Third Party Modules
|
|
from calfbox import cbox
|
|
|
|
#Template Modules
|
|
from template.engine.data import Data as TemplateData
|
|
import template.engine.sequencer
|
|
from template.engine.duration import D4
|
|
from template.engine.pitch import simpleNoteNames
|
|
|
|
#Our modules
|
|
from .track import Track
|
|
|
|
|
|
class Data(template.engine.sequencer.Score):
|
|
"""There must always be a Data class in a file main.py.
|
|
Simply inheriting from engine.data.Data is easiest.
|
|
|
|
You need to match the init parameters of your parent class. They vary from class to class
|
|
of course. Simply copy and paste them from your Data parent class
|
|
|
|
|
|
Pattern is our measure. Since Patroneo is not a metrical program we use the simple
|
|
traditional time signatures.
|
|
"""
|
|
|
|
def __init__(self, parentSession):
|
|
super().__init__(parentSession)
|
|
self.howManyUnits = 8
|
|
self.whatTypeOfUnit = D4
|
|
self.numberOfMeasures = 64
|
|
self.measuresPerGroup = 8 # meta data, has no effect on playback.
|
|
self.subdivisions = 1
|
|
self.lastUsedNotenames = simpleNoteNames["English"] #The default value for new tracks/patterns. Changed each time the user picks a new representation via api.setNoteNames . noteNames are saved with the patterns.
|
|
|
|
#Create three tracks with their first pattern activated, so 'play' after startup already produces sounding notes. This is less confusing for a new user.
|
|
self.addTrack(name="Melody A", color="#ffff00")
|
|
self.addTrack(name="Bass A", color="#00ff00")
|
|
self.addTrack(name="Drums A", color="#ff5500")
|
|
self.tracks[0].structure=set((0,))
|
|
self.tracks[1].structure=set((0,))
|
|
self.tracks[1].pattern.scale = (48, 47, 45, 43, 41, 40, 38, 36) #Low base notes, C-Major
|
|
self.tracks[2].structure=set((0,))
|
|
self.tracks[2].pattern.simpleNoteNames = simpleNoteNames["Drums GM"]
|
|
self.tracks[2].pattern.scale = (49, 53, 50, 45, 42, 39, 38, 36) #A pretty good starter drum set
|
|
|
|
self._processAfterInit()
|
|
|
|
def _processAfterInit(self):
|
|
pass
|
|
|
|
def addTrack(self, name="", scale=None, color=None, simpleNoteNames=None):
|
|
"""Overrides the simpler template version"""
|
|
track = Track(parentData=self, name=name, scale=scale, color=color, simpleNoteNames=simpleNoteNames)
|
|
self.tracks.append(track)
|
|
return track
|
|
|
|
def convertSubdivisions(self, value, errorHandling):
|
|
"""Not only setting the subdivisions but also trying to scale existing notes up or down
|
|
proportinally. But only if possible."""
|
|
|
|
assert errorHandling in ("fail", "delete", "merge")
|
|
|
|
scaleFactor = value / self.subdivisions
|
|
inverseScaleFactor = self.subdivisions / value
|
|
|
|
#the easiest case. New value is bigger and a multiple of the old one. 1->everything, 2->4.
|
|
#We do need not check if the old notes have a place in the new grid because there are more new places than before
|
|
if int(scaleFactor) == scaleFactor:
|
|
assert int(scaleFactor * self.howManyUnits) == scaleFactor * self.howManyUnits
|
|
self.howManyUnits = int(scaleFactor * self.howManyUnits)
|
|
for track in self.tracks:
|
|
for step in track.pattern.data:
|
|
step["index"] = int(scaleFactor * step["index"])
|
|
step["factor"] = scaleFactor * step["factor"]
|
|
|
|
#Possible case, but needs checking.
|
|
elif int(inverseScaleFactor) == inverseScaleFactor:
|
|
assert int(scaleFactor * self.howManyUnits) == scaleFactor * self.howManyUnits
|
|
|
|
#Test, if in "fail" mode
|
|
if errorHandling == "fail":
|
|
if not int(scaleFactor * self.howManyUnits) == scaleFactor * self.howManyUnits:
|
|
return False
|
|
for track in self.tracks:
|
|
for step in track.pattern.data:
|
|
if not int(scaleFactor * step["index"]) == scaleFactor * step["index"]: #yes, not inverse.
|
|
return False
|
|
|
|
#Then apply
|
|
todelete = []
|
|
self.howManyUnits = int(scaleFactor * self.howManyUnits)
|
|
for track in self.tracks:
|
|
for step in track.pattern.data:
|
|
if errorHandling == "delete" and not int(scaleFactor * step["index"]) == scaleFactor * step["index"]:
|
|
todelete.append(step)
|
|
else: # if error handling was "merge" then impossible conversions will lead to step positions that can't be undone by restoring the old subdivision value.
|
|
step["index"] = int(scaleFactor * step["index"]) #yes, not inverse.
|
|
step["factor"] = scaleFactor * step["factor"]
|
|
track.pattern.data = [d for d in track.pattern.data if not d in todelete]
|
|
|
|
else: #anything involving a 3.
|
|
#test if a conversion is possible. It is possible if you could first convert to 1 manually and then back up to the target number.
|
|
#Or in other words: if only the main positions are set as steps.
|
|
if errorHandling == "fail":
|
|
if not int(scaleFactor * self.howManyUnits) == scaleFactor * self.howManyUnits:
|
|
return False
|
|
for track in self.tracks:
|
|
for step in track.pattern.data:
|
|
if step["index"] % self.subdivisions: #not on a main position.
|
|
return False
|
|
#Test without error. Go!
|
|
self.howManyUnits = int(scaleFactor * self.howManyUnits)
|
|
todelete = []
|
|
for track in self.tracks:
|
|
for step in track.pattern.data:
|
|
if errorHandling == "delete" and not int(scaleFactor * step["index"]) == scaleFactor * step["index"]:
|
|
todelete.append(step)
|
|
step["index"] = int(scaleFactor * step["index"]) #yes, not inverse.
|
|
step["factor"] = scaleFactor * step["factor"]
|
|
track.pattern.data = [d for d in track.pattern.data if not d in todelete]
|
|
|
|
self.subdivisions = value
|
|
return True
|
|
|
|
|
|
def buildAllTracks(self, buildSongDuration=False):
|
|
"""Includes all patterns.
|
|
|
|
buildSongDuration is True at least once in the programs life time, on startup.
|
|
If True it will reset the loop. The api calls buildSongDuration directly when it sets
|
|
the loop.
|
|
"""
|
|
for track in self.tracks:
|
|
track.pattern.buildExportCache()
|
|
track.buildTrack()
|
|
if buildSongDuration:
|
|
self.buildSongDuration()
|
|
|
|
def buildSongDuration(self, loopMeasureAroundPpqn=None):
|
|
"""Loop does not reset automatically. We keep it until explicitely changed.
|
|
If we do not have a loop the song duration is already maxTrackDuration, no update needed."""
|
|
oneMeasureInTicks = (self.howManyUnits * self.whatTypeOfUnit) / self.subdivisions
|
|
oneMeasureInTicks = int(oneMeasureInTicks)
|
|
maxTrackDuration = self.numberOfMeasures * oneMeasureInTicks
|
|
if loopMeasureAroundPpqn is None: #could be 0
|
|
cbox.Document.get_song().set_loop(maxTrackDuration, maxTrackDuration) #set playback length for the entire score. Why is the first value not zero? That would create an actual loop from the start to end. We want the song to play only once. The cbox way of doing that is to set the loop range to zero at the end of the track. Zero length is stop.
|
|
else:
|
|
loopMeasure = int(loopMeasureAroundPpqn / oneMeasureInTicks) #0 based
|
|
start = loopMeasure * oneMeasureInTicks
|
|
end = start + oneMeasureInTicks
|
|
cbox.Document.get_song().set_loop(start, end) #set playback length for the entire score. Why is the first value not zero? That would create an actual loop from the start to end. We want the song to play only once. The cbox way of doing that is to set the loop range to zero at the end of the track. Zero length is stop.
|
|
return start, end
|
|
|
|
#Save / Load / Export
|
|
|
|
def serialize(self)->dict:
|
|
dictionary = super().serialize()
|
|
dictionary.update( { #update in place
|
|
"howManyUnits" : self.howManyUnits,
|
|
"whatTypeOfUnit" : self.whatTypeOfUnit,
|
|
"numberOfMeasures" : self.numberOfMeasures,
|
|
"measuresPerGroup" : self.measuresPerGroup,
|
|
"subdivisions" : self.subdivisions,
|
|
"lastUsedNotenames" : self.lastUsedNotenames,
|
|
})
|
|
return dictionary
|
|
|
|
@classmethod
|
|
def instanceFromSerializedData(cls, parentSession, serializedData):
|
|
self = cls.__new__(cls)
|
|
self.howManyUnits = serializedData["howManyUnits"]
|
|
self.whatTypeOfUnit = serializedData["whatTypeOfUnit"]
|
|
self.numberOfMeasures = serializedData["numberOfMeasures"]
|
|
self.measuresPerGroup = serializedData["measuresPerGroup"]
|
|
self.subdivisions = serializedData["subdivisions"]
|
|
self.lastUsedNotenames = serializedData["lastUsedNotenames"]
|
|
|
|
#Tracks depend on the rest of the data already in place because they create a cache on creation.
|
|
super().copyFromSerializedData(parentSession, serializedData, self) #Tracks, parentSession and tempoMap
|
|
|
|
return self
|
|
|
|
def export(self):
|
|
return {
|
|
"numberOfTracks" : len(self.tracks),
|
|
"howManyUnits" : self.howManyUnits,
|
|
"whatTypeOfUnit" : self.whatTypeOfUnit,
|
|
"numberOfMeasures" : self.numberOfMeasures,
|
|
"measuresPerGroup" : self.measuresPerGroup,
|
|
"subdivisions" : self.subdivisions,
|
|
"isTransportMaster" : self.tempoMap.export()["isTransportMaster"],
|
|
}
|
|
|
|
|
|
|