Browse Source

mindless copy and paste

master
Nils 5 years ago
parent
commit
7fc1fa6f63
  1. 265
      engine/api.py
  2. 202
      engine/main.py
  3. 127
      engine/track.py
  4. 177
      qtgui/designer/mainwindow.py
  5. 414
      qtgui/designer/mainwindow.ui
  6. 98
      qtgui/mainwindow.py
  7. 882
      qtgui/pattern_grid.py
  8. 793
      qtgui/songeditor.py
  9. 138
      qtgui/timeline.py

265
engine/api.py

@ -4,6 +4,9 @@
import template.engine.api #we need direct access to the module to inject data in the provided structures. but we also need the functions directly. next line:
from template.engine.api import *
DEFAULT_VELOCITY = 90
DEFAULT_FACTOR = 1
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".
#New callbacks
class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
@ -27,15 +30,15 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
"""There is one tempo for the entire song in quarter notes per mintue.
score.isTransportMaster to False means we do not create our own changes
and leave everything to the default. Negative values are not possible"""
if session.score.isTransportMaster:
export = session.score.quarterNotesPerMinute
if session.data.isTransportMaster:
export = session.data.quarterNotesPerMinute
else:
export = None
for func in self.quarterNotesPerMinuteChanged:
func(export)
def _setPlaybackTicks(self):
ppqn = cbox.Transport.status().pos_ppqn * session.score.subdivisions
ppqn = cbox.Transport.status().pos_ppqn * session.data.subdivisions
status = _playbackStatus()
for func in self.setPlaybackTicks:
func(ppqn, status)
@ -46,13 +49,13 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
func(export)
def _timeSignatureChanged(self):
nr = session.score.howManyUnits
typ = session.score.whatTypeOfUnit
nr = session.data.howManyUnits
typ = session.data.whatTypeOfUnit
for func in self.timeSignatureChanged:
func(nr, typ)
##All patterns and tracks need updates:
for track in session.score.tracks:
for track in session.data.tracks:
self._patternChanged(track)
self._subdivisionsChanged() #update subdivisions. We do not include them in the score or track export on purpose.
@ -62,7 +65,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
You don't need to redraw anything if you don't want to. One recommendation is to
draw every n step a little more important (bigger, different color).
where n = subdivions"""
export = session.score.subdivisions
export = session.data.subdivisions
for func in self.subdivisionsChanged:
func(export)
@ -72,7 +75,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
so it is its own callback.
Use this for fast and inexpensive updates like drawing a label or adjusting the
GUI that shows your measure groups (a label each 8 measures or so)"""
export = session.score.export()
export = session.data.export()
for func in self.scoreChanged:
func(export)
@ -119,7 +122,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
This is also used when tracks get created or deleted, also on initial load.
This also includes the pattern.
"""
lst = [track.export() for track in session.score.tracks]
lst = [track.export() for track in session.data.tracks]
for func in self.numberOfTracksChanged:
func(lst)
@ -131,7 +134,7 @@ class ClientCallbacks(Callbacks): #inherits from the templates api callbacks
func(export)
def _numberOfMeasuresChanged(self):
export = session.score.export()
export = session.data.export()
for func in self.numberOfMeasuresChanged:
func(export)
@ -144,19 +147,21 @@ _templateStartEngine = startEngine
def startEngine(nsmClient):
_templateStartEngine(nsmClient)
session.inLoopMode = None # remember if we are in loop mode or not. Positive value is None or a tuple with start and end
#Send initial Callbacks to create the first GUI state.
#The order of initial callbacks must not change to avoid GUI problems.
#For example it is important that the tracks get created first and only then the number of measures
callbacksDatabase._numberOfTracksChanged()
callbacksDatabase._timeSignatureChanged()
callbacksDatabase._numberOfMeasuresChanged()
callbacksDatabase._subdivisionsChanged()
callbacksDatabase._quarterNotesPerMinuteChanged()
callbacks._numberOfTracksChanged()
callbacks._timeSignatureChanged()
callbacks._numberOfMeasuresChanged()
callbacks._subdivisionsChanged()
callbacks._quarterNotesPerMinuteChanged()
for track in session.score.tracks:
callbacksDatabase._trackMetaDataChanged(track) #for colors, scale and noteNames
for track in session.data.tracks:
callbacks._trackMetaDataChanged(track) #for colors, scale and noteNames
session.score.buildAllTracks()
session.data.buildAllTracks()
_updatePlayback()
@ -164,13 +169,13 @@ def toggleLoop():
"""Plays the current measure as loop.
Current measure is where the playback cursor is"""
if session.inLoopMode:
session.score.buildSongDuration() #no parameter removes the loop
session.data.buildSongDuration() #no parameter removes the loop
_updatePlayback()
session.inLoopMode = None
callbacksDatabase._loopChanged(None, None, None)
callbacks._loopChanged(None, None, None)
else:
now = loopMeasureAroundPpqn=cbox.Transport.status().pos_ppqn
loopStart, loopEnd = session.score.buildSongDuration(now)
loopStart, loopEnd = session.data.buildSongDuration(now)
_updatePlayback()
session.inLoopMode = (loopStart, loopEnd)
@ -178,10 +183,10 @@ def toggleLoop():
if not _playbackStatus():
cbox.Transport.play()
oneMeasureInTicks = (session.score.howManyUnits * session.score.whatTypeOfUnit) / session.score.subdivisions
oneMeasureInTicks = (session.data.howManyUnits * session.data.whatTypeOfUnit) / session.data.subdivisions
measurenumber, rest = divmod(loopStart, oneMeasureInTicks)
callbacksDatabase._loopChanged(int(measurenumber), loopStart, loopEnd)
callbacks._loopChanged(int(measurenumber), loopStart, loopEnd)
def seek(value):
@ -199,138 +204,138 @@ def toStart():
##Score
def set_quarterNotesPerMinute(value):
if value is None:
session.score.isTransportMaster = False #triggers rebuild
session.data.isTransportMaster = False #triggers rebuild
elif value == "on":
assert not session.score.isTransportMaster
assert not session.data.isTransportMaster
#keep old bpm value
session.score.isTransportMaster = True #triggers rebuild
session.data.isTransportMaster = True #triggers rebuild
else:
assert value > 0
session.score.quarterNotesPerMinute = value #triggers rebuild
session.score.isTransportMaster = True #triggers rebuild
session.data.quarterNotesPerMinute = value #triggers rebuild
session.data.isTransportMaster = True #triggers rebuild
#Does not need track rebuilding
_updatePlayback()
callbacksDatabase._quarterNotesPerMinuteChanged()
callbacks._quarterNotesPerMinuteChanged()
def set_whatTypeOfUnit(ticks):
if session.score.whatTypeOfUnit == ticks: return
session.score.whatTypeOfUnit = ticks
session.score.buildAllTracks()
if session.data.whatTypeOfUnit == ticks: return
session.data.whatTypeOfUnit = ticks
session.data.buildAllTracks()
_updatePlayback()
callbacksDatabase._timeSignatureChanged()
callbacks._timeSignatureChanged()
def set_howManyUnits(value):
if session.score.howManyUnits == value: return
session.score.howManyUnits = value
session.score.buildAllTracks()
if session.data.howManyUnits == value: return
session.data.howManyUnits = value
session.data.buildAllTracks()
_updatePlayback()
callbacksDatabase._timeSignatureChanged()
callbacks._timeSignatureChanged()
def set_subdivisions(value):
if session.score.subdivisions == value: return
session.score.subdivisions = value
session.score.buildAllTracks()
if session.data.subdivisions == value: return
session.data.subdivisions = value
session.data.buildAllTracks()
_updatePlayback()
callbacksDatabase._subdivisionsChanged()
callbacks._subdivisionsChanged()
def convert_subdivisions(value, errorHandling):
""""errorHandling can be fail, delete or merge"""
if session.score.subdivisions == value: return
result = session.score.convertSubdivisions(value, errorHandling)
if session.data.subdivisions == value: return
result = session.data.convertSubdivisions(value, errorHandling)
if result:
session.score.buildAllTracks()
session.data.buildAllTracks()
_updatePlayback()
callbacksDatabase._timeSignatureChanged() #includes subdivisions
for tr in session.score.tracks:
callbacksDatabase._patternChanged(tr)
callbacks._timeSignatureChanged() #includes subdivisions
for tr in session.data.tracks:
callbacks._patternChanged(tr)
else:
callbacksDatabase._subdivisionsChanged() #to reset the GUI value back to the working one.
callbacks._subdivisionsChanged() #to reset the GUI value back to the working one.
return result
def set_numberOfMeasures(value):
if session.score.numberOfMeasures == value: return
session.score.numberOfMeasures = value
session.score.buildSongDuration()
if session.data.numberOfMeasures == value: return
session.data.numberOfMeasures = value
session.data.buildSongDuration()
_updatePlayback()
callbacksDatabase._numberOfMeasuresChanged()
callbacksDatabase._scoreChanged() #redundant but cheap and convenient
callbacks._numberOfMeasuresChanged()
callbacks._scoreChanged() #redundant but cheap and convenient
def set_measuresPerGroup(value):
if session.score.measuresPerGroup == value: return
session.score.measuresPerGroup = value
if session.data.measuresPerGroup == value: return
session.data.measuresPerGroup = value
#No playback change
callbacksDatabase._scoreChanged()
callbacks._scoreChanged()
def changeTrackName(trackId, name):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
track.name = " ".join(name.split())
callbacksDatabase._trackMetaDataChanged(track)
callbacks._trackMetaDataChanged(track)
def changeTrackColor(trackId, colorInHex):
"""Expects "#rrggbb"""
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
assert len(colorInHex) == 7, colorInHex
track.color = colorInHex
callbacksDatabase._trackMetaDataChanged(track)
callbacks._trackMetaDataChanged(track)
def addTrack(scale=None):
if scale:
assert type(scale) == tuple
session.score.addTrack(scale=scale)
callbacksDatabase._numberOfTracksChanged()
session.data.addTrack(scale=scale)
callbacks._numberOfTracksChanged()
def createSiblingTrack(trackId):
"""Create a new track with scale, color and jack midi out the same as the given track.
The jack midi out will be independent after creation, but connected to the same instrument
(if any)"""
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
assert type(track.pattern.scale) == tuple
newTrack = session.score.addTrack(name=track.name, scale=track.pattern.scale, color=track.color, noteNames=track.pattern.noteNames) #track name increments itself from "Track A" to "Track B" or "Track 1" -> "Track 2"
newTrack = session.data.addTrack(name=track.name, scale=track.pattern.scale, color=track.color, noteNames=track.pattern.noteNames) #track name increments itself from "Track A" to "Track B" or "Track 1" -> "Track 2"
newTrack.pattern.averageVelocity = track.pattern.averageVelocity
jackConnections = cbox.JackIO.get_connected_ports(track.cboxPortName())
for port in jackConnections:
cbox.JackIO.port_connect(newTrack.cboxPortName(), port)
#Move new track to neighbour the old one.
oldIndex = session.score.tracks.index(track)
newIndex = session.score.tracks.index(newTrack)
newTrackAgain = session.score.tracks.pop(newIndex)
oldIndex = session.data.tracks.index(track)
newIndex = session.data.tracks.index(newTrack)
newTrackAgain = session.data.tracks.pop(newIndex)
assert newTrackAgain is newTrack
session.score.tracks.insert(oldIndex+1, newTrackAgain)
callbacksDatabase._numberOfTracksChanged()
session.data.tracks.insert(oldIndex+1, newTrackAgain)
callbacks._numberOfTracksChanged()
return newTrack.export()
def deleteTrack(trackId):
track = session.score.trackById(trackId)
session.score.deleteTrack(track)
if not session.score.tracks: #always keep at least one track
session.score.addTrack()
track = session.data.trackById(trackId)
session.data.deleteTrack(track)
if not session.data.tracks: #always keep at least one track
session.data.addTrack()
_updatePlayback()
callbacksDatabase._numberOfTracksChanged()
callbacks._numberOfTracksChanged()
def moveTrack(trackId, newIndex):
"""index is 0 based"""
track = session.score.trackById(trackId)
oldIndex = session.score.tracks.index(track)
track = session.data.trackById(trackId)
oldIndex = session.data.tracks.index(track)
if not oldIndex == newIndex:
session.score.tracks.pop(oldIndex)
session.score.tracks.insert(newIndex, track)
callbacksDatabase._numberOfTracksChanged()
session.data.tracks.pop(oldIndex)
session.data.tracks.insert(newIndex, track)
callbacks._numberOfTracksChanged()
#Track Switches
def setSwitches(trackId, setOfPositions, newBool):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
if newBool:
track.structure = track.structure.union(setOfPositions) #add setOfPositions to the existing one
else:
track.structure = track.structure.difference(setOfPositions) #remove everything from setOfPositions that is in the existing one, ignore entries from setOfPositions not in existing.
track.buildTrack()
_updatePlayback()
callbacksDatabase._trackStructureChanged(track)
callbacks._trackStructureChanged(track)
def setSwitch(trackId, position, newBool):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
if newBool:
if position in track.structure: return
track.structure.add(position)
@ -339,79 +344,79 @@ def setSwitch(trackId, position, newBool):
track.structure.remove(position)
track.buildTrack()
_updatePlayback()
callbacksDatabase._trackStructureChanged(track)
callbacks._trackStructureChanged(track)
return True
def trackInvertSwitches(trackId):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
"""
if track.structure:
new = set(i for i in range(max(track.structure)))
track.structure = new.difference(track.structure)
else:
track.structure = set(i for i in range(session.score.numberOfMeasures))
track.structure = set(i for i in range(session.data.numberOfMeasures))
"""
new = set(i for i in range(session.score.numberOfMeasures))
new = set(i for i in range(session.data.numberOfMeasures))
track.structure = new.difference(track.structure)
track.buildTrack()
_updatePlayback()
callbacksDatabase._trackStructureChanged(track)
callbacks._trackStructureChanged(track)
def trackOffAllSwitches(trackId):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
track.structure = set()
track.buildTrack()
_updatePlayback()
callbacksDatabase._trackStructureChanged(track)
callbacks._trackStructureChanged(track)
def trackOnAllSwitches(trackId):
track = session.score.trackById(trackId)
track.structure = set(i for i in range(session.score.numberOfMeasures))
track = session.data.trackById(trackId)
track.structure = set(i for i in range(session.data.numberOfMeasures))
track.buildTrack()
_updatePlayback()
callbacksDatabase._trackStructureChanged(track)
callbacks._trackStructureChanged(track)
def trackMergeCopyFrom(sourceTrackId, targetTrackId):
if not sourceTrackId == targetTrackId:
sourceTrack = session.score.trackById(sourceTrackId)
targetTrack = session.score.trackById(targetTrackId)
sourceTrack = session.data.trackById(sourceTrackId)
targetTrack = session.data.trackById(targetTrackId)
targetTrack.structure = targetTrack.structure.union(sourceTrack.structure)
targetTrack.whichPatternsAreScaleTransposed.update(sourceTrack.whichPatternsAreScaleTransposed)
targetTrack.whichPatternsAreHalftoneTransposed.update(sourceTrack.whichPatternsAreHalftoneTransposed)
targetTrack.buildTrack()
_updatePlayback()
callbacksDatabase._trackStructureChanged(targetTrack)
callbacks._trackStructureChanged(targetTrack)
def setSwitchScaleTranspose(trackId, position, transpose):
"""Scale transposition is flipped. lower value means higher pitch"""
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
track.whichPatternsAreScaleTransposed[position] = transpose
track.buildTrack()
_updatePlayback()
callbacksDatabase._trackStructureChanged(track)
callbacks._trackStructureChanged(track)
return True
def setSwitchHalftoneTranspose(trackId, position, transpose):
"""Halftone transposition is not flipped. Higher value means higher pitch"""
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
track.whichPatternsAreHalftoneTransposed[position] = transpose
track.buildTrack()
_updatePlayback()
callbacksDatabase._trackStructureChanged(track)
callbacks._trackStructureChanged(track)
return True
def insertSilence(howMany, beforeMeasureNumber):
"""Insert empty measures into all tracks"""
for track in session.score.tracks:
for track in session.data.tracks:
track.structure = set( (switch + howMany if switch >= beforeMeasureNumber else switch) for switch in track.structure )
track.whichPatternsAreScaleTransposed = { (k+howMany if k >= beforeMeasureNumber else k):v for k,v in track.whichPatternsAreScaleTransposed.items() }
track.whichPatternsAreHalftoneTransposed = { (k+howMany if k >= beforeMeasureNumber else k):v for k,v in track.whichPatternsAreHalftoneTransposed.items() }
callbacksDatabase._trackStructureChanged(track)
session.score.buildAllTracks()
callbacks._trackStructureChanged(track)
session.data.buildAllTracks()
_updatePlayback()
def deleteSwitches(howMany, fromMeasureNumber):
for track in session.score.tracks:
for track in session.data.tracks:
new_structure = set()
for switch in track.structure:
@ -440,22 +445,22 @@ def deleteSwitches(howMany, fromMeasureNumber):
#else: #discard all in range to delete
track.whichPatternsAreHalftoneTransposed = new_halftoneTransposed
callbacksDatabase._trackStructureChanged(track)
session.score.buildAllTracks()
callbacks._trackStructureChanged(track)
session.data.buildAllTracks()
_updatePlayback()
#Pattern Steps
def setPattern(trackId, patternList):
"""Change the whole pattern, send a callback with the whole pattern"""
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
track.pattern.data = patternList
track.pattern.buildExportCache()
track.buildTrack()
callbacksDatabase._patternChanged(track)
callbacks._patternChanged(track)
def getAverageVelocity(trackId):
"""If a GUI wants to add a new note and choose a sensible velocity it can use this function"""
return session.score.trackById(trackId).pattern.averageVelocity
return session.data.trackById(trackId).pattern.averageVelocity
def setStep(trackId, stepExportDict):
"""This is an atomic operation that only sets one switch and
@ -463,7 +468,7 @@ def setStep(trackId, stepExportDict):
will most like not listen to that callback since they
already changed the step on their side. Only useful for parallel
views."""
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
oldNote = track.pattern.stepByIndexAndPitch(index=stepExportDict["index"], pitch=stepExportDict["pitch"])
if oldNote: #modify existing note
oldNoteIndex = track.pattern.data.index(oldNote)
@ -475,66 +480,66 @@ def setStep(trackId, stepExportDict):
track.pattern.buildExportCache()
track.buildTrack()
_updatePlayback()
callbacksDatabase._stepChanged(track, stepExportDict)
callbacks._stepChanged(track, stepExportDict)
def removeStep(trackId, index, pitch):
"""Reverse of setStep"""
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
oldNote = track.pattern.stepByIndexAndPitch(index, pitch)
track.pattern.data.remove(oldNote)
track.pattern.buildExportCache()
track.buildTrack()
_updatePlayback()
callbacksDatabase._removeStep(track, index, pitch)
callbacks._removeStep(track, index, pitch)
def setScale(trackId, scale):
"""Expects a scale list or tuple from lowest index to highest.
Actual pitches don't matter."""
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
track.pattern.scale = scale
track.pattern.buildExportCache()
track.buildTrack()
_updatePlayback()
callbacksDatabase._trackMetaDataChanged(track)
callbacks._trackMetaDataChanged(track)
def setNoteNames(trackId, noteNames):
"""note names is a list of strings with length 128. One name for each midi note.
It is saved to file"""
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
track.pattern.noteNames = noteNames
callbacksDatabase._trackMetaDataChanged(track)
callbacks._trackMetaDataChanged(track)
def transposeHalftoneSteps(trackId, steps):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
track.pattern.scale = [midipitch+steps for midipitch in track.pattern.scale]
track.pattern.buildExportCache()
track.buildTrack()
_updatePlayback()
callbacksDatabase._trackMetaDataChanged(track)
callbacks._trackMetaDataChanged(track)
def patternInvertSteps(trackId):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
track.pattern.invert()
track.pattern.buildExportCache()
track.buildTrack()
_updatePlayback()
callbacksDatabase._patternChanged(track)
callbacks._patternChanged(track)
def patternOnAllSteps(trackId):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
track.pattern.fill()
track.pattern.buildExportCache()
track.buildTrack()
_updatePlayback()
callbacksDatabase._patternChanged(track)
callbacks._patternChanged(track)
def patternOffAllSteps(trackId):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
track.pattern.empty()
track.pattern.buildExportCache()
track.buildTrack()
_updatePlayback()
callbacksDatabase._patternChanged(track)
callbacks._patternChanged(track)
major = [0, 2, 4, 5, 7, 9, 11, 12] #this if sorted by pitch, lowest to highest. Patroneo works in reverse order to accomodate the row/column approach of a grid. We reverse in setScaleToKeyword
@ -569,7 +574,7 @@ schemes = [
]
def setScaleToKeyword(trackId, keyword):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
rememberRootNote = track.pattern.scale[-1] #no matter if this is the lowest or not%
scale = [x + y for x, y in zip(major, schemesDict[keyword])]
@ -579,10 +584,10 @@ def setScaleToKeyword(trackId, keyword):
track.pattern.buildExportCache()
track.buildTrack()
_updatePlayback()
callbacksDatabase._trackMetaDataChanged(track)
callbacks._trackMetaDataChanged(track)
def changePatternVelocity(trackId, steps):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
for note in track.pattern.data:
new = note["velocity"] + steps
note["velocity"] = min(max(new,0), 127)
@ -590,18 +595,18 @@ def changePatternVelocity(trackId, steps):
track.pattern.buildExportCache()
track.buildTrack()
_updatePlayback()
callbacksDatabase._patternChanged(track)
callbacks._patternChanged(track)
#Other functions
def noteOn(trackId, row):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
midipitch = track.pattern.scale[row]
cbox.send_midi_event(0x90, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutUuid)
def noteOff(trackId, row):
track = session.score.trackById(trackId)
track = session.data.trackById(trackId)
midipitch = track.pattern.scale[row]
cbox.send_midi_event(0x80, midipitch, track.pattern.averageVelocity, output=track.cboxMidiOutUuid)

