#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2022 , 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
#Third Party
from PyQt5 import QtCore , QtGui , QtWidgets
#Our Qt
from . instrument import GuiInstrument , GuiLibrary #for the types
from template . qtgui . flowlayout import FlowLayout
#Engine
import engine . api as api
class SelectedInstrumentController ( object ) :
""" Not a qt class. We externally control a collection of widgets.
There is only one set of widgets . We change their contents dynamically .
The engine has no concept of " selected instrument " . This is purely on our GUI side .
We relay this information not only to our own information widgets but also to other widgets ,
like the piano .
"""
def __init__ ( self , parentMainWindow ) :
self . parentMainWindow = parentMainWindow
self . currentIdKey = None
self . engineData = { } # idKey tuple : engine library metadata dict.
self . statusUpdates = { } # same as engineData, but with incremental status updates. One is guranteed to exist at startup
self . controlWidgets = { } # ccNumber or other identifier : ControlWidget (our class from this file)
#Our Widgets
self . ui = parentMainWindow . ui
self . ui . details_groupBox . setTitle ( " " )
self . ui . details_scrollArea . hide ( ) #until the first instrument was selected
self . ui . controls_groupBox . setLayout ( FlowLayout ( margin = 1 ) )
self . ui . variants_comboBox . activated . connect ( self . _newVariantChosen )
self . ui . keySwitch_comboBox . activated . connect ( self . _newKeySwitchChosen )
#Callbacks
api . callbacks . instrumentListMetadata . append ( self . react_initialInstrumentList )
api . callbacks . instrumentStatusChanged . append ( self . react_instrumentStatusChanged )
api . callbacks . instrumentCCActivity . append ( self . react_instrumentCCActivity )
def directLibrary ( self , idKey : tuple ) :
""" User clicked on a library treeItem """
libraryId , instrumentId = idKey
self . currentIdKey = None
self . ui . details_scrollArea . show ( )
self . ui . variants_comboBox . hide ( )
self . ui . variant_label . hide ( )
self . ui . controls_groupBox . hide ( )
self . ui . keySwitch_label . hide ( )
self . ui . keySwitch_comboBox . hide ( )
metadata = self . engineData [ libraryId ] [ " library " ]
self . ui . details_groupBox . setTitle ( metadata [ " name " ] )
self . ui . info_label . setText ( self . _metadataToDescriptionLabel ( metadata ) )
def _metadataToDescriptionLabel ( self , metadata : dict ) - > str :
""" Can work with instruments and libraries alike """
if " variants " in metadata : #this is an instrument
fullText = metadata [ " description " ] + " \n \n Vendor: " + metadata [ " vendor " ] + " \n \n License: " + metadata [ " license " ]
else :
fullText = metadata [ " description " ] + " \n \n Vendor: " + metadata [ " vendor " ]
return fullText
def _newVariantChosen ( self , index : int ) :
""" User chose a new variant through the combo box """
assert self . ui . variants_comboBox . currentIndex ( ) == index
assert self . currentIdKey
api . chooseVariantByIndex ( self . currentIdKey , index )
def _newKeySwitchChosen ( self , index : int ) :
""" User chose a new keyswitch through the combo box.
Answer comes back via react_instrumentStatusChanged """
assert self . ui . keySwitch_comboBox . currentIndex ( ) == index
assert self . currentIdKey
#Send back the midi pitch, not the index
api . setInstrumentKeySwitch ( self . currentIdKey , self . ui . keySwitch_comboBox . itemData ( index ) )
def _populateKeySwitchComboBox ( self , instrumentStatus ) :
""" Convert engine format from callback to qt combobox string.
This is called when activating an instrument , switching the variant or simply
coming back to an already loaded instrument in the GUI .
This might come in from a midi callback when we are not currently selected .
We test if this message is really for us .
"""
#TODO: distinguish between momentary keyswitches sw_up sw_down and permanent sw_last. We only want sw_last as selection but the rest must be shown as info somewhere.
self . ui . keySwitch_comboBox . clear ( )
if not instrumentStatus or not " keySwitches " in instrumentStatus : #not all instruments have keyswitches
self . ui . keySwitch_comboBox . setEnabled ( False )
return
engineKeySwitchDict = instrumentStatus [ " keySwitches " ]
self . ui . keySwitch_comboBox . setEnabled ( True )
for midiPitch , ( opcode , label ) in sorted ( engineKeySwitchDict . items ( ) ) :
self . ui . keySwitch_comboBox . addItem ( f " [ { midiPitch } ]: { label } " , userData = midiPitch )
#set current one, if any. If not the engine-dict is None and we set to an empty entry, even if there are keyswitches. which is fine.
curIdx = self . ui . keySwitch_comboBox . findData ( instrumentStatus [ " currentKeySwitch " ] )
self . ui . keySwitch_comboBox . setCurrentIndex ( curIdx )
def _populateControls ( self , instrumentStatus : dict ) :
""" Remove all CC Knobs, Faders and other control widgets and draw them again for
the current GUI - Instrument .
This is called when activating an instrument , switching the variant or simply
coming back to an already loaded instrument in the GUI . """
for controlWidget in self . controlWidgets . values ( ) :
self . ui . controls_groupBox . layout ( ) . removeWidget ( controlWidget )
controlWidget . hide ( )
controlWidget . setParent ( None )
del controlWidget
self . controlWidgets = { }
if not instrumentStatus [ " state " ] or not instrumentStatus [ " controlLabels " ] : #Not activated yet.
return
ccNow = api . ccTrackingState ( instrumentStatus [ " idKey " ] )
for ccNumber , ccLabel in instrumentStatus [ " controlLabels " ] . items ( ) :
w = ControlWidget ( self . ui . controls_groupBox , instrumentStatus , ccNumber , ccLabel )
self . controlWidgets [ ccNumber ] = w
self . ui . controls_groupBox . layout ( ) . addWidget ( w )
if ccNumber in ccNow :
w . setValue ( ccNow [ ccNumber ] , sendToEngine = False ) #don't send the engine values it already knows.
def currentTreeItemChanged ( self , currentTreeItem : QtWidgets . QTreeWidgetItem ) :
"""
Program wide GUI - only callback from
widget . currentItemChanged - > mainWindow . currentTreeItemChanged . We set the currentItem
ourselves , so we need to block our signals to avoid recursion .
Only one item can be selected at a time .
The currentTreeItem we receive is not a global instance but from a widget different to ours .
We need to find our local version of the same instrument / library / idKey first .
"""
isLibrary = type ( currentTreeItem ) is GuiLibrary
idKey = currentTreeItem . idKey
if isLibrary :
self . directLibrary ( idKey )
else :
self . instrumentChanged ( idKey )
def instrumentChanged ( self , idKey : tuple ) :
""" This is a GUI-internal function. The user selected a different instrument from
the list . Single click , arrow keys etc .
We combine static metadata , which we saved ourselves , with the current instrument status
( e . g . which variant was chosen ) .
We also relay this information to the main window which can send it to other widgets ,
like the two keyboards . We will not receive this callback from the mainwindow .
"""
libraryId , instrumentId = idKey
self . currentIdKey = idKey
self . ui . details_scrollArea . show ( )
#Cached
instrumentStatus = self . statusUpdates [ libraryId ] [ instrumentId ]
#Static
instrumentData = self . engineData [ libraryId ] [ instrumentId ]
self . ui . details_groupBox . setTitle ( instrumentData [ " name " ] )
self . ui . keySwitch_label . show ( )
self . ui . keySwitch_comboBox . show ( )
self . _populateKeySwitchComboBox ( instrumentStatus [ " keySwitches " ] ) #clears
self . ui . variant_label . show ( )
self . ui . variants_comboBox . show ( )
self . ui . variants_comboBox . clear ( )
self . ui . variants_comboBox . addItems ( instrumentData [ " variantsWithoutSfzExtension " ] )
self . ui . controls_groupBox . show ( )
self . _populateControls ( instrumentStatus )
self . ui . info_label . setText ( self . _metadataToDescriptionLabel ( instrumentData ) )
#Dynamic
self . react_instrumentStatusChanged ( self . statusUpdates [ libraryId ] [ instrumentId ] )
def react_instrumentStatusChanged ( self , instrumentStatus : dict ) :
""" Callback from the api. Has nothing to do with any GUI state or selection.
Happens if the user loads a variant .
Data :
#Static ids
result [ " id " ] = self . metadata [ " id " ]
result [ " idKey " ] = self . idKey #redundancy for convenience.
#Dynamic data
result [ " currentVariant " ] = self . currentVariant # str
result [ " state " ] = self . enabled #bool
It is possible that this callback comes in from a midi trigger , so we have no guarantee
that this matches the currently selected instrument in the GUI . We will cache the updated
status but check if this is our message before changing any GUI fields .
This callback is called again by the GUI directly when switching the instrument with a
mouseclick in instrumentChanged and the GUI will use the cached data .
"""
idKey = instrumentStatus [ " idKey " ]
libraryId , instrumentId = idKey
if not libraryId in self . statusUpdates :
self . statusUpdates [ libraryId ] = { } #empty library. status dict
self . statusUpdates [ libraryId ] [ instrumentId ] = instrumentStatus #create or overwrite / keep up to date
if not self . currentIdKey == idKey :
#Callback for an instrument currently not selected
return
loadState = instrumentStatus [ " state " ]
instrumentData = self . engineData [ libraryId ] [ instrumentId ]
instrumentStatus = self . statusUpdates [ libraryId ] [ instrumentId ]
if loadState : #None if not loaded
self . ui . variants_comboBox . setEnabled ( True )
currentVariantIndex = instrumentData [ " variantsWithoutSfzExtension " ] . index ( instrumentStatus [ " currentVariantWithoutSfzExtension " ] )
self . ui . variants_comboBox . setCurrentIndex ( currentVariantIndex )
else :
self . ui . variants_comboBox . setEnabled ( False )
defaultVariantIndex = instrumentData [ " variantsWithoutSfzExtension " ] . index ( instrumentData [ " defaultVariantWithoutSfzExtension " ] )
self . ui . variants_comboBox . setCurrentIndex ( defaultVariantIndex )
self . _populateKeySwitchComboBox ( instrumentStatus )
self . _populateControls ( instrumentStatus )
def react_initialInstrumentList ( self , data : dict ) :
""" For data form see docstring of instrument.py buildTree()
Summary :
libraryid : dict - >
instrumentid : metadatadict
Dict - Keys are always the same . Some always have data , some can be empty .
We receive this once at program start and build our permanent GUI widgets from it .
The additional status update callback for dynamic data is handled in
self . react_instrumentStatusChanged
"""
self . engineData = data
def react_instrumentCCActivity ( self , idKey , ccNumber , value ) :
if not idKey == self . currentIdKey :
return #not for us
if ccNumber in self . controlWidgets :
self . controlWidgets [ ccNumber ] . setValue ( value )
class ControlWidget ( QtWidgets . QWidget ) :
def __init__ ( self , parentWidget , instrumentStatus , ccNumber , ccLabel ) :
super ( ) . __init__ ( parentWidget )
self . instrumentStatus = instrumentStatus #static info about the complete instrument.
assert self . instrumentStatus [ " state " ] , instrumentStatus
assert self . instrumentStatus [ " controlLabels " ] , instrumentStatus
self . idKey = instrumentStatus [ " idKey " ]
self . ccNumber = ccNumber
self . setLayout ( QtWidgets . QVBoxLayout ( ) )
self . slider = QtWidgets . QSlider ( QtCore . Qt . Horizontal )
self . slider . setTracking ( True )
self . slider . valueChanged . connect ( lambda v : self . setValue ( v , sendToEngine = True ) )
self . layout ( ) . addWidget ( self . slider )
self . spinBox = QtWidgets . QSpinBox ( )
self . spinBox . valueChanged . connect ( lambda v : self . setValue ( v , sendToEngine = True ) )
self . spinBox . setPrefix ( f " [ { ccNumber } ] " )
self . layout ( ) . addWidget ( self . spinBox )
self . label = QtWidgets . QLabel ( ccLabel )
self . layout ( ) . addWidget ( self . label )
self . setFixedSize ( 132 , 96 )
self . setRange ( 0 , 127 )
#self.setValue(0) #No need. We don't want to destroy the instruments defaults.
self . spinBox . setSpecialValueText ( f " [CC { ccNumber } ] " ) #Never touched by human hands
def setRange ( self , floor : int , ceiling : int ) :
self . spinBox . setRange ( floor , ceiling )
self . slider . setRange ( floor , ceiling )
def setValue ( self , value : int , sendToEngine = False ) :
self . spinBox . blockSignals ( True )
self . slider . blockSignals ( True )
self . spinBox . setSpecialValueText ( " " ) #0 is 0
self . spinBox . setValue ( value )
self . slider . setValue ( value )
#Send to Engine
if sendToEngine :
api . sentCCToInstrument ( self . idKey , self . ccNumber , value )
self . spinBox . blockSignals ( False )
self . slider . blockSignals ( False )