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.
 
 

484 lines
24 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2020, Nils Hilbricht, Germany ( https://www.hilbricht.net )
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
This application is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import logging; logger = logging.getLogger(__name__); logger.info("import")
#Standard Library Modules
import os.path
#Third Party Modules
from PyQt5 import QtWidgets, QtCore, QtGui
#Template Modules
from template.qtgui.mainwindow import MainWindow as TemplateMainWindow
from template.qtgui.menu import Menu
from template.qtgui.about import About
#Our modules
import engine.api as api
from .songeditor import SongEditor, TrackLabelEditor
from .timeline import Timeline
from .pattern_grid import PatternGrid, VelocityControls, TransposeControls
from .resources import *
class MainWindow(TemplateMainWindow):
def __init__(self):
"""The order of calls is very important.
The split ploint is calling the super.__init. Some functions need to be called before,
some after.
For example:
The about dialog is created in the template main window init. So we need to set additional
help texts before that init.
"""
#Inject more help texts in the templates About "Did You Know" field.
#About.didYouKnow is a class variable.
#Make the first three words matter!
#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", "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."),
QtCore.QCoreApplication.translate("About", "The mouse wheel is very powerful: Use it to transpose measures (with or without Shift pressed), it resizes the measure number line, zooms when Ctrl is held down, changes row volumes in the pattern with the Alt key or sounds a preview if pressed on a step."),
QtCore.QCoreApplication.translate("About", "Many elements have context menus with unique functions: Try right clicking on a Step, the Track name or a measure in the song editor."),
] + About.didYouKnow
super().__init__()
#New menu entries and template-menu overrides
self.menu.addMenuEntry(
"menuEdit",
"actionConvertSubdivisions",
QtCore.QCoreApplication.translate("Menu", "Convert Grouping"),
self.convertSubdivisionsSubMenu,
tooltip=QtCore.QCoreApplication.translate("Menu", "Change step-grouping but keep your music the same"),
)
self.ui.actionUndo.setVisible(False)
self.ui.actionRedo.setVisible(False)
#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", "[Home] Jump to Start"))
self.ui.toStartButton.clicked.connect(api.rewind)
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)
self.ui.songEditorView.setViewport(QtWidgets.QOpenGLWidget())
self.ui.trackEditorView.parentMainWindow = self
self.trackLabelEditor = TrackLabelEditor(parentView=self.ui.trackEditorView)
self.ui.trackEditorView.setScene(self.trackLabelEditor)
self.ui.trackEditorView.setViewport(QtWidgets.QOpenGLWidget())
self.ui.timelineView.parentMainWindow = self
self.timeline = Timeline(parentView=self.ui.timelineView)
self.ui.timelineView.setScene(self.timeline)
self.ui.timelineView.setViewport(QtWidgets.QOpenGLWidget())
#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)
self.ui.gridView.setRenderHints(QtGui.QPainter.TextAntialiasing)
self.ui.gridView.setViewport(QtWidgets.QOpenGLWidget()) #TODO: QT BUG! Review in the far future.
#ProxyWidget-Items in an openGl accel. GraphicsView create a ton of messages: Unsupported composition mode
#But everything works. Install a message handler to just get rid of the warning.
def passHandler(msg_type, msg_log_context, msg_string):
pass
QtCore.qInstallMessageHandler(passHandler)
self.patternToolbar = QtWidgets.QToolBar()
self.ui.patternArea.layout().insertWidget(0, self.patternToolbar)
self._populatePatternToolbar()
#Toolbar, which needs the widgets above already established
self._populateToolbar()
self.currentTrackId = None #this is purely a GUI construct. the engine does not know a current track. On startup there is no active track
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.
#There is always a track. Forcing that to be active is better than having to hide all the pattern widgets, or to disable them.
#However, we need the engine to be ready.
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.ui.gridView.horizontalScrollBar().setSliderPosition(0)
self.ui.gridView.verticalScrollBar().setSliderPosition(0)
#There is so much going on in the engine, we never reach a save status on load.
#Here is the crowbar-method.
self.nsmClient.announceSaveStatus(isClean = True)
def chooseCurrentTrack(self, exportDict):
"""This is in mainWindow because we need access to different sections of the program.
newCurrentTrack is a backend track ID
This is not triggered by the engine but by our GUI functions. exportDict is not the current
engine data but a cached version. Careful! For example the pattern redraws when the
current track changes, but there was no callback that informed the songeditor of a changed
pattern because it doesn't deal with patterns. So it didn't receive the new exportDict and
sent its old cached version to the patternGrid via this function. So the grid never
got the new information and "forgot" all settings.
"""
newCurrentTrackId = exportDict["id"]
if self.currentTrackId == newCurrentTrackId:
return True
participantsDicts = (self.trackLabelEditor.tracks, self.songEditor.tracks) #all structures with dicts with key trackId that need active/inactive marking
for d in participantsDicts:
try: #First mark old one inactive
d[self.currentTrackId].mark(False)
except KeyError: #track was deleted or never existed (load empty file)
pass
d[newCurrentTrackId].mark(True) #New one as active
self.patternGrid.guicallback_chooseCurrentTrack(exportDict)
#Remember current one for next round and for other functions
#Functions depend on getting set after getting called. They need to know the old track!
self.currentTrackId = newCurrentTrackId
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)
def cloneSelectedTrack(self):
"""Add a new track via the template option. This is the best track add function"""
newTrackExporDict = api.createSiblingTrack(self.currentTrackId)
self.chooseCurrentTrack(newTrackExporDict)
def _populatePatternToolbar(self):
self.patternToolbar.contextMenuEvent = api.nothing #remove that annoying Checkbox context menu
spacerItemLeft = QtWidgets.QWidget()
spacerItemLeft.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
spacerItemRight = QtWidgets.QWidget()
spacerItemRight.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
self.patternToolbar.addWidget(spacerItemLeft)
#spacerItemRight is added as last widget
#Actual widgets
velocityControls = VelocityControls(mainWindow=self, patternScene=self.patternGrid)
self.patternToolbar.addWidget(velocityControls)
transposeControls = TransposeControls(parentScene=self.patternGrid)
self.patternToolbar.addWidget(transposeControls)
#Finally add a spacer to center all widgets
self.patternToolbar.addWidget(spacerItemRight)
def _populateToolbar(self):
self.ui.toolBar.contextMenuEvent = api.nothing #remove that annoying Checkbox context menu
#Designer Buttons
self.ui.actionAddTrack.triggered.connect(self.addTrack)
self.ui.actionClone_Selected_Track.triggered.connect(self.cloneSelectedTrack)
#New Widgets
#BPM. Always in quarter notes to keep it simple
beatsPerMinuteBlock = QtWidgets.QWidget()
bpmLayout = QtWidgets.QHBoxLayout()
bpmLayout.setSpacing(0)
bpmLayout.setContentsMargins(0,0,0,0)
beatsPerMinuteBlock.setLayout(bpmLayout)
bpmCheckbox = QtWidgets.QCheckBox(QtCore.QCoreApplication.translate("Toolbar", "BPM/Tempo: "))
bpmCheckbox.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Deactivate to beccome JACK Transport Slave. Activate for Master."))
bpmLayout.addWidget(bpmCheckbox)
beatsPerMinute = QtWidgets.QSpinBox()
beatsPerMinute.setToolTip(bpmCheckbox.toolTip())
beatsPerMinute.setMinimum(0) #0 means off
beatsPerMinute.setMaximum(999)
beatsPerMinute.setSpecialValueText("JACK")
bpmLayout.addWidget(beatsPerMinute)
def quarterNotesPerMinuteChanged():
beatsPerMinute.blockSignals(True)
assert bpmCheckbox.isChecked()
value = beatsPerMinute.value()
api.set_quarterNotesPerMinute(max(1, value))
beatsPerMinute.blockSignals(False)
beatsPerMinute.editingFinished.connect(quarterNotesPerMinuteChanged)
def bpmCheckboxChanged(state):
bpmCheckbox.blockSignals(True)
if state:
api.set_quarterNotesPerMinute("on")
else:
api.set_quarterNotesPerMinute(None)
bpmCheckbox.blockSignals(False)
bpmCheckbox.stateChanged.connect(bpmCheckboxChanged)
def callback_quarterNotesPerMinuteChanged(newValue):
#We just receive an int, not a dict.
beatsPerMinute.blockSignals(True)
bpmCheckbox.blockSignals(True)
if newValue:
bpmCheckbox.setChecked(True)
beatsPerMinute.setEnabled(True)
beatsPerMinute.setValue(newValue)
else:
beatsPerMinute.setEnabled(False)
bpmCheckbox.setChecked(False)
beatsPerMinute.setValue(0)
beatsPerMinute.blockSignals(False)
bpmCheckbox.blockSignals(False)
api.callbacks.quarterNotesPerMinuteChanged.append(callback_quarterNotesPerMinuteChanged)
#Number of Measures
numberOfMeasures = QtWidgets.QSpinBox()
numberOfMeasures.setMinimum(1)
numberOfMeasures.setMaximum(9999)
numberOfMeasures.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Overall length of the song"))
def numberOfMeasuresChanged():
numberOfMeasures.blockSignals(True)
api.set_numberOfMeasures(int(numberOfMeasures.value()))
numberOfMeasures.blockSignals(False)
numberOfMeasures.editingFinished.connect(numberOfMeasuresChanged)
def callback_setnumberOfMeasures(exportDictScore):
numberOfMeasures.blockSignals(True)
numberOfMeasures.setValue(exportDictScore["numberOfMeasures"])
numberOfMeasures.blockSignals(False)
api.callbacks.numberOfMeasuresChanged.append(callback_setnumberOfMeasures)
#Subdivisions
#See manual
numberOfSubdivisions = QtWidgets.QSpinBox()
numberOfSubdivisions.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Please read the manual!"))
self.numberOfSubdivisions = numberOfSubdivisions #access from the convert dialog
numberOfSubdivisions.setMinimum(1)
numberOfSubdivisions.setMaximum(4)
def numberOfSubdivisionsChanged():
api.set_subdivisions(numberOfSubdivisions.value())
numberOfSubdivisions.editingFinished.connect(numberOfSubdivisionsChanged)
def callback_subdivisionsChanged(newValue):
numberOfSubdivisions.blockSignals(True)
numberOfSubdivisions.setValue(newValue)
numberOfSubdivisions.blockSignals(False)
api.callbacks.subdivisionsChanged.append(callback_subdivisionsChanged)
#Time Signature
unitTypes = [
QtCore.QCoreApplication.translate("TimeSignature", "Whole"),
QtCore.QCoreApplication.translate("TimeSignature", "Half"),
QtCore.QCoreApplication.translate("TimeSignature", "Quarter"),
QtCore.QCoreApplication.translate("TimeSignature", "Eigth"),
QtCore.QCoreApplication.translate("TimeSignature", "Sixteenth")
]
units = [api.D1, api.D2, api.D4, api.D8, api.D16]
howManyUnits = QtWidgets.QSpinBox()
howManyUnits.setMinimum(1)
howManyUnits.setMaximum(999)
howManyUnits.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Length of the pattern (bottom part of the program)"))
whatTypeOfUnit = QtWidgets.QComboBox()
whatTypeOfUnit.addItems(unitTypes)
#whatTypeOfUnit.setStyleSheet("QComboBox { background-color: transparent; } QComboBox QAbstractItemView { selection-background-color: transparent; } ");
whatTypeOfUnit.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "How long is each main step"))
def typeChanged(index):
whatTypeOfUnit.blockSignals(True)
api.set_whatTypeOfUnit(units[index])
whatTypeOfUnit.blockSignals(False)
whatTypeOfUnit.currentIndexChanged.connect(typeChanged)
def numberChanged():
howManyUnits.blockSignals(True)
api.set_howManyUnits(howManyUnits.value())
howManyUnits.blockSignals(False)
howManyUnits.editingFinished.connect(numberChanged)
def callback_setTimeSignature(nr, typ):
howManyUnits.blockSignals(True)
whatTypeOfUnit.blockSignals(True)
idx = units.index(typ)
whatTypeOfUnit.setCurrentIndex(idx)
howManyUnits.setValue(nr)
howManyUnits.blockSignals(False)
whatTypeOfUnit.blockSignals(False)
api.callbacks.timeSignatureChanged.append(callback_setTimeSignature)
#Add all to Toolbar
def spacer():
"""Insert a spacing widget. positions depends on execution order"""
spacer = QtWidgets.QWidget()
spacer.setFixedWidth(5)
self.ui.toolBar.addWidget(spacer)
#Clone Track and AddTrack button is added through Designer but we change the text here to get a translation
self.ui.actionClone_Selected_Track.setText(QtCore.QCoreApplication.translate("Toolbar", "Clone Selected Track"))
self.ui.actionClone_Selected_Track.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Use this! Create a new track that inherits everything but the content from the original. Already jack connected!"))
self.ui.actionAddTrack.setText(QtCore.QCoreApplication.translate("Toolbar", "Add Track"))
self.ui.actionAddTrack.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Add a complete empty track that needs to be connected to an instrument manually."))
spacer()
self.ui.toolBar.addWidget(beatsPerMinuteBlock) # combined widget with its label and translation included
spacer()
l = QtWidgets.QLabel(QtCore.QCoreApplication.translate("Toolbar", "Measures per Track: "))
l.setToolTip(numberOfMeasures.toolTip())
self.ui.toolBar.addWidget(l)
self.ui.toolBar.addWidget(numberOfMeasures)
spacer()
l = QtWidgets.QLabel(QtCore.QCoreApplication.translate("Toolbar", "Steps per Pattern:"))
l.setToolTip(howManyUnits.toolTip())
self.ui.toolBar.addWidget(l)
self.ui.toolBar.addWidget(howManyUnits)
l = QtWidgets.QLabel(QtCore.QCoreApplication.translate("Toolbar", " in groups of: "))
l.setToolTip(howManyUnits.toolTip())
self.ui.toolBar.addWidget(l)
self.ui.toolBar.addWidget(numberOfSubdivisions)
l = QtWidgets.QLabel(QtCore.QCoreApplication.translate("Toolbar", " so that each group produces a:"))
l.setToolTip(whatTypeOfUnit.toolTip())
self.ui.toolBar.addWidget(l)
self.ui.toolBar.addWidget(whatTypeOfUnit)
spacer()
def zoomUpperHalf(self, delta):
"""This is called from within the parts of the combined song editor.
The Song Editor consists of three graphic scenes and their views.
But only the part where you can switch measures on and off calls this."""
try: self.zoomFactor
except: self.zoomFactor = 1 # no save. We don't keep a qt config.
if delta > 0: #zoom in
self.zoomFactor = min(5, round(self.zoomFactor + 0.25, 2))
else: #zoom out
self.zoomFactor = max(1, round(self.zoomFactor - 0.25, 2))
for view in (self.ui.songEditorView, self.ui.trackEditorView, self.ui.timelineView):
view.resetTransform()
self.ui.songEditorView.scale(self.zoomFactor, self.zoomFactor)
self.ui.trackEditorView.scale(1, self.zoomFactor)
self.ui.timelineView.scale(self.zoomFactor, 1)
#view.centerOn(event.scenePos())
def convertSubdivisionsSubMenu(self):
class Submenu(QtWidgets.QDialog):
def __init__(self, mainWindow):
super().__init__(mainWindow) #if you don't set the parent to the main window the whole screen will be the root and the dialog pops up in the middle of it.
#self.setModal(True) #we don't need this when called with self.exec() instead of self.show()
self.layout = QtWidgets.QFormLayout()
self.setLayout(self.layout)
self.numberOfSubdivisions = QtWidgets.QSpinBox()
self.numberOfSubdivisions.setMinimum(1)
self.numberOfSubdivisions.setMaximum(4)#
self.numberOfSubdivisions.setValue(mainWindow.numberOfSubdivisions.value())
self.layout.addRow(QtCore.QCoreApplication.translate("convertSubdivisionsSubMenu", "New Grouping"), self.numberOfSubdivisions)
self.errorHandling = QtWidgets.QComboBox()
self.errorHandling.addItems([
QtCore.QCoreApplication.translate("convertSubdivisionsSubMenu", "Do nothing"),
QtCore.QCoreApplication.translate("convertSubdivisionsSubMenu", "Delete wrong steps"),
QtCore.QCoreApplication.translate("convertSubdivisionsSubMenu", "Merge wrong steps"),
])
self.layout.addRow(QtCore.QCoreApplication.translate("convertSubdivisionsSubMenu", "If not possible"), self.errorHandling)
#self.setFocus(); #self.grabKeyboard(); #redundant for a proper modal dialog. Leave here for documentation reasons.
buttonBox = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel)
buttonBox.accepted.connect(self.accept)
buttonBox.rejected.connect(self.reject)
self.layout.addWidget(buttonBox)
def __call__(self):
"""This instance can be called like a function"""
self.exec() #blocks until the dialog gets closed
s = Submenu(self)
s()
if s.result():
value = s.numberOfSubdivisions.value()
error= ("fail", "delete", "merge")[s.errorHandling.currentIndex()]
api.convert_subdivisions(value, error)
#Override template functions
def _stretchXCoordinates(*args): pass