202
engine/main.py

@ -22,9 +22,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging; logging.info("import {}".format(__file__))
#Standard Library Modules
#Third Party Modules
from calfbox import cbox
#Template Modules
from template.engine.data import Data as TemplateData
import template.engine.sequencer
#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.
@ -32,19 +42,187 @@ class Data(template.engine.sequencer.Score):
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
There is also engine.sequencer.Score and engine.sampler_sf2.Sf2"""
def __init__(self, parentSession, tracks=None, tempoMap=None):
super().__init__(parentSession, tracks, tempoMap)
self.addTrack("Welt")
Pattern is our measure. Since Patroneo is not a metrical program we use the simple
traditional time signatures.
"""
def __init__(self, parentSession, howManyUnits=8, whatTypeOfUnit=D4, tracks = None, numberOfMeasures = 64, measuresPerGroup=8, subdivisions=1, isTransportMaster=False, quarterNotesPerMinute=120.0):
super().__init__(parentSession, tracks, tempoMap=None)
self.howManyUnits = howManyUnits
self.whatTypeOfUnit = whatTypeOfUnit
self.numberOfMeasures = numberOfMeasures
self.measuresPerGroup = measuresPerGroup # meta data, has no effect on playback.
self.subdivisions = subdivisions
self.lastUsedNotenames = noteNames["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.
if not tracks: #Empty / New project
self.tracks = []
self.addTrack(name="Melody A", color="#ffff00")
self.tracks[0].structure=set((0,)) #Already have the first pattern activated, so 'play' after startup already produces sounding notes. This is less confusing for a new user.
self.addTrack(name="Bass A", color="#00ff00")
self.addTrack(name="Drums A", color="#ff5500")
def trackById(self, trackId):
for track in self.tracks:
if trackId == id(track):
return track
raise ValueError(f"Track {trackId} not found. Current Tracks: {[id(tr) for tr in self.tracks]}")
def addTrack(self, name="", scale=None, color=None, noteNames=None):
track = Track(parentScore=self, name=name, scale=scale, color=color, noteNames=noteNames)
self.tracks.append(track)
return track
def deleteTrack(self, track):
track.prepareForDeletion()
self.tracks.remove(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):
"""Includes all patterns.
This still needs to be followed by buildSongDuration. Explicit is better than implicit."""
for track in self.tracks:
track.pattern.buildExportCache()
track.buildTrack()
self.buildSongDuration()
def buildSongDuration(self, loopMeasureAroundPpqn=None):
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,
})
return dictionary
@classmethod
def instanceFromSerializedData(cls, parentSession, serializedData):
self = cls(parentSession=parentSession,
howManyUnits=serializedData["howManyUnits"],
whatTypeOfUnit=serializedData["whatTypeOfUnit"],
tracks=True,
numberOfMeasures=serializedData["numberOfMeasures"],
measuresPerGroup=serializedData["measuresPerGroup"],
subdivisions=serializedData["subdivisions"],
isTransportMaster=serializedData["isTransportMaster"],
quarterNotesPerMinute=serializedData["quarterNotesPerMinute"],
)
self.tracks = []
for trackSrzData in serializedData["tracks"]:
track = Track(
parentScore=self,
name=trackSrzData["name"],
structure=set(trackSrzData["structure"]),
pattern= True, #fake. Filled in right after Track got created
color=trackSrzData["color"],
whichPatternsAreScaleTransposed=trackSrzData["whichPatternsAreScaleTransposed"],
whichPatternsAreHalftoneTransposed=trackSrzData["whichPatternsAreHalftoneTransposed"],
)
track.pattern = Pattern(parentTrack=track, data=trackSrzData["data"], scale=trackSrzData["scale"], noteNames=trackSrzData["noteNames"],)
self.tracks.append(track)
return self
@classmethod
def instanceFromSerializedData(cls, parentSession, serializedData):
self = super().instanceFromSerializedData(cls, parentSession, serializedData)
class Track(template.engine.sequencer.TemplateTrack):
def __repr__(self) -> str:
return f"Client Example Track: {self.name}"
def export(self):
return {
"numberOfTracks" : len(self.tracks),
"howManyUnits" : self.howManyUnits,
"whatTypeOfUnit" : self.whatTypeOfUnit,
"numberOfMeasures" : self.numberOfMeasures,
"measuresPerGroup" : self.measuresPerGroup,
"isTransportMaster" : self.tempoMap.export()["isTransportMaster"],
}
def __init__(self, parentScore, name=None):
super().__init__(parentScore, name)
print (self)
#Dependency Injections.
template.engine.sequencer.Track = Track #Score will look for Track in its module.

127
engine/track.py

