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.
 
 
 
 
 
 

794 lines
36 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2021, Nils Hilbricht, Germany ( https://www.hilbricht.net )
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
This is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import logging; logger = logging.getLogger(__name__); logger.info("import")
#Standard Library Modules
#Third Party Modules
from PyQt5 import QtWidgets, QtCore, QtGui
#Template Modules
from template.qtgui.helper import stretchRect
from template.helper import listToUniqueKeepOrder
#User modules
import engine.api as api
from .pianogrid import PianoGrid
from . import items
from .inputcursor import InputCursor
from .playhead import Playhead
from .constantsAndConfigs import constantsAndConfigs
class Score(QtWidgets.QGraphicsScene):
def __init__(self, parentView):
super().__init__()
self.parentView = parentView
#Set color, otherwise it will be transparent in window managers or wayland that want that.
self.backColor = QtGui.QColor()
self.backColor.setNamedColor("#fdfdff")
self.setBackgroundBrush(self.backColor)
#Selection Related
self.selectedItems = [] #reset by right mouse click on an open field.
self.lastStart = None
self.lastEnd = None
self.lastMouseScenePos = QtCore.QPointF(0,0)
self._dragStartItem = None #convenience
self._resizeStartItem = None #convenience
#Input Cursor
self.inputCursor = InputCursor(self)
self.addItem(self.inputCursor)
#position is synced in mouseMoveEvent
#Note preview
self._lastPlayPitch = None
self._middleMouseDown = False
#Playhead
self.playhead = Playhead(self)
self.addItem(self.playhead)
self.playhead.setY(0)
self.grid = PianoGrid(parentScene=self)
self.addItem(self.grid)
self.grid.setPos(0, 0)
self.grid.setZValue(-50)
#Layers are created once and then kept alive.
self.layers = {}
for i in range(10):
l = GuiLayer(self, i)
self.addItem(l)
l.setPos(0,0)
l.setZValue(10)
self.layers[i] = l #index:GuiLayer
api.callbacks.newEvent.append(lambda eventDict: self.layers[eventDict["layer"]].newEvent(eventDict))
api.callbacks.deleteEvent.append(lambda eventDict: self.layers[eventDict["layer"]].deleteEvent(eventDict))
api.callbacks.eventByteOneChanged.append(lambda eventDict: self.layers[eventDict["layer"]].eventByteOneChanged(eventDict)) #aka movePitch
api.callbacks.eventByteTwoChanged.append(lambda eventDict: self.layers[eventDict["layer"]].eventByteTwoChanged(eventDict)) #Velocity
api.callbacks.eventFreeTextChanged.append(lambda eventDict: self.layers[eventDict["layer"]].eventFreeTextChanged(eventDict)) #Free Text for all items
api.callbacks.eventPositionChanged.append(lambda eventDict: self.layers[eventDict["layer"]].eventPositionChanged(eventDict))
api.callbacks.layerChanged.append(lambda layer, data: self.layers[layer].redrawEvents(data))
api.callbacks.layerColorChanged.append(lambda layer, colorString: self.layers[layer].layerColorChanged(colorString))
api.callbacks.activeLayerChanged.append(self.activeLayerChanged)
api.callbacks.setPlaybackTicks.append(self.updateNotesInProgress)
api.callbacks.pastedEvents.append(self.reactToPasteSelectionChange)
def wheelEvent(self, event):
"""We MUST handle the event somehow. Otherwise background grid items will block the views(!)
wheel scrolling, even when disabled and setting accepting mouse events to none.
This is a qt bug that won't be fixed because API stability over correctnes (according to the
bugtracker.
Contrary to other parts of the system event.ignore and accept actually mean something.
ignore will tell the caller to use the event itself, e.g. scroll.
This event gets the wheel BEFORE the main window (zoom)
"""
#item = self.itemAt(event.scenePos(), self.parentView.transform())
#if type(item) is items.Note:
# super().wheelEvent(event) #send to child item
#else:
event.ignore() #so the view scrolls or we zoom
def selectedItemsToListOfEngineIds(self)->list:
if not self.selectedItems:
return []
result = list() #do not use a set! this changes the order
for item in self.selectedItems:
result += item.ids
return result
def selectedItemsToListOfEngineIdsAndLayers(self)->list:
if not self.selectedItems:
return []
result = list()
for item in self.selectedItems:
for id in item.ids: #do not use a set! this changes the order
result.append((id, item.parentLayerIndex))
return result
def selectedItemsToListOfEngineIdsSplitNotesAndOthers(self)->tuple:
if not self.selectedItems:
return []
notes = []
others = []
for item in self.selectedItems:
if type(item) is items.Note or type(item) is items.ProgramChange:
for id in item.ids: #do not use a set! this changes the order
notes.append(id)
else:
for id in item.ids:
break #id is now an item of ids. Fastest method to get any item of a set.
others.append(id)
return notes, others
return notes, others
def reactToPasteSelectionChange(self, listOfEngineIdsAndLayers):
self._clearSelectedItems()
for id, layerIndex in listOfEngineIdsAndLayers:
self.selectedItems.append(self.layers[layerIndex].items[id])
self.selectedItems = list(set(self.selectedItems)) #remove note-off duplicates
self._markSelectedItems()
self._selectionChanged()
def _selectionChanged(self):
"""Only this scoreView creates and manipulates selections, but other widgets,
like VelocityView, need to know what the selection is. We call a mainWindow function to let
them know and use engineIDs for item identifiers"""
ids = self.selectedItemsToListOfEngineIdsAndLayers()
self.parentView.mainWindow.selectionChanged(ids)
def selectActiveLayer(self):
self._clearSelectedItems()
layerIndex = api.getActiveLayer()
self.selectedItems = list(set(self.layers[layerIndex].items.values()))
self._markSelectedItems()
self._selectionChanged()
def selectAll(self):
self._clearSelectedItems()
result = set()
for layer in self.layers.values():
result.update(layer.items.values())
self.selectedItems = list(result)
self._markSelectedItems()
self._selectionChanged()
def highlight(self,layerIndex:int, noteOnEngineId:int, state:bool):
self.layers[layerIndex].highlight(noteOnEngineId, state)
def velocityChangeRelative(self, differenceInSteps):
if not self.selectedItems:
return []
selectedNotes, selectedOthers = self.selectedItemsToListOfEngineIdsSplitNotesAndOthers()
api.changeVelocitiesRelative(selectedNotes, differenceInSteps)
def moveSelectedItemsRelative(self, differenceInSteps):
"""Triggered by mouse release event. But also possible as cursor keys.
Uses an api call that does not delete the selection as no items are created or deleted.
The api deals with out-of-bounds pitch problems"""
if not self.selectedItems:
return []
selectedNotes, selectedOthers = self.selectedItemsToListOfEngineIdsSplitNotesAndOthers()
api.changeBytesByIdSeparateLists(selectedNotes, selectedOthers, differenceInSteps) #first list is byte1, second byte2
def repositionSelectedItemsRelative(self, differenceInEngineTicks):
selectedItems = self.selectedItemsToListOfEngineIds()
api.repositionItemsRelative(selectedItems, differenceInEngineTicks)
def sendToApiSelectedItems2DAbsolute(self):
"""Take all selected items and send their current tickposition, byte1, byte2 to the engine
for a data update and midi rebuild. This is used primarily after moving a selection with the
mouse, hence 2D.
We keep our items up to date. byte1 and byte2 are not cached engine parameters but the
current ones.
It also updates duration, so it is used for resizing as well."""
if not self.selectedItems:
return []
dataSet = {} # id:(pos,byte1)
for item in self.selectedItems:
dataSet[item.cachedExportDict["id"]] = (item.getEngineTickPosition(), item.byte1, item.byte2)
if item.noteOffExportDict:
dataSet[item.noteOffExportDict["id"]] = (item.getNoteOffEngineTickPosition(), item.byte1, item.byte2)
api.moveEvents(dataSet)
def deleteSelectedItems(self):
"""Called by the user directly via menu action.
Send to the api that we want to delete selected items.
Actual deleteing will come back as callback.
"""
result = self.selectedItemsToListOfEngineIds()
self.selectedItems = []
self._selectionChanged()
api.deleteEventsById(result)
def _markSelectedItems(self):
for item in self.selectedItems:
item.isSelected = True
item.setBrush(item.parentLayer.selectionColor)
def _clearSelectedItems(self):
for item in self.selectedItems:
item.setBrush(item.parentLayer.color)
item.isSelected = False
self.selectedItems = []
self._selectionChanged()
def _toggleItemSelection(self, item):
if item in self.selectedItems:
self.selectedItems.remove(item)
item.setBrush(item.parentLayer.color)
item.isSelected = False
else:
self.selectedItems.append(item)
item.setBrush(item.parentLayer.selectionColor)
item.isSelected = True
self._selectionChanged()
def keyPressEvent(self, event):
if (not self._dragStartItem) and (not self._resizeStartItem) and (not self.inputCursor.duringFreehandDrawing) and event.key() in (QtCore.Qt.Key_Shift, QtCore.Qt.Key_Control):
self.inputCursor.temporaryToggleForKeyPresses(True)
super().keyPressEvent(event)
def keyReleaseEvent(self, event):
if (not self._dragStartItem) and (not self._resizeStartItem) and (not self.inputCursor.duringFreehandDrawing) and event.key() in (QtCore.Qt.Key_Shift, QtCore.Qt.Key_Control):
self.inputCursor.temporaryToggleForKeyPresses(False)
super().keyPressEvent(event)
def react_RubberBandChanged(self, rubberBandRect:QtCore.QRect, fromScenePoint:QtCore.QPointF, toScenePoint:QtCore.QPointF):
"""This signal is emitted when the rubber band rect is changed.
It gets connected by the ScoreView.rubberBandChanged
The viewport Rect is specified by rubberBandRect. The drag start position and
drag end position are provided in scene points with fromScenePoint and toScenePoint.
When rubberband selection ends this signal will be emitted with null vales."""
if not toScenePoint and self.lastStart and self.lastEnd: #Selection exists and mouse button not pressed down -> End of Selection Process
itemsInRubberband = self.items(QtCore.QRectF(self.lastStart, self.lastEnd).normalized())
self.lastStart = None
self.lastEnd = None
try:
itemsInRubberband.remove(self.playhead)
except ValueError: #not in list/selection
pass
filtered = list(filter(lambda item: item.isEnabled(), itemsInRubberband)) #remove shadows, child-labels of CCs and ADD to the current selection.
mods = QtWidgets.QApplication.keyboardModifiers()
if mods == QtCore.Qt.ControlModifier: #Invert Selection
for item in filtered:
self._toggleItemSelection(item)
elif mods == QtCore.Qt.ShiftModifier: #Add to selection
self.selectedItems += filtered
self._markSelectedItems()
else: #Create new selection
#self._clearSelectedItems() Already done in mousePressEvent
self.selectedItems = filtered
self._markSelectedItems()
self._selectionChanged()
else: #during rubberband modification
self.lastStart = fromScenePoint
self.lastEnd = toScenePoint
def mousePressEvent(self, event):
"""
The scene is the first to receive an event and will propagate that down to its items.
The items to their child items etc.
The event is propagated to items by calling super. The items will set accepted or
other data to the event. We loose all data when returning from this function to the View.
Qt will convert the event from GraphicsSceneEvent to normal Qt event and loose all data
Also accept/ignore is far too integrated into Qt and can bet set by any Item without our
control. We therefore use a custom data field, which is allowed because we are in Python."""
self.parentView.setDragMode(QtWidgets.QGraphicsView.NoDrag)
self._middleMouseDown = False
event.wasUsed = None
if event.button() == QtCore.Qt.MiddleButton:
self._middleMouseDown = True
self._lastPlayPitch = None #this must not be in _play, otherwise you can't move the mouse while pressed down
self._play(event)
self.parentView.setDragMode(QtWidgets.QGraphicsView.NoDrag)
return
elif event.button() == QtCore.Qt.LeftButton:
#Inject status flags into the event before sending it to the item
event.playhead = False
super().mousePressEvent(event) #send to item, if any. Sets event.wasUsed to an item. Will only happen with active items since shadows can't be clicked
#wasUsed always is set to an item.
if event.playhead:
return
#enter note duration change. this only exists for notes. duringDurationChange is False by default.
if event.wasUsed and event.wasUsed.duringDurationChange and event.wasUsed in self.selectedItems:
self.parentView.setDragMode(QtWidgets.QGraphicsView.NoDrag)
self._resizeStartItem = event.wasUsed
for item in self.selectedItems:
item.lastStableX = item.pos().x()
item.lastStableWidth = item.rect().width()
#enter event draggin mode by deactivating the rubberband selection and input cursor
elif event.wasUsed and event.wasUsed in self.selectedItems:
self._dragStartItem = event.wasUsed
#self._dragStartItem got itemClickedX injected by the item for later use
for item in self.selectedItems:
item.lastStableX = item.pos().x()
item.lastStableY = item.pos().y()
self.parentView.setDragMode(QtWidgets.QGraphicsView.NoDrag)
elif event.wasUsed: # but not in selected items. Technically a corner case, not allowed logically.
pass
#clicked on empty space.
else:
if QtWidgets.QApplication.keyboardModifiers() in (QtCore.Qt.ControlModifier, QtCore.Qt.ShiftModifier):
self.parentView.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag)
else:
self.parentView.setDragMode(QtWidgets.QGraphicsView.NoDrag) #better safe than sorry
self._clearSelectedItems()
if event.buttons() == QtCore.Qt.LeftButton:
self.inputCursor.putEvent() #The item gets created OR a freehand drawing begins, which sets self.inputCursor.duringFreehandDrawing = startXPos
self.parentView.mainWindow.setKeyboardEnabled(False)
elif not self.selectedItems and event.button() == QtCore.Qt.RightButton:
#We butchered and twisted the event system so far that items don't receive mouse events anymore, unless selected.
#To continue that pattern we trigger the context menu right here.
super().mousePressEvent(event)
items = self.collidingItems(self.inputCursor)
filtered = list(filter(lambda item: item.isEnabled(), items)) #remove shadows, child-labels of CCs and ADD to the current selection.
if filtered:
#if there is more than one item at this place, so bet it. At least that can be seen by the user
item = filtered[0]
item.contextMenu(event) #our own function
else:
super().mousePressEvent(event)
self._clearSelectedItems()
self.inputCursor.temporaryToggleForKeyPresses(False)
def mouseReleaseEvent(self, event):
self.parentView.mainWindow.setKeyboardEnabled(True)
#No matter which mouse button. Preview playback ends.
self._off()
self._middleMouseDown = False
self._lastPlayPitch = None
if self.selectedItems:
#check if this item was moved. If yes then all were moved, by the same distance
if self._dragStartItem:
self.sendToApiSelectedItems2DAbsolute() #calls the api, gathers its own data.
self._dragStartItem = None
elif self._resizeStartItem:
self.sendToApiSelectedItems2DAbsolute() #calls the api, gathers its own data.
self._resizeStartItem = None
#else: #nothing moved
if self.inputCursor.duringFreehandDrawing:
assert self._dragStartItem is None
assert self._resizeStartItem is None
self.inputCursor.stopFreeHandDrawing() #calls the api, sets duringFreehandDrawing to False
def mouseMoveEvent(self, event):
"""Event button is always 0 in a mouse move event. So we have to keep track of
the middle mouse button down ourselves."""
if self._middleMouseDown:
self._play(event)
super().mouseMoveEvent(event)
self.inputCursor.setPos(event.scenePos())
#event.button for moveEvent is always 0, plural buttons work.
#Duration Change
if self.selectedItems and self._resizeStartItem and event.buttons() == QtCore.Qt.LeftButton and self.parentView.dragMode() == QtWidgets.QGraphicsView.NoDrag:
if self._resizeStartItem.duringDurationChange == "right":
posX = round(event.scenePos().x() / constantsAndConfigs.snapToGrid ) * constantsAndConfigs.snapToGrid #snap to grid and duration
diffX = posX - self._resizeStartItem.lastStableX - self._resizeStartItem.lastStableWidth
#prevent the width to go smaller than a D32, or even into the negative. We need to check the whole selection
allowed = True
for item in self.selectedItems:
if item.lastStableWidth + diffX < (api.D32 / constantsAndConfigs.ticksToPixelRatio):
allowed = False
break
if allowed:
for item in self.selectedItems:
item.shiftEndInPixel(diffX)
elif self._resizeStartItem.duringDurationChange == "left":
posX = round(event.scenePos().x() / constantsAndConfigs.snapToGrid ) * constantsAndConfigs.snapToGrid #snap to grid and duration
diffX = posX - self._resizeStartItem.lastStableX
#prevent the width to go smaller than a D32, or even into the negative. We need to check the whole selection
allowed = True
for item in self.selectedItems:
if item.lastStableWidth - diffX < (api.D32 / constantsAndConfigs.ticksToPixelRatio):
allowed = False
break
if allowed:
for item in self.selectedItems:
item.shiftStartInPixel(diffX)
else:
raise ValueError("We have been instructed to do a duration change, but no direction was set")
#X-Y Movement
elif self.selectedItems and self._dragStartItem and event.buttons() == QtCore.Qt.LeftButton and self.parentView.dragMode() == QtWidgets.QGraphicsView.NoDrag:
#Here comes a bit of Copy and Paste Code, but checking just once for the key-combo is easier to read in the end:
#Only Horizontal Movement, changing the tick position
if QtWidgets.QApplication.keyboardModifiers() == QtCore.Qt.AltModifier:
posX = round(event.scenePos().x() / constantsAndConfigs.snapToGrid ) * constantsAndConfigs.snapToGrid #snap to grid and duration
diffX = posX - self._dragStartItem.lastStableX# - self._dragStartItem.itemClickedX
for item in self.selectedItems:
item.setX(item.lastStableX + diffX)
#Only Vertical Movement, changing the pitch.
elif QtWidgets.QApplication.keyboardModifiers() == QtCore.Qt.ControlModifier:
diffY = self.inputCursor.pitchPixel - self._dragStartItem.lastStableY
for item in self.selectedItems:
item.setY(item.lastStableY + diffY)
pitchInPianoRoll = int(127-((item.lastStableY + diffY) / constantsAndConfigs.stafflineGap))
item.setPianoRollPitch(pitchInPianoRoll)
#Free Movement in both directions
else:
#Y always snaps to pitches
diffY = self.inputCursor.pitchPixel - self._dragStartItem.lastStableY
posX = round(event.scenePos().x() / constantsAndConfigs.snapToGrid ) * constantsAndConfigs.snapToGrid #snap to grid and duration
diffX = posX - self._dragStartItem.lastStableX# - self._dragStartItem.itemClickedX
for item in self.selectedItems:
item.setX(item.lastStableX + diffX)
item.setY(item.lastStableY + diffY)
pitchInPianoRoll = int(127-((item.lastStableY + diffY) / constantsAndConfigs.stafflineGap))
item.setPianoRollPitch(pitchInPianoRoll)
self.lastMouseScenePos = event.scenePos()
def _off(self):
if not self._lastPlayPitch is None:
api.sendNoteOffToCbox(self._lastPlayPitch)
self._lastPlayPitch = None
def _play(self, event):
assert self._middleMouseDown
#pitch = 127 - int(event.scenePos().y() / constantsAndConfigs.stafflineGap)
pitch = self.inputCursor.pitch
if pitch < 0 or pitch > 127:
pitch = None
if not pitch == self._lastPlayPitch:
if not self._lastPlayPitch is None:
api.sendNoteOffToCbox(self._lastPlayPitch)
if not pitch is None:
api.sendNoteOnToCbox(pitch) #uses active layer median velocity
self._lastPlayPitch = pitch
def updateNotesInProgress(self, tickindex:int, playbackStatus:bool):
for layer in self.layers.values():
layer.updateNotesInProgress(tickindex)
def _hideAllLayers(self):
for layer in self.layers.values():
layer.setOpacity(1)
layer.setEnabled(True)
layer.hide()
def activeLayerChanged(self, layerIndex:int):
"""Callback for api.callbacks.activeLayerChanged"""
self._hideAllLayers() #also removes and resets shadows.
self.layers[layerIndex].show()
def showAllShadows(self):
"""Show all shadows, except the active layer"""
for i in range(10):
self.toggleShadowLayer(i, state=True)
def toggleShadowLayer(self, layerIndex:int, state:bool=False):
"""This is purely a GUI function. The active layer stays the same."""
if api.getActiveLayer() == layerIndex:
return #not allowed
layer = self.layers[layerIndex]
if state or layer.opacity() == 1:
layer.setOpacity(0.25)
layer.setEnabled(False)
layer.show()
else:
layer.setOpacity(1)
layer.setEnabled(True)
layer.hide()
def stretchXCoordinates(self, factor:float):
"""Reposition the items on the X axis.
Call goes through all parents/children, starting from ScoreView._stretchXCoordinates.
Docstring there."""
#The big structures have a fixed position at (0,0) and move its child items, like notes, internally
#Some items, like the cursor, move around, as a whole item, in the scene directly and need no stretchXCoordinates() themselves.
#Even if updated later they do this on the basis of tickFactor, which was adjusted at this point.
self.grid.stretchXCoordinates(factor)
self.playhead.setX(self.playhead.pos().x() * factor)
for layer in self.layers.values():
layer.stretchXCoordinates(factor)
def copy(self):
api.copyById(self.selectedItemsToListOfEngineIds())
def cut(self):
api.cutById(self.selectedItemsToListOfEngineIds())
def paste(self):
api.paste()
class GuiLayer(QtWidgets.QGraphicsItem):
def __init__(self, parentScore, index):
super().__init__()
self.parentScore = parentScore
self.index = index
self._inProgress = {} #pitch:item
self._fallbackBuffer = {} #pitch:lists #during file loading or otherwise it might happen that we get two note ons in a row of the same pitch and then two note offs in a row. While midi nonsensical it is technically allowed, so we support it.
self.items = {} #engineId:GuiItem. Notes are twice in the dict, one id for note-on and one for note-off. Careful! items() is also a scene command to get items.
self.color = None #set in self.setColor
self.selectionColor = None #set in self.reactToColorCallback
self.setFlag(QtWidgets.QGraphicsItem.ItemHasNoContents, True) #only child items. Without this we get notImplementedError: QGraphicsItem.paint() is abstract and must be overridden
self._fakeRect = QtCore.QRectF(0,0,0,0)
def boundingRect(self, *args):
return self._fakeRect
def stretchXCoordinates(self, factor:float):
for item in set(self.items.values()): #set to remove the same item from NoteOn and Off
item.stretchXCoordinates(factor)
item.setX(item.pos().x() * factor)
def layerColorChanged(self, color:str):
"""Out GUI calls
api.setLayerColor(self.index, self.color)
The engine already has the new color"""
self.color = QtGui.QColor(color)
if self.color.lightness() > 127: #between 0 (for black) and 255 (for white)
self.selectionColor = self.color.darker(150)
else:
self.selectionColor = self.color.lighter(200)
for item in set(self.items.values()): #set to remove the same item from NoteOn and Off
if item in self.parentScore.selectedItems:
item.setBrush(self.selectionColor)
else:
item.setBrush(self.color)
def redrawEvents(self, exportDict):
"""Clean redraw. Deletes all events of this layer.
Used only rarely because it is slow. E.g. on file load.
exportDict is sorted. No noteOff comes before its noteOn"""
assert exportDict["index"] == self.index
for item in set(self.items.values()): #set to remove the same item from NoteOn and Off
self.parentScore.removeItem(item)
self.items = {}
self.layerColorChanged(exportDict["color"])
for event in exportDict["events"]: # dictionary
self.newEvent(event)
def deleteEvent(self, eventDict):
"""Will be called twice in a row for a note on/off.
However, we only have one item for on/off and the off item was already deleted.
So we test if the item is still in the scene."""
if eventDict["id"] in self.items: #The universal safeguard against buggy delete selections
item = self.items[eventDict["id"]]
del self.items[eventDict["id"]]
if not item.scene() is None:
self.parentScore.removeItem(item)
else:
logger.warning(f"GUI Score tried to delete event {eventDict} but was no in our item dict.")
def newEvent(self, eventDict):
"""Used for all callbacks, be it live recording or file loading or anything in between"""
if eventDict["status"] == 0x90: #note on
item = self.liveNoteOn(eventDict)
elif eventDict["status"] == 0x80: #note off
item = self.liveNoteOff(eventDict)
elif eventDict["status"] == 0xA0: #Polyphonic Aftertouch
item = self.polyphonicAftertouch(eventDict)
elif eventDict["status"] == 0xB0: #CC
item = self.cc(eventDict)
elif eventDict["status"] == 0xC0: #Program Change
item = self.progamChange(eventDict)
elif eventDict["status"] == 0xD0: #Channel Pressure
item = self.channelPressure(eventDict)
elif eventDict["status"] == 0xE0: #Pitchbend
item = self.pitchBend(eventDict)
else:
raise ValueError("unknown statusByte", eventDict)
assert item.parentItem() is self, (item.parentItem, self)
item.ids.add(eventDict["id"])
self.items[eventDict["id"]] = item
def liveNoteOn(self, eventDict):
"""Pitch is midi.
This is a bad name. In fact this is for file loading AND live notes"""
pitch = eventDict["byte1"]
x = eventDict["position"] / constantsAndConfigs.ticksToPixelRatio
y = (127-pitch) * constantsAndConfigs.stafflineGap
item = items.Note(self, self.index, eventDict["byte1"], eventDict["byte2"], self.color, eventDict["freeText"])
self._setItemCachedExportDict(item, eventDict)
item.setPos(x, y)
if pitch in self._inProgress:
if not pitch in self._fallbackBuffer:
self._fallbackBuffer[pitch] = []
self._fallbackBuffer[pitch].append(item)
#raise RuntimeError(f"Double note-on detected: {pitch}.")
else:
self._inProgress[pitch] = item
return item
def liveNoteOff(self, eventDict):
pitch = eventDict["byte1"]
#Check for the corner case of overlapping notes.
if pitch in self._fallbackBuffer and self._fallbackBuffer[pitch]:
item = self._fallbackBuffer[pitch].pop()
else:
item = self._inProgress[pitch]
del self._inProgress[pitch]
item.setDuration(eventDict["position"] - item.cachedExportDict["position"])
#r = item.rect()
#r.setRight(eventDict["position"] / constantsAndConfigs.ticksToPixelRatio - item.x())
#item.setRect(r)
self._setItemCachedExportDict(item, eventDict)
return item
def cc(self, eventDict):
"""CCs switch byte1 and byte2 so the value can be drawn in the
main piano roll."""
pianoRollPitch = eventDict["byte2"]
x = eventDict["position"] / constantsAndConfigs.ticksToPixelRatio
y = (127-pianoRollPitch) * constantsAndConfigs.stafflineGap
item = items.CC(self, self.index, eventDict["byte1"], eventDict["byte2"], self.color, eventDict["freeText"])
self._setItemCachedExportDict(item, eventDict)
item.setPos(x, y)
return item
def polyphonicAftertouch(self, eventDict):
"""PolyphonicAftertouch switch byte1 and byte2 so the value can be drawn in the
main piano roll."""
pianoRollPitch = eventDict["byte2"]
x = eventDict["position"] / constantsAndConfigs.ticksToPixelRatio
y = (127-pianoRollPitch) * constantsAndConfigs.stafflineGap
item = items.PolyphonicAftertouch(self, self.index, eventDict["byte1"], eventDict["byte2"], self.color, eventDict["freeText"])
self._setItemCachedExportDict(item, eventDict)
item.setPos(x, y)
return item
def progamChange(self, eventDict):
"""Program Change only sends byte1, which we use as Y position on piano roll, like Notes"""
pianoRollPitch = eventDict["byte1"]
x = eventDict["position"] / constantsAndConfigs.ticksToPixelRatio
y = (127-pianoRollPitch) * constantsAndConfigs.stafflineGap
item = items.ProgramChange(self, self.index, eventDict["byte1"], eventDict["byte2"], self.color, eventDict["freeText"])
self._setItemCachedExportDict(item, eventDict)
item.setPos(x, y)
return item
def channelPressure(self, eventDict):
"""Channel Pressure only sends byte1, which we use as Y position on piano roll, like Notes"""
pianoRollPitch = eventDict["byte1"]
x = eventDict["position"] / constantsAndConfigs.ticksToPixelRatio
y = (127-pianoRollPitch) * constantsAndConfigs.stafflineGap
item = items.ChannelPressure(self, self.index, eventDict["byte1"], eventDict["byte2"], self.color, eventDict["freeText"])
self._setItemCachedExportDict(item, eventDict)
item.setPos(x, y)
return item
def pitchBend(self, eventDict):
"""CCs switch byte1 and byte2 so the value can be drawn in the
main piano roll.
Furthermore byte1 is always 0, which is Vico specific."""
pianoRollPitch = eventDict["byte2"]
x = eventDict["position"] / constantsAndConfigs.ticksToPixelRatio
y = (127-pianoRollPitch) * constantsAndConfigs.stafflineGap
item = items.PitchBend(self, self.index, eventDict["byte1"], eventDict["byte2"], self.color, eventDict["freeText"])
self._setItemCachedExportDict(item, eventDict)
item.setPos(x, y)
return item
def updateNotesInProgress(self, tickindex:int):
for item in self._inProgress.values():
r = item.rect()
r.setRight(tickindex / constantsAndConfigs.ticksToPixelRatio - item.x())
item.setRect(r)
def eventByteOneChanged(self, eventDict):
"""e.g. move a pitch up and down"""
item = self.items[eventDict["id"]]
self._setItemCachedExportDict(item, eventDict)
item.callbackByteOne(eventDict["byte1"])
def eventByteTwoChanged(self, eventDict):
item = self.items[eventDict["id"]]
self._setItemCachedExportDict(item, eventDict)
item.callbackByteTwo(eventDict["byte2"])
def eventFreeTextChanged(self, eventDict):
item = self.items[eventDict["id"]]
self._setItemCachedExportDict(item, eventDict)
item.setFreeText(eventDict["freeText"])
def _setItemCachedExportDict(self, item, eventDict):
if eventDict["status"] == 0x80:
item.noteOffExportDict = eventDict
else:
item.cachedExportDict = eventDict
def eventPositionChanged(self, eventDict):
item = self.items[eventDict["id"]]
self._setItemCachedExportDict(item, eventDict)
if eventDict["status"] == 0x80:
r = item.rect()
r.setRight(eventDict["position"] / constantsAndConfigs.ticksToPixelRatio - item.x())
item.setRect(r)
else:
x = eventDict["position"] / constantsAndConfigs.ticksToPixelRatio
item.setX(x)
def highlight(self, noteOnEngineId:int, state:bool):
self.items[noteOnEngineId].highlight(state)