#! /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 . """ 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 zoom(self, scaleFactor:float): pass def stretchXCoordinates(self, factor): pass 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