@ -0,0 +1,127 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2018, Nils Hilbricht, Germany ( https://www.hilbricht.net )
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
more specifically its template base application.
The Template Base 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/>.
"""
#Standard Library Modules
#Third Party Modules
#Template Modules
import template.engine.sequencer
#Our modules
class Track(object): #injection at the bottom of this file!
"""The pattern is same as the track, even if the GUI does not represent it that way"""
def __repr__(self) -> str:
return f"Patroneo Track: {self.name}"
#def __init__(self, parentScore, name="", structure=None, pattern=None, scale=None, color=None, whichPatternsAreScaleTransposed=None, whichPatternsAreHalftoneTransposed=None, noteNames=None):
def __init__(self, parentScore, name="", structure=None, pattern=None, scale=None, color=None, whichPatternsAreScaleTransposed=None, whichPatternsAreHalftoneTransposed=None, noteNames=None):
print (self)
self.color = color if color else "#00FFFF" # "#rrggbb" in hex. no alpha. a convenience slot for the GUI to save a color.
#User data:
self.pattern = pattern if pattern else Pattern(parentTrack = self, scale=scale, noteNames=noteNames)
self.structure = structure if structure else set() #see buildTrack(). This is the main track data structure besides the pattern. Just integers (starts at 0) as switches which are positions where to play the patterns. In between are automatic rests.
if whichPatternsAreScaleTransposed:
self.whichPatternsAreScaleTransposed = {int(k):int(v) for k,v in whichPatternsAreScaleTransposed.items()} #json saves dict keys as strings
else:
self.whichPatternsAreScaleTransposed = {} #position:integers between -7 and 7. Reversed pitch, row based: -7 is higher than 7!!
if whichPatternsAreHalftoneTransposed:
self.whichPatternsAreHalftoneTransposed = {int(k):int(v) for k,v in whichPatternsAreHalftoneTransposed.items()} #json saves dict keys as strings
else:
self.whichPatternsAreHalftoneTransposed = {} #position:integers between -7 and 7. Reversed pitch, row based: -7 is higher than 7!!
def buildTrack(self):
"""The goal is to create a cbox-track, consisting of cbox-clips which hold cbox-pattern,
generated with our own note data. The latter happens in structures_pattern.
"""
#First clean the transpositions of zeroes
self.whichPatternsAreScaleTransposed = {k:v for k,v in self.whichPatternsAreScaleTransposed.items() if v!=0 and k in self.structure}
self.whichPatternsAreHalftoneTransposed = {k:v for k,v in self.whichPatternsAreHalftoneTransposed.items() if v!=0 and k in self.structure}
oneMeasureInTicks = (self.parentScore.howManyUnits * self.parentScore.whatTypeOfUnit) / self.parentScore.subdivisions #subdivisions is 1 by default. bigger values mean shorter values, which is compensated by the user setting bigger howManyUnits manually.
oneMeasureInTicks = int(oneMeasureInTicks)
filteredStructure = [index for index in sorted(self.structure) if index < self.parentScore.numberOfMeasures] #not <= because we compare count with range
cboxclips = [o.clip for o in self.calfboxTrack.status().clips]
for cboxclip in cboxclips:
cboxclip.delete() #removes itself from the track
for index in filteredStructure:
scaleTransposition = self.whichPatternsAreScaleTransposed[index] if index in self.whichPatternsAreScaleTransposed else 0
halftoneTransposition = self.whichPatternsAreHalftoneTransposed[index] if index in self.whichPatternsAreHalftoneTransposed else 0
cboxPattern = self.pattern.buildPattern(scaleTransposition, halftoneTransposition, self.parentScore.howManyUnits, self.parentScore.whatTypeOfUnit, self.parentScore.subdivisions)
r = self.calfboxTrack.add_clip(index*oneMeasureInTicks, 0, oneMeasureInTicks, cboxPattern) #pos, pattern-internal offset, length, pattern.
######Old optimisations. Keep for later####
##########################################
#if changeClipsInPlace: #no need for track.buildTrack. Very cheap pattern exchange.
# cboxclips = [o.clip for o in self.parentTrack.calfboxTrack.status().clips]
# for cboxclip in cboxclips:
# cboxclip.set_pattern(self.cboxPattern[cboxclip.patroneoScaleTransposed])
def serialize(self)->dict:
dictionary = super().serialize()
dictionary.update( { #update in place
"color" : self.color,
"structure" : list(self.structure),
"data" : self.pattern.data,
"scale" : self.pattern.scale, #The scale is part of the track meta callback.
"noteNames" : self.pattern.noteNames, #The noteNames are part of the track meta callback.
"whichPatternsAreScaleTransposed" : self.whichPatternsAreScaleTransposed,
"whichPatternsAreHalftoneTransposed" : self.whichPatternsAreHalftoneTransposed,
})
return dictionary
@classmethod
def instanceFromSerializedData(cls, parentTrack, serializedData):
self = cls.__new__(cls)
self._name = serializedData["name"]
self.parentTrack = parentTrack
self.parentScore = parentTrack.parentScore
self._processAfterInit()
return self
def export(self)->dict:
dictionary = super().export()
dictionary.update({
"color" : self.color,
"structure" : sorted(self.structure),
"pattern": self.pattern.exportCache,
"scale": self.pattern.scale,
"noteNames": self.pattern.noteNames,
"numberOfMeasures": self.parentScore.numberOfMeasures,
"whichPatternsAreScaleTransposed": self.whichPatternsAreScaleTransposed,
"whichPatternsAreHalftoneTransposed": self.whichPatternsAreHalftoneTransposed,
})
#Dependency Injections.
template.engine.sequencer.Score.TrackClass = Track #Score will look for Track in its module.

177
qtgui/designer/mainwindow.py

@ -11,29 +11,182 @@ from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(800, 504)
MainWindow.resize(800, 600)
MainWindow.setWindowTitle("Patroneo")
self.centralwidget = QtWidgets.QWidget(MainWindow)
self.centralwidget.setObjectName("centralwidget")
self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget)
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
self.verticalLayout.setSpacing(0)
self.verticalLayout.setObjectName("verticalLayout")
self.splitter = QtWidgets.QSplitter(self.centralwidget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.splitter.sizePolicy().hasHeightForWidth())
self.splitter.setSizePolicy(sizePolicy)
self.splitter.setFrameShape(QtWidgets.QFrame.NoFrame)
self.splitter.setLineWidth(0)
self.splitter.setOrientation(QtCore.Qt.Vertical)
self.splitter.setHandleWidth(6)
self.splitter.setObjectName("splitter")
self.songArea = QtWidgets.QWidget(self.splitter)
self.songArea.setObjectName("songArea")
self.horizontalLayout = QtWidgets.QHBoxLayout(self.songArea)
self.horizontalLayout.setContentsMargins(0, 0, 0, 5)
self.horizontalLayout.setSpacing(0)
self.horizontalLayout.setObjectName("horizontalLayout")
self.widget_2 = QtWidgets.QWidget(self.songArea)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Preferred)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.widget_2.sizePolicy().hasHeightForWidth())
self.widget_2.setSizePolicy(sizePolicy)
self.widget_2.setMinimumSize(QtCore.QSize(200, 0))
self.widget_2.setMaximumSize(QtCore.QSize(200, 16777215))
self.widget_2.setObjectName("widget_2")
self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.widget_2)
self.verticalLayout_4.setContentsMargins(0, 0, 0, 0)
self.verticalLayout_4.setSpacing(0)
self.verticalLayout_4.setObjectName("verticalLayout_4")
self.widget_3 = QtWidgets.QWidget(self.widget_2)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.widget_3.sizePolicy().hasHeightForWidth())
self.widget_3.setSizePolicy(sizePolicy)
self.widget_3.setMinimumSize(QtCore.QSize(200, 30))
self.widget_3.setMaximumSize(QtCore.QSize(200, 30))
self.widget_3.setObjectName("widget_3")
self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.widget_3)
self.horizontalLayout_2.setContentsMargins(1, 1, 1, 1)
self.horizontalLayout_2.setSpacing(2)
self.horizontalLayout_2.setObjectName("horizontalLayout_2")
self.playPauseButton = QtWidgets.QPushButton(self.widget_3)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.playPauseButton.sizePolicy().hasHeightForWidth())
self.playPauseButton.setSizePolicy(sizePolicy)
self.playPauseButton.setText("play")
self.playPauseButton.setShortcut("Space")
self.playPauseButton.setFlat(False)
self.playPauseButton.setObjectName("playPauseButton")
self.horizontalLayout_2.addWidget(self.playPauseButton)
self.loopButton = QtWidgets.QPushButton(self.widget_3)
self.loopButton.setText("loop")
self.loopButton.setShortcut("")
self.loopButton.setObjectName("loopButton")
self.horizontalLayout_2.addWidget(self.loopButton)
self.toStartButton = QtWidgets.QPushButton(self.widget_3)
self.toStartButton.setText("first")
self.toStartButton.setShortcut("")
self.toStartButton.setObjectName("toStartButton")
self.horizontalLayout_2.addWidget(self.toStartButton)
self.verticalLayout_4.addWidget(self.widget_3)
self.trackEditorView = QtWidgets.QGraphicsView(self.widget_2)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Expanding)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.trackEditorView.sizePolicy().hasHeightForWidth())
self.trackEditorView.setSizePolicy(sizePolicy)
self.trackEditorView.setMinimumSize(QtCore.QSize(200, 0))
self.trackEditorView.setMaximumSize(QtCore.QSize(200, 16777215))
self.trackEditorView.setFrameShape(QtWidgets.QFrame.NoFrame)
self.trackEditorView.setFrameShadow(QtWidgets.QFrame.Plain)
self.trackEditorView.setLineWidth(0)
self.trackEditorView.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.trackEditorView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.trackEditorView.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
self.trackEditorView.setObjectName("trackEditorView")
self.verticalLayout_4.addWidget(self.trackEditorView)
self.horizontalLayout.addWidget(self.widget_2)
self.widget = QtWidgets.QWidget(self.songArea)
self.widget.setObjectName("widget")
self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.widget)
self.verticalLayout_3.setContentsMargins(0, 0, 0, 0)
self.verticalLayout_3.setSpacing(0)
self.verticalLayout_3.setObjectName("verticalLayout_3")
self.timelineView = QtWidgets.QGraphicsView(self.widget)
sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(0)
sizePolicy.setHeightForWidth(self.timelineView.sizePolicy().hasHeightForWidth())
self.timelineView.setSizePolicy(sizePolicy)
self.timelineView.setMinimumSize(QtCore.QSize(0, 30))
self.timelineView.setMaximumSize(QtCore.QSize(16777215, 30))
self.timelineView.setFrameShape(QtWidgets.QFrame.NoFrame)
self.timelineView.setFrameShadow(QtWidgets.QFrame.Plain)
self.timelineView.setLineWidth(0)
self.timelineView.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.timelineView.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.timelineView.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
self.timelineView.setObjectName("timelineView")
self.verticalLayout_3.addWidget(self.timelineView)
self.songEditorView = QtWidgets.QGraphicsView(self.widget)
self.songEditorView.setFrameShape(QtWidgets.QFrame.NoFrame)
self.songEditorView.setFrameShadow(QtWidgets.QFrame.Plain)
self.songEditorView.setLineWidth(0)
self.songEditorView.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
self.songEditorView.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
self.songEditorView.setObjectName("songEditorView")
self.verticalLayout_3.addWidget(self.songEditorView)
self.horizontalLayout.addWidget(self.widget)
self.patternArea = QtWidgets.QWidget(self.splitter)
self.patternArea.setObjectName("patternArea")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.patternArea)
self.verticalLayout_2.setContentsMargins(0, 5, 0, 0)
self.verticalLayout_2.setSpacing(0)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.gridView = QtWidgets.QGraphicsView(self.patternArea)
self.gridView.setFrameShape(QtWidgets.QFrame.NoFrame)
self.gridView.setFrameShadow(QtWidgets.QFrame.Plain)
self.gridView.setLineWidth(0)
self.gridView.setAlignment(QtCore.Qt.AlignHCenter|QtCore.Qt.AlignTop)
self.gridView.setObjectName("gridView")
self.verticalLayout_2.addWidget(self.gridView)
self.verticalLayout.addWidget(self.splitter)
MainWindow.setCentralWidget(self.centralwidget)
self.toolBar = QtWidgets.QToolBar(MainWindow)
self.toolBar.setWindowTitle("toolBar")
self.toolBar.setToolTip("")
self.toolBar.setMovable(False)
self.toolBar.setFloatable(False)
self.toolBar.setObjectName("toolBar")
MainWindow.addToolBar(QtCore.Qt.TopToolBarArea, self.toolBar)
self.menubar = QtWidgets.QMenuBar(MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 800, 20))
self.menubar.setObjectName("menubar")
self.menuInsert_Item = QtWidgets.QMenu(self.menubar)
self.menuInsert_Item.setObjectName("menuInsert_Item")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(MainWindow)
self.statusbar.setObjectName("statusbar")
MainWindow.setStatusBar(self.statusbar)
self.actionRofl = QtWidgets.QAction(MainWindow)
self.actionRofl.setObjectName("actionRofl")
self.menubar.addAction(self.menuInsert_Item.menuAction())
self.actionAddTrack = QtWidgets.QAction(MainWindow)
self.actionAddTrack.setText("Add Track")
self.actionAddTrack.setToolTip("Add a new Track")
self.actionAddTrack.setShortcut("")
self.actionAddTrack.setObjectName("actionAddTrack")
self.actionPlayPause = QtWidgets.QAction(MainWindow)
self.actionPlayPause.setText("PlayPause")
self.actionPlayPause.setShortcut("Space")
self.actionPlayPause.setObjectName("actionPlayPause")
self.actionLoop = QtWidgets.QAction(MainWindow)
self.actionLoop.setText("Loop")
self.actionLoop.setShortcut("L")
self.actionLoop.setObjectName("actionLoop")
self.actionToStart = QtWidgets.QAction(MainWindow)
self.actionToStart.setText("To Start")
self.actionToStart.setShortcut("Backspace")
self.actionToStart.setObjectName("actionToStart")
self.actionClone_Selected_Track = QtWidgets.QAction(MainWindow)
self.actionClone_Selected_Track.setText("Clone selected Track")
self.actionClone_Selected_Track.setShortcut("")
self.actionClone_Selected_Track.setObjectName("actionClone_Selected_Track")
self.toolBar.addAction(self.actionClone_Selected_Track)
self.toolBar.addAction(self.actionAddTrack)
self.retranslateUi(MainWindow)
QtCore.QMetaObject.connectSlotsByName(MainWindow)
def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
self.menuInsert_Item.setTitle(_translate("MainWindow", "Insert Item"))
self.actionRofl.setText(_translate("MainWindow", "Rofl!"))
self.actionAddTrack.setIconText(_translate("MainWindow", "Add Track"))
self.actionClone_Selected_Track.setIconText(_translate("MainWindow", "Clone selected Track"))

414
qtgui/designer/mainwindow.ui

@ -7,13 +7,366 @@
<x>0</x>
<y>0</y>
<width>800</width>
<height>504</height>
<height>600</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
<string notr="true">Patroneo</string>
</property>
<widget class="QWidget" name="centralwidget"/>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QSplitter" name="splitter">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="handleWidth">
<number>6</number>
</property>
<widget class="QWidget" name="songArea" native="true">
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>5</number>
</property>
<item>
<widget class="QWidget" name="widget_2" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>200</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>16777215</height>
</size>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QWidget" name="widget_3" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>200</width>
<height>30</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>30</height>
</size>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<property name="spacing">
<number>2</number>
</property>
<property name="leftMargin">
<number>1</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>1</number>
</property>
<property name="bottomMargin">
<number>1</number>
</property>
<item>
<widget class="QPushButton" name="playPauseButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string notr="true">play</string>
</property>
<property name="shortcut">
<string notr="true">Space</string>
</property>
<property name="flat">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="loopButton">
<property name="text">
<string notr="true">loop</string>
</property>
<property name="shortcut">
<string notr="true"/>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="toStartButton">
<property name="text">
<string notr="true">first</string>
</property>
<property name="shortcut">
<string notr="true"/>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGraphicsView" name="trackEditorView">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>200</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>16777215</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="verticalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QWidget" name="widget" native="true">
<layout class="QVBoxLayout" name="verticalLayout_3">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QGraphicsView" name="timelineView">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>30</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>30</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="verticalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
</widget>
</item>
<item>
<widget class="QGraphicsView" name="songEditorView">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="verticalScrollBarPolicy">
<enum>Qt::ScrollBarAsNeeded</enum>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="patternArea" native="true">
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>5</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QGraphicsView" name="gridView">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
<property name="lineWidth">
<number>0</number>
</property>
<property name="alignment">
<set>Qt::AlignHCenter|Qt::AlignTop</set>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QToolBar" name="toolBar">
<property name="windowTitle">
<string notr="true">toolBar</string>
</property>
<property name="toolTip">
<string notr="true"/>
</property>
<property name="movable">
<bool>false</bool>
</property>
<property name="floatable">
<bool>false</bool>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionClone_Selected_Track"/>
<addaction name="actionAddTrack"/>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
@ -23,17 +376,54 @@
<height>20</height>
</rect>
</property>
<widget class="QMenu" name="menuInsert_Item">
<property name="title">
<string>Insert Item</string>
</property>
</widget>
<addaction name="menuInsert_Item"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<action name="actionRofl">
<action name="actionAddTrack">
<property name="text">
<string notr="true">Add Track</string>
</property>
<property name="iconText">
<string>Add Track</string>
</property>
<property name="toolTip">
<string notr="true">Add a new Track</string>
</property>
<property name="shortcut">
<string notr="true"/>
</property>
</action>
<action name="actionPlayPause">
<property name="text">
<string notr="true">PlayPause</string>
</property>
<property name="shortcut">
<string notr="true">Space</string>
</property>
</action>
<action name="actionLoop">
<property name="text">
<string notr="true">Loop</string>
</property>
<property name="shortcut">
<string notr="true">L</string>
</property>
</action>
<action name="actionToStart">
<property name="text">
<string notr="true">To Start</string>
</property>
<property name="shortcut">
<string notr="true">Backspace</string>
</property>
</action>
<action name="actionClone_Selected_Track">
<property name="text">
<string>Rofl!</string>
<string notr="true">Clone selected Track</string>
</property>
<property name="iconText">
<string>Clone selected Track</string>
</property>
<property name="shortcut">
<string notr="true"/>
</property>
</action>
</widget>

98
qtgui/mainwindow.py

@ -30,8 +30,12 @@ from template.qtgui.mainwindow import MainWindow as TemplateMainWindow
from template.qtgui.menu import Menu
from template.qtgui.about import About
#User modules
#Our modules
import engine.api as api
from .songeditor import SongEditor, TrackLabelEditor
from .timeline import Timeline
from .pattern_grid import PatternGrid
from .resources import *
class MainWindow(TemplateMainWindow):
@ -51,32 +55,86 @@ class MainWindow(TemplateMainWindow):
#Do not start them all with "You can..." or "...that you can", in response to the Did you know? title.
#We use injection into the class and not a parameter because this dialog gets shown by creating an object. We can't give the parameters when this is shown via the mainWindow menu.
About.didYouKnow = [
QtCore.QCoreApplication.translate("About", "This is an example application. Extend it to your liking. Start by editing config.py")
QtCore.QCoreApplication.translate("About", "Prefer clone track over adding a new empty track when creating a new pattern for an existing 'real world' instrument."),
QtCore.QCoreApplication.translate("About", "You can run multiple Patroneo instances in parallel to create complex polyrhythms."),
QtCore.QCoreApplication.translate("About", "To revert all steps that are longer or shorter than default invert the pattern twice in a row."),
QtCore.QCoreApplication.translate("About", "Control a synth with MIDI Control Changes (CC) by routing a Patroneo track into a midi plugin that converts notes to CC."),
] + About.didYouKnow
super().__init__()
#New menu entries and template-menu overrides
self.menu.connectMenuEntry("actionAbout", lambda: print("About Dialog Menu deactivated")) #deactivates the original function
self.menu.addMenuEntry("menuEdit", "actionNils", "Nils", lambda: print("Merle"))
self.menu.connectMenuEntry("actionNils", lambda: print("Perle"))
self.start() #This shows the GUI, or not, depends on the NSM gui save setting. We need to call that after the menu, otherwise the about dialog will block and then we get new menu entries, which looks strange.
#Playback Controls
width = 65
self.ui.playPauseButton.setFixedWidth(width)
self.ui.playPauseButton.setText("")
self.ui.playPauseButton.setIcon(QtGui.QIcon(':playpause.png'))
self.ui.playPauseButton.clicked.connect(api.playPause)
self.ui.playPauseButton.setToolTip(QtCore.QCoreApplication.translate("PlaybackControls", "[Space] Play / Pause"))
self.ui.centralwidget.addAction(self.ui.actionPlayPause) #no action without connection to a widget.
self.ui.actionPlayPause.triggered.connect(self.ui.playPauseButton.click)
self.ui.loopButton.setFixedWidth(width)
self.ui.loopButton.setText("")
self.ui.loopButton.setIcon(QtGui.QIcon(':loop.png'))
self.ui.loopButton.setToolTip(QtCore.QCoreApplication.translate("PlaybackControls", "[L] Loop current Measure"))
self.ui.loopButton.clicked.connect(api.toggleLoop)
self.ui.centralwidget.addAction(self.ui.actionLoop) #no action without connection to a widget.
self.ui.actionLoop.triggered.connect(self.ui.loopButton.click)
def callback_loopButtonText(measureNumber):
if not measureNumber is None:
nrstr = str(measureNumber+1)
self.ui.loopButton.setText(nrstr)
else:
self.ui.loopButton.setText("")
api.callbacks.loopChanged.append(callback_loopButtonText)
self.ui.toStartButton.setFixedWidth(width)
self.ui.toStartButton.setText("")
self.ui.toStartButton.setIcon(QtGui.QIcon(':tostart.png'))
self.ui.toStartButton.setToolTip(QtCore.QCoreApplication.translate("PlaybackControls", "[Backspace] Jump to Start"))
self.ui.toStartButton.clicked.connect(api.toStart)
self.ui.centralwidget.addAction(self.ui.actionToStart) #no action without connection to a widget.
self.ui.actionToStart.triggered.connect(self.ui.toStartButton.click)
##Song Editor
self.ui.songEditorView.parentMainWindow = self
self.songEditor = SongEditor(parentView=self.ui.songEditorView)
self.ui.songEditorView.setScene(self.songEditor)
def dropEvent(self, event):
"""This function does not exist in the template.
It is easiest to edit it directly than to create another abstraction layer.
self.ui.trackEditorView.parentMainWindow = self
self.trackLabelEditor = TrackLabelEditor(parentView=self.ui.trackEditorView)
self.ui.trackEditorView.setScene(self.trackLabelEditor)
Having that function in the mainWindow will not make drops available for subwindows
like About or UserManual. """
if True: # remove if you want to handle file drops
for url in event.mimeData().urls():
print ("TODO: Enable this function", url)
self.ui.timelineView.parentMainWindow = self
self.timeline = Timeline(parentView=self.ui.timelineView)
self.ui.timelineView.setScene(self.timeline)
#Sync the vertical trackEditorView scrollbar (which is never shown) with the songEditorView scrollbar.
self.ui.songEditorView.setVerticalScrollBar(self.ui.trackEditorView.verticalScrollBar()) #this seems backwards, but it is correct :)
#Sync the horizontal timelineView scrollbar (which is never shown) with the songEditorView scrollbar.
self.ui.songEditorView.setHorizontalScrollBar(self.ui.timelineView.horizontalScrollBar()) #this seems backwards, but it is correct :)
##Pattern Editor
self.ui.gridView.parentMainWindow = self
self.patternGrid = PatternGrid(parentView=self.ui.gridView)
self.ui.gridView.setScene(self.patternGrid)
#There is always a track. Forcing that to be active is better than having to hide all the pattern widgets, or to disable them.
self.chooseCurrentTrack(api.session.data.tracks[0].export()) #By Grabthar's hammer, by the suns of Worvan, what a hack! #TODO: Access to the sessions data structure directly instead of api. Not good. Getter function or api @property is cleaner.
self.start() #This shows the GUI, or not, depends on the NSM gui save setting. We need to call that after the menu, otherwise the about dialog will block and then we get new menu entries, which looks strange.
for url in event.mimeData().urls():
filePath = url.toLocalFile()
#Decide here if you want only files, only directories, both etc.
if os.path.isfile(filePath) and filePath.lower().endswith(".sf2"):
linkedPath = self.nsmClient.importResource(filePath)
print ("linked sf2 into", linkedPath)
def addTrack(self):
"""Add a new track and initialize it with some data from the current one"""
scale = api.session.data.trackById(self.currentTrackId).pattern.scale #TODO: Access to the sessions data structure directly instead of api. Not good. Getter function or api @property is cleaner.
api.addTrack(scale)

882
qtgui/pattern_grid.py

@ -0,0 +1,882 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2018, 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/>.
"""
from time import time
import engine.api as api #Session is already loaded and created, no duplication.
from template.engine import pitch
from PyQt5 import QtCore, QtGui, QtWidgets
SIZE_UNIT = 40
SIZE_TOP_OFFSET = 75
SIZE_BOTTOM_OFFSET = 35
SIZE_RIGHT_OFFSET = 80
_zValuesRelativeToScene = { #Only use for objects added directly to the scene, not children.
"off" : 1,
"shadow" : 4,
"step" :5,
"scale" : 20, #so the drop down menu is above the steps
}
class PatternGrid(QtWidgets.QGraphicsScene):
"""
data example for c'4 d'8 e' f'2 in a 4/4 timesig. Actually in any timesig.
[
{"index:0", "pitch": 60, "factor": 1 , "velocity":110},
{"index:1", "pitch": 62, "factor": 0.5 , "velocity":90},
{"index:1.5", "pitch": 64, "factor": 0.5 , "velocity":80},
{"index:2", "pitch": 65, "factor": 2 , "velocity":60},
]
We delete most of our content and redraw if the timesignature changes.
We draw all steps at once, even if hidden.
If the active track changes we only change the status (color) of steps but not the
steps themselves. We do not save any track state here but always react dynamically
and sent every change we do ourselves simply with the currentTrackId
"""
def __init__(self, parentView):
super().__init__()
self.parentView = parentView
self._steps = {} # (x,y):Step()
self._labels = [] #Step numbers
self._zoomFactor = 1 # no save. We don't keep a qt config.
role = QtGui.QPalette.BrightText
self.textColor = self.parentView.parentMainWindow.fPalBlue.color(role)
self.labelColor = QtGui.QColor("black") #save for new step
self.trackName = QtWidgets.QGraphicsSimpleTextItem("")
self.trackName.setBrush(self.textColor)
self.addItem(self.trackName)
self.trackName.setPos(0,0)
self.scale = Scale(parentScene=self)
self.addItem(self.scale)
self.scale.setPos(0, SIZE_TOP_OFFSET)
velocityControlsProxy = self.addWidget(VelocityControls(parentScene=self))
velocityControlsProxy.setPos(0, 25) #we can't get the height of the track name properly. So it was trial and error...
velocityControlsProxy.setZValue(_zValuesRelativeToScene["scale"]) #so the drop down menu is above the steps
tranposeControlsProxy = self.addWidget(TransposeControls(parentScene=self))
tranposeControlsProxy.setPos(velocityControlsProxy.geometry().width() + 10, 25) #we can't get the height of the track name properly. So it was trial and error...
tranposeControlsProxy.setZValue(_zValuesRelativeToScene["scale"]) #so the drop down menu is above the steps
self._middleMouseDown = False
#self.ticksToPixelRatio set by callback_timeSignatureChanged
self.playhead = Playhead(parentScene = self)
self.addItem(self.playhead)
self.playhead.setY(SIZE_TOP_OFFSET)
api.callbacks.timeSignatureChanged.append(self.callback_timeSignatureChanged)
api.callbacks.patternChanged.append(self.callback_patternChanged)
api.callbacks.trackMetaDataChanged.append(self.callback_trackMetaDataChanged)
api.callbacks.subdivisionsChanged.append(self.guicallback_subdivisionsChanged)
def callback_timeSignatureChanged(self, howMany, typeInTicks):
"""The typeInTicks actually changes nothing visually here.
We only care about howMany steps we offer."""
self.oneMeasureInTicks = howMany * typeInTicks
self.ticksToPixelRatio = typeInTicks / SIZE_UNIT
self._redrawSteps(howMany)
def _redrawSteps(self, howMany):
"""Draw the empty steps grid. This only happens if the pattern itself changes,
for example with the time signature or with a GUI subdivision change.
Normal step on/off is done incrementally.
"""
for existingStep in self._steps.values():
self.removeItem(existingStep)
self._steps = {} # (x,y):Step()
#Build a two dimensional grid
for column in range(howMany):
for row in range(api.NUMBER_OF_STEPS):
x = column * SIZE_UNIT + SIZE_RIGHT_OFFSET
y = row * SIZE_UNIT + SIZE_TOP_OFFSET
step = Step(parentScene=self, column=column, row=row)
step.setPos(x, y)
self.addItem(step)
self._steps[(column, row)] = step
#there is always at least one column so we don't need to try step for AttributeError
w = step.x() + SIZE_UNIT + SIZE_RIGHT_OFFSET #the position of the last step plus one step width and one offset more for good measure
h = step.y() + SIZE_UNIT + SIZE_TOP_OFFSET + SIZE_BOTTOM_OFFSET #same as w
self.setSceneRect(0, 0, w, h)
def guicallback_chooseCurrentTrack(self, exportDict):
"""It is guaranteed that this only happens on a real track change, not twice the same.
During the track change the currenTrackId value is still the old
track. There we introduce a force switch that enables self.guicallback_chooseCurrentTrack
to trigger a redraw even during the track change.
"""
assert not exportDict["id"] == self.parentView.parentMainWindow.currentTrackId #this is still the old track.
#self.trackName.setText(exportDict["name"])
updateMode = self.parentView.viewportUpdateMode() #prevent iteration flickering from color changes.
self.parentView.setViewportUpdateMode(self.parentView.NoViewportUpdate)
self.callback_trackMetaDataChanged(exportDict, force=True) #we need the color when setting pattern changed. This needs to be called before patternChanged
self.callback_patternChanged(exportDict, force=True) #needs to be called after trackMetaDataChanged for the color.
self.removeShadows()
self.parentView.setViewportUpdateMode(updateMode)
def callback_patternChanged(self, exportDict, force=False):
"""We receive the whole track as exportDict.
exportDict["pattern"] is the data structure example in the class docstring.
We also receive this for every track, no matter if this our current working track.
So we check if we are the current track. However, that prevents setting up or steps
on a track change because during the track change the currenTrackId value is still the old
track. There we introduce a force switch that enables self.guicallback_chooseCurrentTrack
to trigger a redraw even during the track change.
"""
if force or exportDict["id"] == self.parentView.parentMainWindow.currentTrackId:
updateMode = self.parentView.viewportUpdateMode() #prevent iteration flickering from color changes.
self.parentView.setViewportUpdateMode(self.parentView.NoViewportUpdate)
for step in self._steps.values():
step.off()
for noteDict in exportDict["pattern"]:
x = noteDict["index"]
y = noteDict["pitch"]
velocityAndFactor = (noteDict["velocity"], noteDict["factor"])
self._steps[(x,y)].on(velocityAndFactor=velocityAndFactor, exceedsPlayback=noteDict["exceedsPlayback"])
self.scale.setScale(exportDict["scale"])
self.scale.setNoteNames(exportDict["noteNames"])
self.parentView.setViewportUpdateMode(updateMode)
#else ignore. We fetch new data when we change the track anyway.
#Deprectated. We do incremental updates now. But who knows if we need it in the future. I doubt it...
#def sendCurrentPatternToEngine(self):
# pattern = [step.export() for step in self._steps.values() if step.status] #engine compatible backend dict of the current GUI state. Send the switched on values.
# api.setPattern(trackId=self.parentView.parentMainWindow.currentTrackId, patternList=pattern)
def callback_trackMetaDataChanged(self, exportDict, force=False):
"""
During the track change the currenTrackId value is still the old
track. There we introduce a force switch that enables self.guicallback_chooseCurrentTrack
to trigger a redraw even during the track change.
"""
if force or self.parentView.parentMainWindow.currentTrackId == exportDict["id"]:
self.trackName.setText(exportDict["name"])
self.trackName.show()
c = QtGui.QColor(exportDict["color"])
self.currentColor = c
if c.lightness() > 127: #between 0 (for black) and 255 (for white)
labelColor = QtGui.QColor("black")
else:
labelColor = QtGui.QColor("white")
self.labelColor = labelColor #save for new events
for step in self._steps.values():
if step.status:
step.setBrush(c)
step.velocityNumber.setBrush(labelColor)
def guicallback_subdivisionsChanged(self, newValue):
"""handle measuresPerGroup"""
#Draw labels
for existinglabel in self._labels:
self.removeItem(existinglabel)
self._labels = []
for (x,y), step in self._steps.items():
step.main = not x % newValue
step.setApperance()
groupCounter, beatNumber = divmod(x, newValue)
if not beatNumber:
label = QtWidgets.QGraphicsSimpleTextItem(str(groupCounter+1))
self.addItem(label)
label.setBrush(self.textColor)
x = x * SIZE_UNIT
x += SIZE_RIGHT_OFFSET
label.setPos(x+3, SIZE_TOP_OFFSET-13)
self._labels.append(label)
def showVelocities(self):
for patternStep in self._steps.values():
if patternStep.status:
patternStep.velocityNumber.show()
def hideVelocities(self):
for patternStep in self._steps.values():
patternStep.velocityNumber.hide()
def mousePressEvent(self, event):
self._middleMouseDown = False
if event.button() == QtCore.Qt.MiddleButton:
self._middleMouseDown = True
self._lastRow = None
self._play(event)
event.accept()
if not type(self.itemAt(event.scenePos().x(), event.scenePos().y(), self.parentView.transform())) is Step:
self.showVelocities()
else:
event.ignore()
super().mousePressEvent(event)
def _off(self):
if not self._lastRow is None:
api.noteOff(self.parentView.parentMainWindow.currentTrackId, self._lastRow)
self._lastRow = None
def _play(self, event):
assert self._middleMouseDown
if not self.parentView.parentMainWindow.currentTrackId:
return
row = (event.scenePos().y() - SIZE_TOP_OFFSET) / SIZE_UNIT
if row >= 0:
row = int(row)
else:
row = -1
x = event.scenePos().x()
inside = x > SIZE_RIGHT_OFFSET and x < self.sceneRect().width() - SIZE_RIGHT_OFFSET
if ( row < 0 or row > 7 ) or not inside :
row = None
if not row == self._lastRow:
if not self._lastRow is None:
api.noteOff(self.parentView.parentMainWindow.currentTrackId, self._lastRow)
if not row is None:
api.noteOn(self.parentView.parentMainWindow.currentTrackId, row)
self._lastRow = row
def mouseMoveEvent(self, event):
"""Event button is always 0 in a mouse move event"""
if self._middleMouseDown:
event.accept()
self._play(event)
else:
#Not for us, trigger, let other items decide.
event.ignore()
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if self._middleMouseDown:
self._off()
self._middleMouseDown = False
if event.button() == QtCore.Qt.MiddleButton:
event.accept()
self._lastRow = None
self.hideVelocities()
else:
event.ignore()
super().mousePressEvent(event)
def contextMenuEvent(self, event):
menu = QtWidgets.QMenu()
trackId = self.parentView.parentMainWindow.currentTrackId
listOfLabelsAndFunctions = [
(QtCore.QCoreApplication.translate("EventContextMenu", "Invert Steps"), lambda: api.patternInvertSteps(trackId)),
(QtCore.QCoreApplication.translate("EventContextMenu", "All Steps On"), lambda: api.patternOnAllSteps(trackId)),
(QtCore.QCoreApplication.translate("EventContextMenu", "All Steps Off"), lambda: api.patternOffAllSteps(trackId)),
]
for text, function in listOfLabelsAndFunctions:
if function is None:
l = QtWidgets.QLabel(text)
l.setAlignment(QtCore.Qt.AlignCenter)
a = QtWidgets.QWidgetAction(menu)
a.setDefaultWidget(l)
menu.addAction(a)
else:
a = QtWidgets.QAction(text, menu)
menu.addAction(a)
a.triggered.connect(function)
pos = QtGui.QCursor.pos()
pos.setY(pos.y() + 5)
menu.exec_(pos)
def wheelEvent(self, event):
"""zoom, otherwise ignore event"""
if QtWidgets.QApplication.keyboardModifiers() == QtCore.Qt.ControlModifier:
if event.delta() > 0: #zoom in
self._zoomFactor = min(5, round(self._zoomFactor + 0.25, 2))
else: #zoom out
self._zoomFactor = max(0.1, round(self._zoomFactor - 0.25, 2))
self._zoom(event)
event.accept()
else:
event.ignore()
super().wheelEvent(event)
def _zoom(self, event):
if 0.1 < self._zoomFactor < 5:
self.parentView.resetTransform()
self.parentView.scale(self._zoomFactor, self._zoomFactor)
self.parentView.centerOn(event.scenePos())
def createShadow(self, exportDict):
"""Receives steps from another track and display them as shadoy steps in the current one
as a reference. Creating a new shadow does not delete the old one.
"""
for x, y in ((s["index"], s["pitch"]) for s in exportDict["pattern"]):
self._steps[(x,y)].shadow = True # (x,y):Step()
self._steps[(x,y)].setApperance()
def removeShadows(self):
for step in self._steps.values():
if step.shadow:
step.shadow = False
step.setApperance()
class Step(QtWidgets.QGraphicsRectItem):
"""The representation of a note"""
def __init__(self, parentScene, column, row): #Factor and Velocity are set on activation
self.parentScene = parentScene
self.column = column #grid coordinates, not pixels
self.row = row
offset = 2
self.offset = offset
self.defaultSize = (offset, offset, SIZE_UNIT-offset*2, SIZE_UNIT-offset*2) #x, y, w, h
super().__init__(*self.defaultSize)
self.setAcceptHoverEvents(True)
self.setFlags(QtWidgets.QGraphicsItem.ItemIsFocusable) #to receive key press events
self.main = True
self.exceedsPlayback = False
self.factor = api.DEFAULT_FACTOR
self.status = False
self._factorChangeAllowed = False #during drag and drop this will be True. Used in the mouse steps.
self.shadow = False
#Velocity
self._rememberVelocity = None
self.velocityNumber = QtWidgets.QGraphicsSimpleTextItem()
self.velocityNumber.setParentItem(self)
self.velocityNumber.setBrush(self.parentScene.labelColor)
self.velocityNumber.setPos(offset*2,offset*2) #that is not pretty but you can see it under the cursor
self.velocityNumber.hide() #only visible during mouse wheel event
#The data section. On creation all the steps are uninitialized. They are off and hold no musical values
#self.velocity = set in self.useDefaultValues . We don't set it at all here to have a clear Exception when the program tries to access it.
#self.factor = set in self.useDefaultValues . We don't set it at all here to have a clear Exception when the program tries to access it.
#self.pitch #this is determined by the position on the grid
self.setApperance() #sets color, size and exceedPlayback warning. not velocity.
def setApperance(self):
"""sets color, main/sub size and exceedPlayback warning. not velocity.
This gets called quite often. On mouse down and on release for starters."""
def setWidth():
if not self.exceedsPlayback and self.x() + self.rect().width() + SIZE_RIGHT_OFFSET> self.parentScene.sceneRect().right():
self.exceedsPlayback = True
if self.exceedsPlayback:
rect = self.rect()
maximumWidth = self.parentScene.sceneRect().right() - self.x() - SIZE_RIGHT_OFFSET - self.offset*2
rect.setWidth(maximumWidth)
self.setRect(rect)
else:
rect = self.rect()
rect.setWidth(SIZE_UNIT * self.factor - self.offset*2)
self.setRect(rect)
if self.status:
setWidth()
assert self.parentScene.currentColor
self.setBrush(self.parentScene.currentColor)
self.velocityNumber.setBrush(self.parentScene.labelColor)
self.setZValue(_zValuesRelativeToScene["step"])
else:
self.setOpacity(1)
if self.shadow:
setWidth()
color = self.parentScene.parentView.parentMainWindow.fPalBlue.color(QtGui.QPalette.Shadow) #this is already an existing instance
self.setOpacity(0.3)
self.setZValue(_zValuesRelativeToScene["shadow"])
elif self.main:
color = self.parentScene.parentView.parentMainWindow.fPalBlue.color(QtGui.QPalette.AlternateBase) #this is already an existing instance
self.setZValue(_zValuesRelativeToScene["off"])
else:
color = self.parentScene.parentView.parentMainWindow.fPalBlue.color(QtGui.QPalette.Base) #this is already an existing instance
self.setZValue(_zValuesRelativeToScene["off"])
self.setBrush(color)
@property
def velocity(self):
return self._velocity
@velocity.setter
def velocity(self, value):
self._velocity = value
self.velocityNumber.setText(str(value))
self.setOpacity(self._compress(value, 1, 127, 0.4, 1.0))
def _compress(self, input, inputLowest, inputHighest, outputLowest, outputHighest):
return (input-inputLowest) / (inputHighest-inputLowest) * (outputHighest-outputLowest) + outputLowest
def export(self):
"""Make a dict to send to the engine"""
return {
"index":self.column,
"pitch":self.row,
"factor":self.factor,
"velocity":self.velocity}
def useDefaultValues(self):
self.velocity = api.getAverageVelocity(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId) #already sets opacity and velocityNumber
self._rememberVelocity = self.velocity
self.factor = api.DEFAULT_FACTOR
self.initalized = True
def on(self, velocityAndFactor=None, exceedsPlayback=None):
"""velocityAndFactor is a tuple"""
if velocityAndFactor: #on load / by callback
self.velocity, self.factor = velocityAndFactor
else: #User clicked on an empty field.
self.useDefaultValues()
self.exceedsPlayback = exceedsPlayback
assert self.factor > 0
rect = self.rect()
#rect.setWidth(self.defaultSize[2] * self.factor)
rect.setWidth(SIZE_UNIT * self.factor - self.offset*2)
self.setRect(rect)
self.status = True
self.setApperance() #sets color, main/sub size and exceedPlayback warning
def off(self):
self.status = False
self.setRect(*self.defaultSize)
self.setApperance() #sets color, main/sub size and exceedPlayback warning
self.velocityNumber.hide() #just in case.
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
event.accept()
if self.status:
self.off()
api.removeStep(self.parentScene.parentView.parentMainWindow.currentTrackId, self.column, self.row)
else:
self.on()
self._factorChangeAllowed = True
self._factorStartTime = time() #see mouseReleaseEvent
else:
event.ignore()
def mouseMoveEvent(self, event):
if self._factorChangeAllowed:
# < is left to right
# > is right to left
event.accept()
rect = self.rect()
if event.lastScenePos().x() < event.scenePos().x():
new = event.scenePos().x() - self.x()
else:
new = max(self.defaultSize[2]/2, event.scenePos().x() - self.x()) #pixel values, not tick, nor factor
rect.setRight(new)
self.setRect(rect)
def mouseReleaseEvent(self, event):
if self._factorChangeAllowed:
assert self.status
self._factorChangeAllowed = False
width = self.rect().width() + self.offset*2
value = width / SIZE_UNIT
elapsedTime = time() - self._factorStartTime #to prevent hectic mouse pressing from triggering the factor we only accept a change if a certain time treshold was passed
if (elapsedTime > 0.2 and value >= 0.5):# or value == 0.5:
self.factor = value
self.setApperance() #sets color, size and exceedPlayback warning
else: # A quick mouseclick
assert self.factor == 1
self.setRect(*self.defaultSize) #we reset this in case something goes wrong. If everything is all right we will a receive a callback to set the width anyway, before the user sees anything.
self.setApperance() #sets color, size and exceedPlayback warning
api.setStep(self.parentScene.parentView.parentMainWindow.currentTrackId, self.export())
event.accept()
def hoverEnterEvent(self, event):
"""Hover events are only triggered if no mouse button is down. The mouse got grabbed
by the item. Which makes sense because we want to receive mouseRelease on an item even
if the mouse cursor is not on that item anymore"""
if self.status:
event.accept()
self._rememberVelocity = self.velocity
else:
event.ignore()
def hoverLeaveEvent(self, event):
"""Hover events are only triggered if no mouse button is down. The mouse got grabbed
by the item. Which makes sense because we want to receive mouseRelease on an item even
if the mouse cursor is not on that item anymore"""
self.velocityNumber.hide()
if self.status:
event.accept()
if self.status and not self.velocity == self._rememberVelocity:
api.setStep(self.parentScene.parentView.parentMainWindow.currentTrackId, self.export())
self._rememberVelocity = self.velocity
else:
event.ignore()
def wheelEvent(self, event):
"""This buffers until hoverLeaveEvent and then the new value is sent in self.hoverLeaveEvent"""
if self.status:
event.accept()
self.velocityNumber.show()
if event.delta() > 0:
self.velocity += 2
if self.velocity >= 127:
self.velocity = 127
else:
self.velocity -= 2
if self.velocity <= 2:
self.velocity = 1
else:
event.ignore()
class Scale(QtWidgets.QGraphicsRectItem):
def __init__(self, parentScene):
super().__init__(0,0,0,0)
self.parentScene = parentScene
self.pitchWidgets = [] #sorted from top to bottom in Step Rect and scene coordinates
self.noteNames = [] #list of 128 notes. use index with note name. Can be changed at runtime.
api.callbacks.trackMetaDataChanged.append(self.callback_trackMetaDataChanged)
self.buildScale() #also sets the positions of the buttons above
def callback_trackMetaDataChanged(self, exportDict):
#Order matters. We need to set the notenames before the scale.
self.setNoteNames(exportDict["noteNames"])
self.setScale(exportDict["scale"])
def buildScale(self):
"""Only executed once per pattern"""
for i in range(api.NUMBER_OF_STEPS):
p = PitchWidget(parentItem=self)
y = i * SIZE_UNIT
p.setParentItem(self)
p.setPos(-65, y+10)
self.pitchWidgets.append(p)
#self.setRect(0,0, SIZE_RIGHT_OFFSET, p.y() + SIZE_UNIT) #p is the last of the 8.
def setScale(self, scaleList):
"""We receive from top to bottom, in step rect coordinates. This is not sorted after
pitches! Pitches can be any order the user wants.
"""
for widget, scaleMidiPitch in zip(self.pitchWidgets, scaleList):
widget.spinBox.setValue(scaleMidiPitch)
widget.rememberLastValue = scaleMidiPitch
def setNoteNames(self, pNoteNames):
"""A list of 128 strings. Gets only called by the callback."""
#if pNoteNames in pitch.notenames.keys():
# self.noteNames = pitch.notenames[pNoteNames]
#else:
self.noteNames = pNoteNames
for pitchWidget in self.pitchWidgets:
pitchWidget.spinBoxValueChanged() #change all current pitchWidgets
def sendToEngine(self):
result = [widget.spinBox.value() for widget in self.pitchWidgets]
#result.reverse()
api.setScale(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, scale=result)
class TransposeControls(QtWidgets.QWidget):
"""Communication with the scale spinBoxes is done via api callbacks. We just fire and forget"""
#Not working. the translation generate works statically. translatedScales = [QtCore.QT_TRANSLATE_NOOP("Scale", scale) for scale in api.schemes]
#No choice but to prepare the translations manually here. At least we do not need to watch for the order.
QtCore.QT_TRANSLATE_NOOP("Scale", "Major")
QtCore.QT_TRANSLATE_NOOP("Scale", "Minor")
QtCore.QT_TRANSLATE_NOOP("Scale", "Dorian")
QtCore.QT_TRANSLATE_NOOP("Scale", "Phrygian")
QtCore.QT_TRANSLATE_NOOP("Scale", "Lydian")
QtCore.QT_TRANSLATE_NOOP("Scale", "Mixolydian")
QtCore.QT_TRANSLATE_NOOP("Scale", "Locrian")
QtCore.QT_TRANSLATE_NOOP("Scale", "Blues")
QtCore.QT_TRANSLATE_NOOP("Scale", "Hollywood")
QtCore.QT_TRANSLATE_NOOP("Scale", "English")
QtCore.QT_TRANSLATE_NOOP("Scale", "Lilypond")
QtCore.QT_TRANSLATE_NOOP("Scale", "German")
QtCore.QT_TRANSLATE_NOOP("Scale", "Drums GM")
def __init__(self, parentScene):
self.parentScene = parentScene
super().__init__()
layout = QtWidgets.QHBoxLayout()
layout.setSpacing(0)
layout.setContentsMargins(0,0,0,0)
self.setLayout(layout)
transposeUp = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("TransposeControls", "+Half Tone"))
transposeUp.clicked.connect(self.transposeUp)
transposeUp.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Transpose the whole scale up a half tone (+1 midi note)"))
layout.addWidget(transposeUp)
transposeDown = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("TransposeControls", "-Half Tone"))
transposeDown.clicked.connect(self.transposeDown)
transposeDown.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Transpose the whole scale down a half tone (-1 midi note)"))
layout.addWidget(transposeDown)
transposeUpOctave = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("TransposeControls", "+Octave"))
transposeUpOctave.clicked.connect(self.transposeUpOctave)
transposeUpOctave.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Transpose the whole scale up an octave (+12 midi notes)"))
layout.addWidget(transposeUpOctave)
transposeDownOctave = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("TransposeControls", "-Octave"))
transposeDownOctave.clicked.connect(self.transposeDownOctave)
transposeDownOctave.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Transpose the whole scale down an octave (-12 midi notes)"))
layout.addWidget(transposeDownOctave)
translatedSchemes = [QtCore.QCoreApplication.translate("Scale", scheme) for scheme in api.schemes]
transposeToScale = QtWidgets.QComboBox()
self._transposeToScaleWidget = transposeToScale
transposeToScale.addItems([QtCore.QCoreApplication.translate("TransposeControls","Set Scale to:")] + translatedSchemes) #This is a hack. QProxyWidgets will draw outside of the view and cannot be seen anymore. We reset to the 0th entry after each change.
transposeToScale.activated.connect(self.transposeToScale) #activated, not changend. even when choosing the same item
transposeToScale.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Take the bottom note and build a predefined scale from it upwards."))
layout.addWidget(transposeToScale)
self._comboBoxNoteNames = QtWidgets.QComboBox()
translatedNotenames = [QtCore.QCoreApplication.translate("Scale", scheme) for scheme in sorted(list(pitch.noteNames.keys()))]
self._comboBoxNoteNames.addItems([QtCore.QCoreApplication.translate("TransposeControls","Set Notenames to:")] + translatedNotenames)
self._comboBoxNoteNames.activated.connect(self._changeNoteNamesByDropdown) #activated, not changend. even when choosing the same item
self._comboBoxNoteNames.setToolTip(QtCore.QCoreApplication.translate("TransposeControls", "Use this scheme as note names."))
layout.addWidget(self._comboBoxNoteNames)
def _changeNoteNamesByDropdown(self, index):
if index > 0:
index -= 1 # we inserted our "Set NoteNames to" at index 0 shifting the real values +1.
schemes = sorted(pitch.noteNames.keys())
noteNamesAsString = sorted(pitch.noteNames.keys())[index]
noteNames = pitch.noteNames[noteNamesAsString]
api.setNoteNames(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, noteNames=noteNames)
self._comboBoxNoteNames.blockSignals(True)
self._comboBoxNoteNames.setCurrentIndex(0)
self._comboBoxNoteNames.blockSignals(False)
def transposeUp(self):
api.transposeHalftoneSteps(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=1)
def transposeDown(self):
api.transposeHalftoneSteps(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=-1)
def transposeUpOctave(self):
api.transposeHalftoneSteps(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=12)
def transposeDownOctave(self):
api.transposeHalftoneSteps(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=-12)
def transposeToScale(self, index):
if index > 0:
index -= 1 # the backend list obviously has no "Set Scale to" on index [0]
api.setScaleToKeyword(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, keyword=api.schemes[index]) #this schemes must NOT be translated since it is the original key/symbol.
self._transposeToScaleWidget.blockSignals(True)
self._transposeToScaleWidget.setCurrentIndex(0)
self._transposeToScaleWidget.blockSignals(False)
class PitchWidget(QtWidgets.QGraphicsProxyWidget):
""" A PitchWidget has a variable width by nature because the note-name can vary.
For that reason We need to truncate to match the fixed size.
Offset and position are set in Scale.buildScale
"""
def __init__(self, parentItem):
super().__init__()
self.parentItem = parentItem
self.spinBox = QtWidgets.QSpinBox()
#self.spinBox.setFrame(True)
self.spinBox.setMinimum(0)
self.spinBox.setMaximum(127)
self.spinBox.stepBy = self.stepBy
#self.spinBox.setValue(0) #No init value. This is changed on active track callback
widget = QtWidgets.QWidget()
layout = QtWidgets.QHBoxLayout()
layout.setSpacing(0)
layout.setContentsMargins(0,0,0,0)
widget.setLayout(layout)
widget.setStyleSheet(".QWidget { background-color: rgba(0,0,0,0) }") #transparent, but only this widget, hence the leading dot
self.label = QtWidgets.QLabel() #changed in spinBoxValueChanged
self.label.setText("")
self.label.setFixedSize(90, 18)
self.label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
layout.addWidget(self.label)
layout.addWidget(self.spinBox)
self.setWidget(widget)
self.spinBox.valueChanged.connect(self.spinBoxValueChanged)
self.spinBox.editingFinished.connect(self.spinBoxEditingFinished)
#self.spinBoxValueChanged() #Delay that. The engine Data is not ready yet. It will be sent by the callback
arrowsLeftStylesheet = """
QSpinBox {
padding-left: 15px; /* make room for the arrows */
}
QSpinBox::up-button {
subcontrol-position: top left; /* position at the top right corner */
}
QSpinBox::down-button {
subcontrol-position: bottom left; /* position at bottom right corner */
}
"""
#That does not looks good
#self.spinBox.setStyleSheet(arrowsLeftStylesheet)
def midiToNotename(self, midipitch):
assert self.parentItem.noteNames, self.parentItem.noteNames
try:
return self.parentItem.noteNames[midipitch] #includes octave names
except IndexError:
print (midipitch)
print (self.parentItem.noteNames)
exit()
def spinBoxValueChanged(self):
self.label.setText(self.midiToNotename(self.spinBox.value()))
def spinBoxEditingFinished(self):
if not self.rememberLastValue == self.spinBox.value():
self.parentItem.sendToEngine()
self.rememberLastValue = self.spinBox.value()
def stepBy(self, n):
"""Override standard behaviour to make page up and page down go in octaves, not in 10"""
if n == 10:
QtWidgets.QAbstractSpinBox.stepBy(self.spinBox, 12)
elif n == -10:
QtWidgets.QAbstractSpinBox.stepBy(self.spinBox, -12)
else:
QtWidgets.QAbstractSpinBox.stepBy(self.spinBox, n)
class Playhead(QtWidgets.QGraphicsLineItem):
def __init__(self, parentScene):
super().__init__(0, 0, 0, api.NUMBER_OF_STEPS*SIZE_UNIT) # (x1, y1, x2, y2)
self.parentScene = parentScene
p = QtGui.QPen()
p.setColor(QtGui.QColor("red"))
p.setWidth(3)
#p.setCosmetic(True)
self.setPen(p)
api.callbacks.setPlaybackTicks.append(self.setCursorPosition)
self.setZValue(90)
def setCursorPosition(self, tickindex, playbackStatus):
"""Using modulo makes the playback cursor wrap around and play over the pattern
eventhough we use the global tick value."""
x = (tickindex % self.parentScene.oneMeasureInTicks) / self.parentScene.ticksToPixelRatio
x += SIZE_RIGHT_OFFSET
if playbackStatus: # api.duringPlayback:
self.show()
self.setX(x)
scenePos = self.parentScene.parentView.mapFromScene(self.pos())
cursorViewPosX = scenePos.x() #the cursor position in View coordinates
width = self.parentScene.parentView.geometry().width()
if cursorViewPosX <= 0 or cursorViewPosX >= width: #"pageflip"
self.parentScene.parentView.horizontalScrollBar().setValue(x)
else:
self.hide()
class VelocityControls(QtWidgets.QWidget):
def __init__(self, parentScene):
self.parentScene = parentScene
super().__init__()
layout = QtWidgets.QHBoxLayout()
layout.setSpacing(0)
layout.setContentsMargins(0,0,0,0)
self.setLayout(layout)
velocityUp = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("VelocityControls", "+Velocity"))
velocityUp.clicked.connect(self.velocityUp)
velocityUp.wheelEvent = self._mouseWheelEvent
velocityUp.setToolTip(QtCore.QCoreApplication.translate("VelocityControls", "Make everything louder. Hover and mousewheel up/down to go in steps of 10."))
layout.addWidget(velocityUp)
velocityDown = QtWidgets.QPushButton(QtCore.QCoreApplication.translate("VelocityControls", "-Velocity"))
velocityDown.clicked.connect(self.velocityDown)
velocityDown.wheelEvent = self._mouseWheelEvent
velocityDown.setToolTip(QtCore.QCoreApplication.translate("VelocityControls", "Make everything softer. Hover and mousewheel up/down to go in steps of 10."))
layout.addWidget(velocityDown)
def _mouseWheelEvent(self, event):
event.accept()
if event.angleDelta().y() > 0: #up
api.changePatternVelocity(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=10)
else: #down
api.changePatternVelocity(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=-10)
#The steps got redrawn, therefore their velocity numbers are now invislbe again. We are still in hover modus:
self.parentScene.showVelocities()
def enterEvent(self, event):
self.parentScene.showVelocities()
def leaveEvent(self, event):
self.parentScene.hideVelocities()
def velocityUp(self):
api.changePatternVelocity(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=1)
#The steps got redrawn, therefore their velocity numbers are now invislbe again. We are still in hover modus:
self.parentScene.showVelocities()
def velocityDown(self):
api.changePatternVelocity(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, steps=-1)
#The steps got redrawn, therefore their velocity numbers are now invislbe again. We are still in hover modus:
self.parentScene.showVelocities()

