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.

445 lines
22 KiB

#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2018, Nils Hilbricht, Germany ( https://www.hilbricht.net )
This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
more specifically its template base application.
The Template Base Application is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import logging; logging.info("import {}".format(__file__))
#Standard Library Modules
import os.path
6 years ago
#Third Party Modules
from PyQt5 import QtWidgets, QtCore, QtGui
6 years ago
#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
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."),
] + About.didYouKnow
super().__init__()
#New menu entries and template-menu overrides
#Playback Controls
width = 65
self.ui.playPauseButton.setFixedWidth(width)
self.ui.playPauseButton.setText("")
self.ui.playPauseButton.setIcon(QtGui.QIcon(':playpause.png'))
self.ui.playPauseButton.clicked.connect(api.playPause)
self.ui.playPauseButton.setToolTip(QtCore.QCoreApplication.translate("PlaybackControls", "[Space] Play / Pause"))
self.ui.centralwidget.addAction(self.ui.actionPlayPause) #no action without connection to a widget.
self.ui.actionPlayPause.triggered.connect(self.ui.playPauseButton.click)
self.ui.loopButton.setFixedWidth(width)
self.ui.loopButton.setText("")
self.ui.loopButton.setIcon(QtGui.QIcon(':loop.png'))
self.ui.loopButton.setToolTip(QtCore.QCoreApplication.translate("PlaybackControls", "[L] Loop current Measure"))
self.ui.loopButton.clicked.connect(api.toggleLoop)
self.ui.centralwidget.addAction(self.ui.actionLoop) #no action without connection to a widget.
self.ui.actionLoop.triggered.connect(self.ui.loopButton.click)
def callback_loopButtonText(measureNumber):
if not measureNumber is None:
nrstr = str(measureNumber+1)
self.ui.loopButton.setText(nrstr)
else:
self.ui.loopButton.setText("")
api.callbacks.loopChanged.append(callback_loopButtonText)
self.ui.toStartButton.setFixedWidth(width)
self.ui.toStartButton.setText("")
self.ui.toStartButton.setIcon(QtGui.QIcon(':tostart.png'))
self.ui.toStartButton.setToolTip(QtCore.QCoreApplication.translate("PlaybackControls", "[Backspace] Jump to Start"))
self.ui.toStartButton.clicked.connect(api.toStart)
self.ui.centralwidget.addAction(self.ui.actionToStart) #no action without connection to a widget.
self.ui.actionToStart.triggered.connect(self.ui.toStartButton.click)
##Song Editor
self.ui.songEditorView.parentMainWindow = self
self.songEditor = SongEditor(parentView=self.ui.songEditorView)
self.ui.songEditorView.setScene(self.songEditor)
self.ui.trackEditorView.parentMainWindow = self
self.trackLabelEditor = TrackLabelEditor(parentView=self.ui.trackEditorView)
self.ui.trackEditorView.setScene(self.trackLabelEditor)
self.ui.timelineView.parentMainWindow = self
self.timeline = Timeline(parentView=self.ui.timelineView)
self.ui.timelineView.setScene(self.timeline)
#Sync the vertical trackEditorView scrollbar (which is never shown) with the songEditorView scrollbar.
self.ui.songEditorView.setVerticalScrollBar(self.ui.trackEditorView.verticalScrollBar()) #this seems backwards, but it is correct :)
#Sync the horizontal timelineView scrollbar (which is never shown) with the songEditorView scrollbar.
self.ui.songEditorView.setHorizontalScrollBar(self.ui.timelineView.horizontalScrollBar()) #this seems backwards, but it is correct :)
##Pattern Editor
self.ui.gridView.parentMainWindow = self
self.patternGrid = PatternGrid(parentView=self.ui.gridView)
self.ui.gridView.setScene(self.patternGrid)
6 years ago
#Toolbar, which needs the widgets above already established
self._populateToolbar()
#MainWindow Callbacks
api.callbacks.numberOfTracksChanged.append(self.callback_numberOfTracksChanged)
6 years ago
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.
6 years ago
#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.
6 years ago
def callback_numberOfTracksChanged(self, exportDictList):
"""We need to find out of the current track was the deleted one or if a new track got added
automatically."""
#if self.programStarted and len(exportDictList) == 1:
if len(exportDictList) == 1:
self.chooseCurrentTrack(exportDictList[0])
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)
6 years ago
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 _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)
#Subdivisions Convert Button
convertSubdivisions = QtWidgets.QToolButton()
convertSubdivisions.setText(QtCore.QCoreApplication.translate("Toolbar", "Convert Grouping"))
convertSubdivisions.clicked.connect(self.convertSubdivisionsSubMenu)
convertSubdivisions.setToolTip(QtCore.QCoreApplication.translate("Toolbar", "Change step grouping but keep your music the same"))
#Add all to Toolbar
def spacer():
"""Insert a spacing widget. positions depends on execution order"""
spacer = QtWidgets.QWidget()
spacer.setFixedWidth(30)
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()
self.ui.toolBar.addWidget(convertSubdivisions)
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)