You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
914 lines
41 KiB
914 lines
41 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2019, Nils Hilbricht, Germany ( https://www.hilbricht.net )
|
|
|
|
This file is part of Patroneo ( https://www.laborejo.org )
|
|
|
|
Laborejo is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
"""
|
|
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, QtOpenGL
|
|
|
|
SIZE_UNIT = 40
|
|
SIZE_TOP_OFFSET = 40
|
|
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(-20, SIZE_TOP_OFFSET)
|
|
|
|
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.
|
|
|
|
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["simpleNoteNames"])
|
|
|
|
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["sequencerInterface"]["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
|
|
|
|
potentialStep = self.itemAt(event.scenePos().x(), event.scenePos().y(), self.parentView.transform())
|
|
potentialSteps = [st for st in self.items(event.scenePos()) if type(st) is Step]
|
|
|
|
#An over-long active step is stacked before the actual step. We need to either find the lowest
|
|
#or the one furthest to the right because active notes can't have negative duration
|
|
if potentialSteps:
|
|
potentialStep = max(potentialSteps, key=lambda ls: ls.column)
|
|
else:
|
|
potentialStep = None
|
|
|
|
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)),
|
|
]
|
|
|
|
if potentialStep:
|
|
listOfLabelsAndFunctions.insert(0, (QtCore.QCoreApplication.translate("EventContextMenu", "Repeat to step {} incl. to fill Row").format(potentialStep.column+1), lambda: api.patternRowRepeatFromStep(trackId, potentialStep.row, potentialStep.column)))
|
|
listOfLabelsAndFunctions.insert(0, (QtCore.QCoreApplication.translate("EventContextMenu", "Clear Row"), lambda: api.patternClearRow(trackId, potentialStep.row)))
|
|
listOfLabelsAndFunctions.insert(0, (QtCore.QCoreApplication.translate("EventContextMenu", "Invert Row"), lambda: api.patternInvertRow(trackId, potentialStep.row)))
|
|
|
|
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()
|
|
elif QtWidgets.QApplication.keyboardModifiers() == QtCore.Qt.AltModifier:
|
|
potentialStep = self.itemAt(event.scenePos().x(), event.scenePos().y(), self.parentView.transform())
|
|
if type(potentialStep) is Step:
|
|
trackId = self.parentView.parentMainWindow.currentTrackId
|
|
if event.delta() > 0:
|
|
delta = 2
|
|
else:
|
|
delta = -2
|
|
api.patternRowChangeVelocity(trackId, potentialStep.row, delta)
|
|
self.showVelocities() #removed in self.keyReleaseEvent
|
|
event.accept()
|
|
else:
|
|
event.ignore()
|
|
super().wheelEvent(event)
|
|
else:
|
|
event.ignore()
|
|
super().wheelEvent(event)
|
|
|
|
def keyReleaseEvent(self, event):
|
|
"""Complementary for wheelEvent with Alt to change row velocity.
|
|
It is hard to detect the Alt key. We just brute force because there are not many
|
|
keyPresses in Patroneo at all."""
|
|
self.hideVelocities()
|
|
event.ignore()
|
|
super().keyReleaseEvent(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.simpleNoteNames = [] #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["simpleNoteNames"])
|
|
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.
|
|
E.g. it happens when you switch the active gui track"""
|
|
#if pNoteNames in pitch.notenames.keys():
|
|
# self.simpleNoteNames = pitch.notenames[pNoteNames]
|
|
#else:
|
|
self.simpleNoteNames = pNoteNames
|
|
for pitchWidget in self.pitchWidgets:
|
|
pitchWidget.spinBoxValueChanged() #change all current pitchWidgets
|
|
|
|
def sendToEngine(self, callback=True):
|
|
result = [widget.spinBox.value() for widget in self.pitchWidgets]
|
|
#result.reverse()
|
|
trackId = self.parentScene.parentView.parentMainWindow.currentTrackId
|
|
if trackId: #startup check
|
|
api.setScale(trackId, scale=result, callback=callback)
|
|
|
|
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", "Chromatic")
|
|
|
|
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.simpleNoteNames.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.simpleNoteNames.keys())
|
|
noteNamesAsString = sorted(pitch.simpleNoteNames.keys())[index]
|
|
simpleNoteNames = pitch.simpleNoteNames[noteNamesAsString]
|
|
api.setSimpleNoteNames(trackId=self.parentScene.parentView.parentMainWindow.currentTrackId, simpleNoteNames=simpleNoteNames)
|
|
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(110, 18)
|
|
self.label.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
|
|
|
|
layout.addWidget(self.label)
|
|
layout.addWidget(self.spinBox)
|
|
|
|
self.setWidget(widget)
|
|
|
|
self.spinBox.wheelEvent = self.spinBoxMouseWheelEvent
|
|
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
|
|
|
|
|
|
def midiToNotename(self, midipitch):
|
|
assert self.parentItem.simpleNoteNames, self.parentItem.simpleNoteNames
|
|
try:
|
|
return self.parentItem.simpleNoteNames[midipitch] #includes octave names
|
|
except IndexError:
|
|
print (midipitch)
|
|
print (self.parentItem.simpleNoteNames)
|
|
exit()
|
|
|
|
def spinBoxValueChanged(self):
|
|
self.label.setText(self.midiToNotename(self.spinBox.value()))
|
|
#self.parentItem.sendToEngine(callback=False) # results in a loop with callback, and in wrong data without. This is not the right place to implement immediate note feedback while editing is still going on.
|
|
|
|
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)
|
|
|
|
def spinBoxMouseWheelEvent(self, event):
|
|
"""We cannot use spinBoxValueChanged to send mousewheel scrolling pitch changing directly
|
|
to the engine while editing is still active. this results in signal loops and various
|
|
data corruptions. Fixing this would be far too much work.
|
|
You can either use the arrow keys and press enter, which triggers editingFinished.
|
|
But here we intercept the mousewheel directly."""
|
|
event.ignore()
|
|
QtWidgets.QSpinBox.wheelEvent(self.spinBox, event) #this changes to the new text and therefore the new value. Call BEFORE sendToEngine
|
|
self.parentItem.sendToEngine(callback=False)
|
|
#if event.angleDelta().y() > 0: #up
|
|
#else: #down
|
|
|
|
|
|
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, mainWindow, patternScene):
|
|
super().__init__()
|
|
|
|
self.parentScene = patternScene
|
|
self.mainWindow = mainWindow
|
|
|
|
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.mainWindow.currentTrackId, steps=10)
|
|
else: #down
|
|
api.changePatternVelocity(trackId=self.mainWindow.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.mainWindow.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.mainWindow.currentTrackId, steps=-1)
|
|
#The steps got redrawn, therefore their velocity numbers are now invislbe again. We are still in hover modus:
|
|
self.parentScene.showVelocities()
|
|
|
|
|
|
|
|
|
|
|