793
qtgui/songeditor.py

@ -0,0 +1,793 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2018, 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/>.
"""
from time import time
import engine.api as api #Session is already loaded and created, no duplication.
from PyQt5 import QtCore, QtGui, QtWidgets
SIZE_UNIT = 25 #this is in manual sync with timeline.py SIZE_UNIT
SIZE_TOP_OFFSET = 0
_zValuesRelativeToScene = { #Only use for objects added directly to the scene, not children.
"trackStructure":3,
"switch":4,
"barline":5,
"playhead":90,
}
class SongEditor(QtWidgets.QGraphicsScene):
def __init__(self, parentView):
super().__init__()
self.parentView = parentView
#Subitems
self.playhead = Playhead(parentScene = self)
self.addItem(self.playhead)
self.playhead.setY(SIZE_TOP_OFFSET)
self.tracks = {} #TrackID:TrackStructures
self.barlines = [] #in order
self.cachedCombinedTrackHeight = 0 #set by callback_tracksChanged
self.trackOrder = [] #set by callback_numberOfTracksChanged
role = QtGui.QPalette.BrightText
self.brightPen = QtGui.QPen(self.parentView.parentMainWindow.fPalBlue.color(role))
self.normalPen = QtGui.QPen()
api.callbacks.numberOfTracksChanged.append(self.callback_numberOfTracksChanged)
api.callbacks.timeSignatureChanged.append(self.callback_timeSignatureChanged)
api.callbacks.numberOfMeasuresChanged.append(self.callback_setnumberOfMeasures)
api.callbacks.trackStructureChanged.append(self.callback_trackStructureChanged) #updates single tracks
api.callbacks.exportCacheChanged.append(self.cacheExportDict)
api.callbacks.scoreChanged.append(self.callback_scoreChanged) #sends information about measuresPerGroup
api.callbacks.trackMetaDataChanged.append(self.callback_trackMetaDataChanged)
#self.ticksToPixelRatio = None set by callback_timeSignatureChanged
def wheelEvent(self, event):
"""zoom, otherwise ignore event"""
if QtWidgets.QApplication.keyboardModifiers() == QtCore.Qt.ControlModifier:
self.parentView.parentMainWindow.zoomUpperHalf(event.delta())
event.accept()
else:
event.ignore()
super().wheelEvent(event)
def callback_trackMetaDataChanged(self, exportDict):
"""This is not for the initial track creation, only for later changes"""
self.tracks[exportDict["id"]].updateMetaData(exportDict)
def cacheExportDict(self, exportDict):
"""Does not get called on structure change because callback_trackStructureChanged
also caches the exportDict """
self.tracks[exportDict["id"]].exportDict = exportDict
def callback_trackStructureChanged(self, exportDict):
"""Happens if a switch gets flipped"""
track = self.tracks[exportDict["id"]]
track.updateSwitches(exportDict)
def callback_timeSignatureChanged(self, nr, typ):
oneMeasureInTicks = nr * typ
self.ticksToPixelRatio = oneMeasureInTicks / SIZE_UNIT
def callback_numberOfTracksChanged(self, exportDictList):
"""Used for new tracks, delete track and move track"""
toDelete = set(self.tracks.keys())
self.trackOrder = []
for index, exportDict in enumerate(exportDictList):
if exportDict["id"] in self.tracks:
toDelete.remove(exportDict["id"]) #keep this track and don't delete later.
else: #new track
self.tracks[exportDict["id"]] = TrackStructure(parentScene=self)
self.addItem(self.tracks[exportDict["id"]])
self.tracks[exportDict["id"]].setZValue(_zValuesRelativeToScene["trackStructure"])
self.trackOrder.append(self.tracks[exportDict["id"]])
self.tracks[exportDict["id"]].setY(index * SIZE_UNIT + SIZE_TOP_OFFSET)
self.tracks[exportDict["id"]].updateSwitches(exportDict)
self.tracks[exportDict["id"]].updateStaffLines(exportDict["numberOfMeasures"])
#We had these tracks in the GUI but they are gone in the export. This is track delete.
for trackId in toDelete:
trackStructure = self.tracks[trackId]
#we don't need to delete from trackOrder here because that is cleared each time we call this function
del self.tracks[trackId]
self.removeItem(trackStructure) #remove from scene
del trackStructure
assert all(track.exportDict["index"] == self.trackOrder.index(track) for track in self.tracks.values())
self.cachedCombinedTrackHeight = len(self.tracks) * SIZE_UNIT
self.setSceneRect(0,0,exportDict["numberOfMeasures"]*SIZE_UNIT,self.cachedCombinedTrackHeight + 3*SIZE_TOP_OFFSET) #no empty space on top without a scene rect. Also a bit of leniance.
self.playhead.setLine(0, 0, 0, self.cachedCombinedTrackHeight) #(x1, y1, x2, y2)
self.adjustBarlineHeightForNewTrackCount()
def adjustBarlineHeightForNewTrackCount(self):
"""Fetches the current context itself and modifies all existing barlines.
"""
for barline in self.barlines:
barline.setLine(0,0,0,self.cachedCombinedTrackHeight)
def callback_setnumberOfMeasures(self, exportDictScore):
requestAmountOfMeasures = exportDictScore["numberOfMeasures"]
requestAmountOfMeasures += 1 #the final closing barline
maximumAmountIncludingHidden = len(self.barlines)
if requestAmountOfMeasures == maximumAmountIncludingHidden:
for l in self.barlines: l.show()
elif requestAmountOfMeasures > maximumAmountIncludingHidden: #we need more than we have. Maybe new ones.
for l in self.barlines: l.show()
for i in range(maximumAmountIncludingHidden, requestAmountOfMeasures):
barline = QtWidgets.QGraphicsLineItem(0,0,0,1) #correct length will be set below, but we need something other than 0 here
self.addItem(barline)
barline.setAcceptedMouseButtons(QtCore.Qt.NoButton) #barlines will intercept clicks on the track otherwise. We keep the horizontal stafflines blocking to prevent accidents though.
barline.setPos(i*SIZE_UNIT, SIZE_TOP_OFFSET)
barline.setEnabled(False)
barline.setZValue(_zValuesRelativeToScene["barline"])
self.barlines.append(barline)
else: #user reduced the number of barlines. We only hide, never delete.
for l in self.barlines[requestAmountOfMeasures:]:
l.hide()
#Guaranteed visible.
for l in self.barlines[:requestAmountOfMeasures]:
l.show()
self.callback_scoreChanged(exportDictScore) #colors from the start
self.adjustBarlineHeightForNewTrackCount() #otherwise only the new ones have the correct height.
self.setSceneRect(0,0,requestAmountOfMeasures*SIZE_UNIT,self.cachedCombinedTrackHeight + 3*SIZE_TOP_OFFSET) #no empty space on top without a scene rect
for track in self.tracks.values():
track.updateSwitchVisibility(requestAmountOfMeasures=requestAmountOfMeasures-1)
track.updateStaffLines(requestAmountOfMeasures-1)
def callback_scoreChanged(self, exportDictScore):
self.measuresPerGroupCache = exportDictScore["measuresPerGroup"]
for i,barline in enumerate(self.barlines):
if i > 0 and (i+1) % exportDictScore["measuresPerGroup"] == 1:
barline.setPen(self.brightPen)
else:
barline.setPen(self.normalPen)
class TrackStructure(QtWidgets.QGraphicsRectItem):
"""From left to right. Holds two lines to show the "stafflinen" and a number of switches,
colored rectangles to indicate where a pattern is activated on the timeline"""
def __init__(self, parentScene):
super().__init__(0,0,1,SIZE_UNIT)
self.parentScene = parentScene
self.exportDict = None #self.update gets called immediately after creation.
self.switches = {} # position:switchInstance
self.currentColor = None #set in updateMetaData
self.labelColor = None #set in updateMetaData for redable labels on our color. for example transpose number
#The track holds the horizontal lines. The number of barlines is calculated in the parentScene for all tracks at once.
self.topLine = QtWidgets.QGraphicsLineItem(0,0,1,0) #empty line is not possible. We need at least something to let self.update work with
self.topLine.setParentItem(self)
self.topLine.setPos(0,0)
self.bottomLine = QtWidgets.QGraphicsLineItem(0,0,1,0) #empty line is not possible. We need at least something to let self.update work with
self.bottomLine.setParentItem(self)
self.bottomLine.setPos(0,SIZE_UNIT)
self.topLine.setEnabled(False)
self.bottomLine.setEnabled(False)
#Interactive Marker to select several switches in a row
self._mousePressOn = None #to remember the position of a mouse click
#self._markerLine = QtWidgets.QGraphicsLineItem(0,0,10,0) #only updated, never replaced
self._markerLine = QtWidgets.QGraphicsRectItem(0,0,10,SIZE_UNIT) #only updated, never replaced
self._markerLine.setParentItem(self)
self._markerLine.setY(0) #x is set in mousePressEvent
self._markerLine.setZValue(_zValuesRelativeToScene["playhead"])
self._markerLine.hide()
def _setColors(self, exportDict):
c = QtGui.QColor(exportDict["color"])
self.currentColor = c
if c.lightness() > 127: #between 0 (for black) and 255 (for white)
labelColor = QtGui.QColor("black")
else:
labelColor = QtGui.QColor("white")
self.labelColor = labelColor #save for new switches
def updateSwitches(self, exportDict):
self.exportDict = exportDict
self._setColors(exportDict)
#Create new switches
for position in exportDict["structure"]:
if not position in self.switches:
self.switches[position] = self._createSwitch(position)
self.updateSwitchVisibility(exportDict["numberOfMeasures"])
def updateMetaData(self, exportDict):
"""Color and Transposition status.
Does not get called on track structure change."""
self._setColors(exportDict)
for switch in self.switches.values():
switch.setBrush(self.currentColor)
switch.setScaleTransposeColor(self.labelColor)
switch.setHalftoneTransposeColor(self.labelColor)
def updateStaffLines(self, requestAmountOfMeasures):
l = self.topLine.line()
l.setLength(requestAmountOfMeasures * SIZE_UNIT)
self.topLine.setLine(l)
l = self.bottomLine.line()
l.setLength(requestAmountOfMeasures * SIZE_UNIT)
self.bottomLine.setLine(l)
#Update self, which is the track background
self.setRect(0,0,requestAmountOfMeasures * SIZE_UNIT, SIZE_UNIT)
def _createSwitch(self, position):
"""Called only by self.updateSwitches
Qt can't put the same item into the scene twice. We need to create a new one each time"""
switch = Switch(parentTrackStructure=self, position=position)
assert self.currentColor
switch.setBrush(self.currentColor)
switch.setParentItem(self)
switch.setX(position * SIZE_UNIT)
return switch
def updateSwitchVisibility(self, requestAmountOfMeasures):
"""Switch pattern-visibility on and off.
This never creates or deletes switches
We assume self.exportDict is up to date
because we get called by self.updateSwitches, which saves the exportDict."""
structure = self.exportDict["structure"]
whichPatternsAreScaleTransposed = self.exportDict["whichPatternsAreScaleTransposed"]
whichPatternsAreHalftoneTransposed = self.exportDict["whichPatternsAreHalftoneTransposed"]
for position, switch in self.switches.items():
if position < requestAmountOfMeasures and position in structure:
switch.show()
else:
switch.hide() #Not delete because this may be just a temporary reduction of measures
switch.scaleTransposeOff()
if position in whichPatternsAreScaleTransposed:
switch.setScaleTranspose(-1 * whichPatternsAreScaleTransposed[position]) #we flip the polarity from "makes sense" to row based "lower is higher" here. The opposite, sending, flip is done in switch hover leave event
else:
switch.scaleTransposeOff()
if position in whichPatternsAreHalftoneTransposed:
switch.setHalftoneTranspose(whichPatternsAreHalftoneTransposed[position]) #half tone transposition is not flipped
else:
switch.halftoneTransposeOff()
def scenePos2switchPosition(self, x):
return int(x / SIZE_UNIT)
def mousePressEvent(self, event):
#First we need to find the mouse clicks position. self.switches only holds pos that were at least activated once.
#The track is only the area where the rectangles and lines meet. it is impossible to click below or right of the tracks.
#we always get a valid position this way.
if event.button() == QtCore.Qt.MiddleButton and not self._mousePressOn:
self.parentScene.parentView.parentMainWindow.patternGrid.createShadow(self.exportDict)
else:
self.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.exportDict)
if event.button() == QtCore.Qt.LeftButton:
assert not self._mousePressOn
position = self.scenePos2switchPosition(event.scenePos().x()) #measure number 0 based
self._markerLine.setX(position * SIZE_UNIT )
newBool = not position in self.switches or not self.switches[position].isVisible()
if newBool:
self._markerLine.setBrush(self.currentColor)
else:
self._markerLine.setBrush(self.parentScene.parentView.parentMainWindow.fPalBlue.color(QtGui.QPalette.AlternateBase)) #we are always the active track so this is our color
self._mousePressOn = (time(), self, position, newBool) #Reset to None in mouseReleaseEvent
result = api.setSwitch(self.exportDict["id"], position, newBool) #returns True if a switch happend
assert result
#elif event.button() == QtCore.Qt.RightButton and not self._mousePressOn:
#no, this is done with contextMenuEvent directly so it also reacts to the context menu keyboard key.
def contextMenuEvent(self, event):
if self._mousePressOn: #Right click can happen while the left button is still pressed down, which we don't want.
return
menu = QtWidgets.QMenu()
position = self.scenePos2switchPosition(event.scenePos().x()) #measure number 0 based
measuresPerGroup = self.parentScene.measuresPerGroupCache
listOfLabelsAndFunctions = [
(QtCore.QCoreApplication.translate("SongStructure", "Insert {} empty measures before no. {}").format(measuresPerGroup, position+1), lambda: api.insertSilence(howMany=measuresPerGroup, beforeMeasureNumber=position)),
(QtCore.QCoreApplication.translate("SongStructure", "Delete {} measures from no. {} on").format(measuresPerGroup, position+1), lambda: api.deleteSwitches(howMany=measuresPerGroup, fromMeasureNumber=position)),
]
for text, function in listOfLabelsAndFunctions:
a = QtWidgets.QAction(text, menu)
menu.addAction(a)
a.triggered.connect(function)
pos = QtGui.QCursor.pos()
pos.setY(pos.y() + 5)
self.parentScene.parentView.parentMainWindow.setFocus()
menu.exec_(pos)
def mouseMoveEvent(self, event):
position = self.scenePos2switchPosition(event.scenePos().x()) #measure number 0 based
if self._mousePressOn and position != self._mousePressOn[2]:
#self._markerLine.setLine(0,0, (position - self._mousePressOn[2])*SIZE_UNIT + SIZE_UNIT/2, 0)
rect = self._markerLine.rect()
if position < 0:
position = 0
elif position + 1 > self.exportDict["numberOfMeasures"]: #position is already a switch position
position = self.exportDict["numberOfMeasures"] - 1
if position < self._mousePressOn[2]:
left = (position - self._mousePressOn[2]) * SIZE_UNIT
rect.setLeft(left)
rect.setRight(SIZE_UNIT)
else:
right = (position - self._mousePressOn[2]) * SIZE_UNIT + SIZE_UNIT
rect.setRight(right)
rect.setLeft(0)
self._markerLine.setRect(rect)
self._markerLine.show()
else:
self._markerLine.hide()
def mouseReleaseEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self._markerLine.hide()
position = self.scenePos2switchPosition(event.scenePos().x())
if position < 0:
position = 0
elif position +1 > self.exportDict["numberOfMeasures"]: #position is already a switch position
position = self.exportDict["numberOfMeasures"] -1
startTime, startTrack, startPosition, setTo = self._mousePressOn
self._mousePressOn = None
if not startPosition == position and time() - startTime > 0.4: #optimisation to spare the engine from redundant work. Also prevent hectic drag-clicking
#setTo is a bool that tells us if all the switches in our range should go on (True) or off (False). The first switch, startPosition, is already set in mousePressEvent for a better user experience.
low, high = sorted((startPosition, position)) #both included
setOfPositions = set(range(low, high+1)) #range does not include the last one, we want it in. it MUST be a set.
api.setSwitches(self.exportDict["id"], setOfPositions, setTo)
def mark(self, boolean):
"""Mark the whole Track as active or not"""
if boolean:
role = QtGui.QPalette.AlternateBase
else:
role = QtGui.QPalette.Base
c = self.parentScene.parentView.parentMainWindow.fPalBlue.color(role)
self.setBrush(c)
class Switch(QtWidgets.QGraphicsRectItem):
"""Switches live for the duration of the track. Once created they only ever get hidden/shown,
never deleted."""
def __init__(self, parentTrackStructure, position):
self.parentTrackStructure = parentTrackStructure
self.position = position
super().__init__(0,0,SIZE_UNIT,SIZE_UNIT)
self.setAcceptHoverEvents(True)
self.setZValue(_zValuesRelativeToScene["switch"])
self.scaleTransposeGlyph = QtWidgets.QGraphicsSimpleTextItem("")
self.scaleTransposeGlyph.setParentItem(self)
self.scaleTransposeGlyph.setScale(0.80)
self.scaleTransposeGlyph.setPos(2,1)
self.scaleTransposeGlyph.setBrush(self.parentTrackStructure.labelColor)
self.scaleTransposeGlyph.hide()
self.scaleTranspose = 0
self.halftoneTransposeGlyph = QtWidgets.QGraphicsSimpleTextItem("")
self.halftoneTransposeGlyph.setParentItem(self)
self.halftoneTransposeGlyph.setScale(0.80)
self.halftoneTransposeGlyph.setPos(1,13)
self.halftoneTransposeGlyph.setBrush(self.parentTrackStructure.labelColor)
self.halftoneTransposeGlyph.hide()
self.halftoneTranspose = 0
def setScaleTranspose(self, value):
"""
Called by track callbacks and also for the temporary buffer display
while internally both the engine and us, the GUI, use steps and transposition through
"negative is higher pitch" we present it reversed for the user.
Greater number is higher pitch
It is guaranteed that only active switches can have a transposition.
Also transposition=0 is not included.
"""
self.scaleTranspose = value
self._setScaleTransposeLabel(value)
def _setScaleTransposeLabel(self, value):
text = ("+" if value > 0 else "") + str(value) + "s"
self.scaleTransposeGlyph.setText(text)
self.scaleTransposeGlyph.show()
def setScaleTransposeColor(self, c):
self.scaleTransposeGlyph.setBrush(c)
def scaleTransposeOff(self):
self.scaleTransposeGlyph.setText("")
#self.scaleTransposeGlyph.hide()
self.scaleTranspose = 0
self._bufferScaleTranspose = 0
def setHalftoneTranspose(self, value):
self.halftoneTranspose = value
self._setHalftoneTransposeLabel(value)
def _setHalftoneTransposeLabel(self, value):
text = ("+" if value > 0 else "") + str(value) + "h"
self.halftoneTransposeGlyph.setText(text)
self.halftoneTransposeGlyph.show()
def setHalftoneTransposeColor(self, c):
self.halftoneTransposeGlyph.setBrush(c)
def halftoneTransposeOff(self):
self.halftoneTransposeGlyph.setText("")
#self.halftoneTransposeGlyph.hide()
self.halftoneTranspose = 0
self._bufferhalftoneTranspose = 0
def mousePressEvent(self, event):
"""A mouse events on the track activate a switch. Then we receive the event to turn it
off again."""
event.ignore()
def hoverEnterEvent(self, event):
self._bufferScaleTranspose = self.scaleTranspose
self._bufferHalftoneTranspose = self.halftoneTranspose
def hoverLeaveEvent(self, event):
"""only triggered when active/shown"""
event.accept()
#Scale Transpose. Independent of Halftone Transpose
if not self._bufferScaleTranspose == self.scaleTranspose:
api.setSwitchScaleTranspose(self.parentTrackStructure.exportDict["id"], self.position, -1*self._bufferScaleTranspose) #we flip the polarity here. The receiving flip is done in the callback.
#new transpose/buffer gets set via callback
if self._bufferScaleTranspose == 0:
self.scaleTransposeOff()
#Halftone Transpose. Independent of Scale Transpose
if not self._bufferHalftoneTranspose == self.halftoneTranspose:
api.setSwitchHalftoneTranspose(self.parentTrackStructure.exportDict["id"], self.position, self._bufferHalftoneTranspose) #half tone transposition is not flipped
#new transpose/buffer gets set via callback
if self._bufferHalftoneTranspose == 0:
self.halftoneTransposeOff()
def wheelEvent(self, event):
"""Does not get triggered when switch is off.
This buffers until hoverLeaveEvent and then the new value is sent in self.hoverLeaveEvent"""
event.accept()
if QtWidgets.QApplication.keyboardModifiers() == QtCore.Qt.ShiftModifier: #half tone transposition
if event.delta() > 0:
self._bufferHalftoneTranspose = min(+24, self._bufferHalftoneTranspose+1)
else:
self._bufferHalftoneTranspose = max(-24, self._bufferHalftoneTranspose-1)
self._setHalftoneTransposeLabel(self._bufferHalftoneTranspose)
else: #scale transposition
if event.delta() > 0:
self._bufferScaleTranspose = min(+7, self._bufferScaleTranspose+1)
else:
self._bufferScaleTranspose = max(-7, self._bufferScaleTranspose-1)
self._setScaleTransposeLabel(self._bufferScaleTranspose)
class TrackLabelEditor(QtWidgets.QGraphicsScene):
"""Only the track labels"""
def __init__(self, parentView):
super().__init__()
self.parentView = parentView
self.tracks = {} #TrackID:TrackStructures
api.callbacks.numberOfTracksChanged.append(self.callback_numberOfTracksChanged)
api.callbacks.trackMetaDataChanged.append(self.callback_trackMetaDataChanged)
api.callbacks.exportCacheChanged.append(self.cacheExportDict)
self.cachedCombinedTrackHeight = 0 #set by callback_tracksChanged
def cacheExportDict(self, exportDict):
self.tracks[exportDict["id"]].exportDict = exportDict
def callback_trackMetaDataChanged(self, exportDict):
"""This is not for the initial track creation, only for later changes"""
self.tracks[exportDict["id"]].update(exportDict)
def callback_numberOfTracksChanged(self, exportDictList):
toDelete = set(self.tracks.keys())
width = self.parentView.geometry().width()
for index, exportDict in enumerate(exportDictList):
if exportDict["id"] in self.tracks:
toDelete.remove(exportDict["id"])
else: #new track
self.tracks[exportDict["id"]] = TrackLabel(parentScene=self, width=width, height=SIZE_UNIT)
self.addItem(self.tracks[exportDict["id"]])
self.tracks[exportDict["id"]].setY(index * SIZE_UNIT + SIZE_TOP_OFFSET)
self.tracks[exportDict["id"]].update(exportDict)
#We had this tracks in the GUI but they are gone in the export. This is track delete.
for trackId in toDelete:
trackLabel = self.tracks[trackId]
del self.tracks[trackId]
self.removeItem(trackLabel) #remove from scene
del trackLabel
if toDelete and self.parentView.parentMainWindow.currentTrackId in toDelete: #toDelete still exist if tracks were deleted above
anyExistingTrack = next(iter(self.tracks.values()))
self.parentView.parentMainWindow.chooseCurrentTrack(anyExistingTrack.exportDict)
self.cachedCombinedTrackHeight = len(self.tracks) * SIZE_UNIT
self.setSceneRect(0,0,width-1,self.cachedCombinedTrackHeight + 3*SIZE_TOP_OFFSET)
def contextMenuEvent(self, event):
"""
We can't delete this properly object from within. The engine callback will react faster
than we need to finish this function. That means qt and python will try to access
objects that are non-existent"""
menu = QtWidgets.QMenu()
item = self.itemAt(event.scenePos().x(), event.scenePos().y(), self.parentView.transform())
if not type(item) is QtWidgets.QGraphicsProxyWidget:
return None
exportDict = item.parentItem().exportDict.copy()
del item #yes, manual memory management in Python. We need to get rid of anything we want to delete later or Qt will crash because we have a python wrapper without a qt object
listOfLabelsAndFunctions = [
(exportDict["name"], None),
(QtCore.QCoreApplication.translate("TrackLabelContext", "Invert Measures"), lambda: api.trackInvertSwitches(exportDict["id"])),
(QtCore.QCoreApplication.translate("TrackLabelContext", "All Measures On"), lambda: api.trackOnAllSwitches(exportDict["id"])),
(QtCore.QCoreApplication.translate("TrackLabelContext", "All Measures Off"), lambda: api.trackOffAllSwitches(exportDict["id"])),
(QtCore.QCoreApplication.translate("TrackLabelContext", "Clone this Track"), lambda: api.createSiblingTrack(exportDict["id"])),
(QtCore.QCoreApplication.translate("TrackLabelContext", "Delete Track"), lambda: api.deleteTrack(exportDict["id"])),
]
for text, function in listOfLabelsAndFunctions:
if function is None:
l = QtWidgets.QLabel(text)
l.setAlignment(QtCore.Qt.AlignCenter)
a = QtWidgets.QWidgetAction(menu)
a.setDefaultWidget(l)
menu.addAction(a)
else:
a = QtWidgets.QAction(text, menu)
menu.addAction(a)
a.triggered.connect(function)
#Add a submenu for merge/copy
mergeMenu = menu.addMenu(QtCore.QCoreApplication.translate("TrackLabelContext", "Merge/Copy from"))
def createCopyMergeLambda(srcId):
return lambda: api.trackMergeCopyFrom(srcId, exportDict["id"])
for track in self.tracks.values():
sourceDict = track.exportDict
a = QtWidgets.QAction(sourceDict["name"], mergeMenu)
mergeMenu.addAction(a)
mergeCommand = createCopyMergeLambda(sourceDict["id"])
if sourceDict["id"] == exportDict["id"]:
a.setEnabled(False)
a.triggered.connect(mergeCommand)
pos = QtGui.QCursor.pos()
pos.setY(pos.y() + 5)
self.parentView.parentMainWindow.setFocus()
menu.exec_(pos)
class TrackLabel(QtWidgets.QGraphicsRectItem):
def __init__(self, parentScene, width, height):
super().__init__(0, 0, width, height)
self.parentScene = parentScene
self.setPen(QtGui.QPen(QtCore.Qt.NoPen))
self.positioningHandle = TrackLabel.PositioningHandle(parentTrackLabel=self)
self.positioningHandle.setParentItem(self)
self.positioningHandle.setPos(0,0)
self.positioningHandle.setToolTip(QtCore.QCoreApplication.translate("TrackLabel", "grab and move to reorder tracks"))
self.colorButton = TrackLabel.ColorPicker(parentTrackLabel=self)
self.colorButton.setParentItem(self)
self.colorButton.setPos(SIZE_UNIT, 3)
self.colorButton.setToolTip(QtCore.QCoreApplication.translate("TrackLabel", "change track color"))
self.lineEdit = TrackLabel.NameLineEdit(parentTrackLabel=self)
self.label = QtWidgets.QGraphicsProxyWidget()
self.label.setWidget(self.lineEdit)
self.label.setParentItem(self)
self.label.setPos(2*SIZE_UNIT+3,0)
self.setFlag(self.ItemIgnoresTransformations)
class ColorPicker(QtWidgets.QGraphicsRectItem):
def __init__(self, parentTrackLabel):
super().__init__(0,0,SIZE_UNIT*0.75,SIZE_UNIT*0.75)
self.parentTrackLabel = parentTrackLabel
self.setBrush(QtGui.QColor("cyan"))
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self.parentTrackLabel.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.parentTrackLabel.exportDict)
event.accept()
colorDialog = QtWidgets.QColorDialog()
color = colorDialog.getColor(self.brush().color()) #blocks
if color.isValid(): #and not abort
#self.setBrush(color) #done via callback.
api.changeTrackColor(self.parentTrackLabel.exportDict["id"], color.name())
#else:
# colorDialog.setStandardColor(self.brush().color())
else:
event.ignore()
#super().mousePressEvent(event)
class PositioningHandle(QtWidgets.QGraphicsEllipseItem):
def __init__(self, parentTrackLabel):
super().__init__(0,0,SIZE_UNIT-2,SIZE_UNIT-2)
self.parentTrackLabel = parentTrackLabel
self.setPen(QtGui.QPen(QtCore.Qt.NoPen))
role = QtGui.QPalette.ToolTipBase
c = self.parentTrackLabel.parentScene.parentView.parentMainWindow.fPalBlue.color(role)
self.setBrush(c)
self.setOpacity(0.08) #this is meant as a slight overlay/highlight of both the current track and the other tracks
self.arrowLabel = QtWidgets.QGraphicsSimpleTextItem("")
self.arrowLabel.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresParentOpacity)
self.arrowLabel.setParentItem(self)
self.arrowLabel.setScale(1.6)
self.arrowLabel.setPos(2,1)
role = QtGui.QPalette.Text
self.arrowLabel.setBrush(self.parentTrackLabel.parentScene.parentView.parentMainWindow.fPalBlue.color(role))
self._cursorPosOnMoveStart = None
def yPos2trackIndex(self, y):
"""0 based"""
pos = round(y / SIZE_UNIT)
pos = min(pos, len(self.parentTrackLabel.parentScene.tracks)-1)
return pos
def mouseMoveEvent(self, event):
if self._cursorPosOnMoveStart:
self.parentTrackLabel.setY(max(0, event.scenePos().y()))
#super().mouseMoveEvent(event) #with this the sync between cursor and item is off.
def mousePressEvent(self, event):
"""release gets only triggered when mousePressEvent was on the same item.
We don't need to worry about the user just releasing the mouse on this item"""
self._posBeforeMove = self.parentTrackLabel.pos()
self._cursorPosOnMoveStart = QtGui.QCursor.pos()
self._lineCursor = self.parentTrackLabel.lineEdit.cursor()
self.parentTrackLabel.mousePressEvent(event)
#super().mousePressEvent(event) #with this in mouseMoveEvent does not work. IIRC because we do not set the movableFlag
def mouseReleaseEvent(self, event):
newIndex = self.yPos2trackIndex(self.parentTrackLabel.y()) #we need to save that first, right after this we reset the position
self.parentTrackLabel.setPos(self._posBeforeMove) #In case the block was moved to a position where no track is (below the tracks) we just reset the graphics before anything happens. The user will never see this really
self._posBeforeMove = None
self._cursorPosOnMoveStart = None
api.moveTrack(self.parentTrackLabel.exportDict["id"], newIndex)
class NameLineEdit(QtWidgets.QLineEdit):
def __init__(self, parentTrackLabel):
super().__init__("")
self.parentTrackLabel = parentTrackLabel
self.setFrame(False)
self.setMaxLength(25)
self.setMinimumSize(QtCore.QSize(0, SIZE_UNIT))
self.setStyleSheet("background-color: rgba(0,0,0,0)") #transparent so we see the RectItem color
self.setReadOnly(True)
self.setFocusPolicy(QtCore.Qt.ClickFocus) #nmo tab
self.editingFinished.connect(self.sendToEngine)
self.returnPressed.connect(self.enter)
def mousePressEvent(self,event):
"""We also need to force this track as active"""
event.accept() #we need this for doubleClick
self.parentTrackLabel.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.parentTrackLabel.exportDict)
#event.ignore() #send to parent instead
#super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
event.accept()
self.setReadOnly(False)
def enter(self):
self.sendToEngine()
def sendToEngine(self):
self.setReadOnly(True)
new = self.text()
if not new == self.parentTrackLabel.exportDict["name"]:
self.blockSignals(True)
api.changeTrackName(self.parentTrackLabel.exportDict["id"], new)
self.blockSignals(False)
#def keyPressEvent(self, event):
# if event.key()) == QtCore.Qt.Key_Return:
# event.accept()
#
# else:
# event.ignore()
# super().keyPressEvent(event)
def update(self, exportDict):
self.lineEdit.setText(exportDict["name"])
self.exportDict = exportDict
self.colorButton.setBrush(QtGui.QColor(exportDict["color"]))
def mousePressEvent(self,event):
self.parentScene.parentView.parentMainWindow.chooseCurrentTrack(self.exportDict)
#event.ignore() #don't add that here. Will destroy mouseMove and Release events of child widgets
#def mouseReleaseEvent(self, event):
# event.
def mark(self, boolean):
if boolean:
role = QtGui.QPalette.AlternateBase
else:
role = QtGui.QPalette.Base
c = self.parentScene.parentView.parentMainWindow.fPalBlue.color(role)
self.setBrush(c)
class Playhead(QtWidgets.QGraphicsLineItem):
def __init__(self, parentScene):
super().__init__(0, 0, 0, 0) # (x1, y1, x2, y2)
self.parentScene = parentScene
p = QtGui.QPen()
p.setColor(QtGui.QColor("red"))
p.setWidth(3)
#p.setCosmetic(True)
self.setPen(p)
api.callbacks.setPlaybackTicks.append(self.setCursorPosition)
self.setZValue(_zValuesRelativeToScene["playhead"])
def setCursorPosition(self, tickindex, playbackStatus):
"""Set the playhead to the right position, but keep the viewport stable.
Shift the entire "page" if the cursor becomes invisible because its steps outside the viewport"""
x = tickindex / self.parentScene.ticksToPixelRatio
self.setX(x)
if playbackStatus: # api.duringPlayback:
scenePos = self.parentScene.parentView.mapFromScene(self.pos())
cursorViewPosX = scenePos.x() #the cursor position in View coordinates
width = self.parentScene.parentView.geometry().width()
if cursorViewPosX <= 0 or cursorViewPosX >= width: #"pageflip"
self.parentScene.parentView.horizontalScrollBar().setValue(x)

138
qtgui/timeline.py

@ -0,0 +1,138 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2018, 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/>.
"""
SIZE_UNIT = 25 #this is in manual sync with songeditor.py SIZE_UNIT
import engine.api as api #Session is already loaded and created, no duplication.
from PyQt5 import QtCore, QtGui, QtWidgets
class Timeline(QtWidgets.QGraphicsScene):
def __init__(self, parentView):
super().__init__()
self.parentView = parentView
self.addItem(TimelineRect(parentScene=self))
class TimelineRect(QtWidgets.QGraphicsRectItem):
"""Shows information about song progression.
JACK transport only shares the current time.
We cannot draw anything ahead of time other than what we know ourselves.
We rely on external tempo information and cannot react to tempo changes.
Our linear value is measures, so we display these."""
def __init__(self, parentScene):
self.height = 25
super().__init__(0, 0, 1, self.height)
self.parentScene = parentScene
self._cachedExportDictScore = {}
role = QtGui.QPalette.Light
c = self.parentScene.parentView.parentMainWindow.fPalBlue.color(role)
self.setBrush(c)
role = QtGui.QPalette.BrightText
self.brightText = self.parentScene.parentView.parentMainWindow.fPalBlue.color(role)
self._cachedSubdivisions = 1
self.measureNumbers = []
#self._buffer_measuresPerGroup set in callback_setnumberOfMeasures, changed in wheelEvent. Utilized in hoverLeaveEvent
self._pressed = False
api.callbacks.numberOfMeasuresChanged.append(self.callback_setnumberOfMeasures)
api.callbacks.scoreChanged.append(self.callback_setnumberOfMeasures) #sends information about measuresPerGroup
api.callbacks.subdivisionsChanged.append(self.cache_subdivisions) #sends information about measuresPerGroup
api.callbacks.timeSignatureChanged.append(self.cache_timesignature)
self.setToolTip(QtCore.QCoreApplication.translate("Timeline", "Click to set playback position. Scroll with mousewheel to adjust measure grouping."))
def cache_timesignature(self, howManyUnits, whatTypeOfUnit):
self._cachedExportDictScore["howManyUnits"] = howManyUnits
self._cachedExportDictScore["whatTypeOfUnit"] = whatTypeOfUnit
def cache_subdivisions(self, subdivisions):
self._cachedSubdivisions = subdivisions
def callback_setnumberOfMeasures(self, exportDictScore):
"""We only draw one number and line for each group and not the barlines in between"""
self._cachedExportDictScore = exportDictScore
requestAmountOfMeasures = exportDictScore["numberOfMeasures"]
self._buffer_measuresPerGroup = exportDictScore["measuresPerGroup"]
self.setRect(0,0,requestAmountOfMeasures * SIZE_UNIT, SIZE_UNIT)
#Delete old
for l in self.measureNumbers:
l.setParentItem(None)
self.parentScene.removeItem(l)
self.measureNumbers = []
#Create new
for i in range(requestAmountOfMeasures+1):
if i > 0 and (i+1) % exportDictScore["measuresPerGroup"] == 1:
measure = QtWidgets.QGraphicsSimpleTextItem(str(i)) #str(i).zfill(3)
measure.setBrush(self.brightText)
measure.setParentItem(self)
measure.setPos((i-1)*SIZE_UNIT, 5) #some magic pixel values for finetuning.
#measure.setEnabled(False) #Contrary to intuition this will not make this item ignore mouse clicks but just eat them. Enabling fowards mouse item to the timeline below.
measure.setFlag(self.ItemIgnoresTransformations)
self.measureNumbers.append(measure)
barline = QtWidgets.QGraphicsLineItem(0,0,0,self.height)
barline.setParentItem(self)
barline.setPen(self.brightText)
barline.setPos(i*SIZE_UNIT, 0)
#barline.setEnabled(False) #Contrary to intuition this will not make this item ignore mouse clicks but just eat them. Enabling fowards mouse item to the timeline below.
barline.setFlag(self.ItemIgnoresTransformations)
self.measureNumbers.append(barline)
def _sendPlaybackPositionToEngine(self, posX):
oneMeasureInTicks = ( self._cachedExportDictScore["howManyUnits"] * self._cachedExportDictScore["whatTypeOfUnit"] ) / self._cachedSubdivisions
ratio = oneMeasureInTicks / SIZE_UNIT
value = posX * ratio
api.seek(int(value))
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
event.accept()
self._sendPlaybackPositionToEngine(event.scenePos().x())
self._pressed = True
def mouseMoveEvent(self, event):
if self._pressed:
self._sendPlaybackPositionToEngine(event.scenePos().x())
event.accept()
def mouseReleaseEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
event.accept()
self._pressed = False
def wheelEvent(self, event):
"""This buffers until hoverLeaveEvent and then the new value is sent in self.hoverLeaveEvent"""
event.accept()
if event.delta() > 0:
self._buffer_measuresPerGroup += 1
else:
self._buffer_measuresPerGroup = max(1, self._buffer_measuresPerGroup-1)
api.set_measuresPerGroup(self._buffer_measuresPerGroup)
Loading…
Cancel
Save