1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# Pixmap Keyboard, a custom Qt widget
5# Copyright (C) 2011-2020 Filipe Coelho <falktx@falktx.com>
6#
7# This program is free software; you can redistribute it and/or
8# modify it under the terms of the GNU General Public License as
9# published by the Free Software Foundation; either version 2 of
10# the License, or any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# For a full copy of the GNU General Public License see the doc/GPL.txt file.
18
19# ------------------------------------------------------------------------------------------------------------
20# Imports (Global)
21
22from PyQt5.QtCore import pyqtSignal, pyqtSlot, qCritical, Qt, QPointF, QRectF, QTimer, QSize
23from PyQt5.QtGui import QColor, QFont, QPainter, QPixmap
24from PyQt5.QtWidgets import QActionGroup, QMenu, QScrollArea, QWidget
25
26# ------------------------------------------------------------------------------------------------------------
27# Imports (Custom)
28
29from carla_shared import QSafeSettings
30
31# ------------------------------------------------------------------------------------------------------------
32
33kMidiKey2RectMapHorizontal = [
34    QRectF(0,   0, 24, 57), # C
35    QRectF(14,  0, 15, 33), # C#
36    QRectF(24,  0, 24, 57), # D
37    QRectF(42,  0, 15, 33), # D#
38    QRectF(48,  0, 24, 57), # E
39    QRectF(72,  0, 24, 57), # F
40    QRectF(84,  0, 15, 33), # F#
41    QRectF(96,  0, 24, 57), # G
42    QRectF(112, 0, 15, 33), # G#
43    QRectF(120, 0, 24, 57), # A
44    QRectF(140, 0, 15, 33), # A#
45    QRectF(144, 0, 24, 57), # B
46]
47
48kMidiKey2RectMapVertical = [
49    QRectF(0, 144, 57, 24), # C
50    QRectF(0, 139, 33, 15), # C#
51    QRectF(0, 120, 57, 24), # D
52    QRectF(0, 111, 33, 15), # D#
53    QRectF(0, 96,  57, 24), # E
54    QRectF(0, 72,  57, 24), # F
55    QRectF(0, 69,  33, 15), # F#
56    QRectF(0, 48,  57, 24), # G
57    QRectF(0, 41,  33, 15), # G#
58    QRectF(0, 24,  57, 24), # A
59    QRectF(0, 13,  33, 15), # A#
60    QRectF(0,  0,  57, 24), # B
61]
62
63kPcKeys_qwerty = [
64    # 1st octave
65    str(Qt.Key_Z),
66    str(Qt.Key_S),
67    str(Qt.Key_X),
68    str(Qt.Key_D),
69    str(Qt.Key_C),
70    str(Qt.Key_V),
71    str(Qt.Key_G),
72    str(Qt.Key_B),
73    str(Qt.Key_H),
74    str(Qt.Key_N),
75    str(Qt.Key_J),
76    str(Qt.Key_M),
77    # 2nd octave
78    str(Qt.Key_Q),
79    str(Qt.Key_2),
80    str(Qt.Key_W),
81    str(Qt.Key_3),
82    str(Qt.Key_E),
83    str(Qt.Key_R),
84    str(Qt.Key_5),
85    str(Qt.Key_T),
86    str(Qt.Key_6),
87    str(Qt.Key_Y),
88    str(Qt.Key_7),
89    str(Qt.Key_U),
90    # 3rd octave
91    str(Qt.Key_I),
92    str(Qt.Key_9),
93    str(Qt.Key_O),
94    str(Qt.Key_0),
95    str(Qt.Key_P),
96]
97
98kPcKeys_qwertz = [
99    # 1st octave
100    str(Qt.Key_Y),
101    str(Qt.Key_S),
102    str(Qt.Key_X),
103    str(Qt.Key_D),
104    str(Qt.Key_C),
105    str(Qt.Key_V),
106    str(Qt.Key_G),
107    str(Qt.Key_B),
108    str(Qt.Key_H),
109    str(Qt.Key_N),
110    str(Qt.Key_J),
111    str(Qt.Key_M),
112    # 2nd octave
113    str(Qt.Key_Q),
114    str(Qt.Key_2),
115    str(Qt.Key_W),
116    str(Qt.Key_3),
117    str(Qt.Key_E),
118    str(Qt.Key_R),
119    str(Qt.Key_5),
120    str(Qt.Key_T),
121    str(Qt.Key_6),
122    str(Qt.Key_Z),
123    str(Qt.Key_7),
124    str(Qt.Key_U),
125    # 3rd octave
126    str(Qt.Key_I),
127    str(Qt.Key_9),
128    str(Qt.Key_O),
129    str(Qt.Key_0),
130    str(Qt.Key_P),
131]
132
133kPcKeys_azerty = [
134    # 1st octave
135    str(Qt.Key_W),
136    str(Qt.Key_S),
137    str(Qt.Key_X),
138    str(Qt.Key_D),
139    str(Qt.Key_C),
140    str(Qt.Key_V),
141    str(Qt.Key_G),
142    str(Qt.Key_B),
143    str(Qt.Key_H),
144    str(Qt.Key_N),
145    str(Qt.Key_J),
146    str(Qt.Key_Comma),
147    # 2nd octave
148    str(Qt.Key_A),
149    str(Qt.Key_Eacute),
150    str(Qt.Key_Z),
151    str(Qt.Key_QuoteDbl),
152    str(Qt.Key_E),
153    str(Qt.Key_R),
154    str(Qt.Key_ParenLeft),
155    str(Qt.Key_T),
156    str(Qt.Key_Minus),
157    str(Qt.Key_Y),
158    str(Qt.Key_Egrave),
159    str(Qt.Key_U),
160    # 3rd octave
161    str(Qt.Key_I),
162    str(Qt.Key_Ccedilla),
163    str(Qt.Key_O),
164    str(Qt.Key_Agrave),
165    str(Qt.Key_P),
166]
167
168kPcKeysLayouts = {
169    'qwerty': kPcKeys_qwerty,
170    'qwertz': kPcKeys_qwertz,
171    'azerty': kPcKeys_azerty,
172}
173
174kValidColors = ("Blue", "Green", "Orange", "Red")
175
176kBlackNotes = (1, 3, 6, 8, 10)
177
178# ------------------------------------------------------------------------------------------------------------
179# MIDI Keyboard, using a pixmap for painting
180
181class PixmapKeyboard(QWidget):
182    # signals
183    noteOn   = pyqtSignal(int)
184    noteOff  = pyqtSignal(int)
185    notesOn  = pyqtSignal()
186    notesOff = pyqtSignal()
187
188    def __init__(self, parent):
189        QWidget.__init__(self, parent)
190
191        self.fEnabledKeys   = []
192        self.fLastMouseNote = -1
193        self.fStartOctave   = 0
194        self.fPcKeybOffset  = 2
195        self.fInitalizing   = True
196
197        self.fFont = self.font()
198        self.fFont.setFamily("Monospace")
199        self.fFont.setPixelSize(12)
200        self.fFont.setBold(True)
201
202        self.fPixmapNormal   = QPixmap(":/bitmaps/kbd_normal.png")
203        self.fPixmapDown     = QPixmap(":/bitmaps/kbd_down-blue.png")
204        self.fHighlightColor = kValidColors[0]
205
206        self.fkPcKeyLayout = "qwerty"
207        self.fkPcKeys      = kPcKeysLayouts["qwerty"]
208        self.fKey2RectMap  = kMidiKey2RectMapHorizontal
209
210        self.fWidth  = self.fPixmapNormal.width()
211        self.fHeight = self.fPixmapNormal.height()
212
213        self.setCursor(Qt.PointingHandCursor)
214        self.setStartOctave(0)
215        self.setOctaves(6)
216
217        self.loadSettings()
218
219        self.fInitalizing = False
220
221    def saveSettings(self):
222        if self.fInitalizing:
223            return
224
225        settings = QSafeSettings("falkTX", "CarlaKeyboard")
226        settings.setValue("PcKeyboardLayout", self.fkPcKeyLayout)
227        settings.setValue("PcKeyboardOffset", self.fPcKeybOffset)
228        settings.setValue("HighlightColor", self.fHighlightColor)
229        del settings
230
231    def loadSettings(self):
232        settings = QSafeSettings("falkTX", "CarlaKeyboard")
233        self.setPcKeyboardLayout(settings.value("PcKeyboardLayout", self.fkPcKeyLayout, str))
234        self.setPcKeyboardOffset(settings.value("PcKeyboardOffset", self.fPcKeybOffset, int))
235        self.setColor(settings.value("HighlightColor", self.fHighlightColor, str))
236        del settings
237
238    def allNotesOff(self, sendSignal=True):
239        self.fEnabledKeys = []
240
241        if sendSignal:
242            self.notesOff.emit()
243
244        self.update()
245
246    def sendNoteOn(self, note, sendSignal=True):
247        if 0 <= note <= 127 and note not in self.fEnabledKeys:
248            self.fEnabledKeys.append(note)
249
250            if sendSignal:
251                self.noteOn.emit(note)
252
253            self.update()
254
255        if len(self.fEnabledKeys) == 1:
256            self.notesOn.emit()
257
258    def sendNoteOff(self, note, sendSignal=True):
259        if 0 <= note <= 127 and note in self.fEnabledKeys:
260            self.fEnabledKeys.remove(note)
261
262            if sendSignal:
263                self.noteOff.emit(note)
264
265            self.update()
266
267        if len(self.fEnabledKeys) == 0:
268            self.notesOff.emit()
269
270    def setColor(self, color):
271        if color not in kValidColors:
272            return
273
274        if self.fHighlightColor == color:
275            return
276
277        self.fHighlightColor = color
278        self.fPixmapDown.load(":/bitmaps/kbd_down-{}.png".format(color.lower()))
279        self.saveSettings()
280
281    def setPcKeyboardLayout(self, layout):
282        if layout not in kPcKeysLayouts.keys():
283            return
284
285        if self.fkPcKeyLayout == layout:
286            return
287
288        self.fkPcKeyLayout = layout
289        self.fkPcKeys = kPcKeysLayouts[layout]
290        self.saveSettings()
291
292    def setPcKeyboardOffset(self, offset):
293        if offset < 0:
294            offset = 0
295        elif offset > 9:
296            offset = 9
297
298        if self.fPcKeybOffset == offset:
299            return
300
301        self.fPcKeybOffset = offset
302        self.saveSettings()
303
304    def setOctaves(self, octaves):
305        if octaves < 1:
306            octaves = 1
307        elif octaves > 10:
308            octaves = 10
309
310        self.fOctaves = octaves
311
312        self.setMinimumSize(self.fWidth * self.fOctaves, self.fHeight)
313        self.setMaximumSize(self.fWidth * self.fOctaves, self.fHeight)
314
315    def setStartOctave(self, octave):
316        if octave < 0:
317            octave = 0
318        elif octave > 9:
319            octave = 9
320
321        if self.fStartOctave == octave:
322            return
323
324        self.fStartOctave = octave
325        self.update()
326
327    def handleMousePos(self, pos):
328        if pos.x() < 0 or pos.x() > self.fOctaves * self.fWidth:
329            return
330        octave = int(pos.x() / self.fWidth)
331        keyPos = QPointF(pos.x() % self.fWidth, pos.y())
332
333        if self.fKey2RectMap[1].contains(keyPos):    # C#
334            note = 1
335        elif self.fKey2RectMap[ 3].contains(keyPos): # D#
336            note = 3
337        elif self.fKey2RectMap[ 6].contains(keyPos): # F#
338            note = 6
339        elif self.fKey2RectMap[ 8].contains(keyPos): # G#
340            note = 8
341        elif self.fKey2RectMap[10].contains(keyPos): # A#
342            note = 10
343        elif self.fKey2RectMap[ 0].contains(keyPos): # C
344            note = 0
345        elif self.fKey2RectMap[ 2].contains(keyPos): # D
346            note = 2
347        elif self.fKey2RectMap[ 4].contains(keyPos): # E
348            note = 4
349        elif self.fKey2RectMap[ 5].contains(keyPos): # F
350            note = 5
351        elif self.fKey2RectMap[ 7].contains(keyPos): # G
352            note = 7
353        elif self.fKey2RectMap[ 9].contains(keyPos): # A
354            note = 9
355        elif self.fKey2RectMap[11].contains(keyPos): # B
356            note = 11
357        else:
358            note = -1
359
360        if note != -1:
361            note += (self.fStartOctave + octave) * 12
362
363            if self.fLastMouseNote != note:
364                self.sendNoteOff(self.fLastMouseNote)
365                self.sendNoteOn(note)
366
367        elif self.fLastMouseNote != -1:
368            self.sendNoteOff(self.fLastMouseNote)
369
370        self.fLastMouseNote = note
371
372    def showOptions(self, event):
373        event.accept()
374        menu = QMenu()
375
376        menu.addAction(self.tr("Note: restart carla to apply globally")).setEnabled(False)
377        menu.addAction(self.tr("Color")).setSeparator(True)
378
379        groupColor  = QActionGroup(menu)
380        groupLayout = QActionGroup(menu)
381        actColors   = []
382        actLayouts  = []
383
384        menu.addAction(self.tr("Highlight color")).setSeparator(True)
385
386        for color in kValidColors:
387            act = menu.addAction(color)
388            act.setActionGroup(groupColor)
389            act.setCheckable(True)
390            if self.fHighlightColor == color:
391                act.setChecked(True)
392            actColors.append(act)
393
394        menu.addAction(self.tr("PC Keyboard layout")).setSeparator(True)
395
396        for pcKeyLayout in kPcKeysLayouts.keys():
397            act = menu.addAction(pcKeyLayout)
398            act.setActionGroup(groupLayout)
399            act.setCheckable(True)
400            if self.fkPcKeyLayout == pcKeyLayout:
401                act.setChecked(True)
402            actLayouts.append(act)
403
404        menu.addAction(self.tr("PC Keyboard base octave (%i)" % self.fPcKeybOffset)).setSeparator(True)
405
406        actOctaveUp   = menu.addAction(self.tr("Octave up"))
407        actOctaveDown = menu.addAction(self.tr("Octave down"))
408
409        if self.fPcKeybOffset == 0:
410            actOctaveDown.setEnabled(False)
411
412        actSelected = menu.exec_(event.screenPos().toPoint())
413
414        if not actSelected:
415            return
416
417        if actSelected in actColors:
418            return self.setColor(actSelected.text())
419
420        if actSelected in actLayouts:
421            return self.setPcKeyboardLayout(actSelected.text())
422
423        if actSelected == actOctaveUp:
424            return self.setPcKeyboardOffset(self.fPcKeybOffset + 1)
425
426        if actSelected == actOctaveDown:
427            return self.setPcKeyboardOffset(self.fPcKeybOffset - 1)
428
429    def minimumSizeHint(self):
430        return QSize(self.fWidth, self.fHeight)
431
432    def sizeHint(self):
433        return QSize(self.fWidth * self.fOctaves, self.fHeight)
434
435    def keyPressEvent(self, event):
436        if not event.isAutoRepeat():
437            try:
438                qKey  = str(event.key())
439                index = self.fkPcKeys.index(qKey)
440            except:
441                pass
442            else:
443                self.sendNoteOn(index+(self.fPcKeybOffset*12))
444
445        QWidget.keyPressEvent(self, event)
446
447    def keyReleaseEvent(self, event):
448        if not event.isAutoRepeat():
449            try:
450                qKey  = str(event.key())
451                index = self.fkPcKeys.index(qKey)
452            except:
453                pass
454            else:
455                self.sendNoteOff(index+(self.fPcKeybOffset*12))
456
457        QWidget.keyReleaseEvent(self, event)
458
459    def mousePressEvent(self, event):
460        if event.button() == Qt.RightButton:
461            self.showOptions(event)
462        else:
463            self.fLastMouseNote = -1
464            self.handleMousePos(event.pos())
465            self.setFocus()
466        QWidget.mousePressEvent(self, event)
467
468    def mouseMoveEvent(self, event):
469        if event.button() != Qt.RightButton:
470            self.handleMousePos(event.pos())
471        QWidget.mouseMoveEvent(self, event)
472
473    def mouseReleaseEvent(self, event):
474        if self.fLastMouseNote != -1:
475            self.sendNoteOff(self.fLastMouseNote)
476            self.fLastMouseNote = -1
477        QWidget.mouseReleaseEvent(self, event)
478
479    def paintEvent(self, event):
480        painter = QPainter(self)
481        event.accept()
482
483        # -------------------------------------------------------------
484        # Paint clean keys (as background)
485
486        for octave in range(self.fOctaves):
487            target = QRectF(self.fWidth * octave, 0, self.fWidth, self.fHeight)
488            source = QRectF(0, 0, self.fWidth, self.fHeight)
489            painter.drawPixmap(target, self.fPixmapNormal, source)
490
491        if not self.isEnabled():
492            painter.setBrush(QColor(0, 0, 0, 150))
493            painter.setPen(QColor(0, 0, 0, 150))
494            painter.drawRect(0, 0, self.width(), self.height())
495            return
496
497        # -------------------------------------------------------------
498        # Paint (white) pressed keys
499
500        paintedWhite = False
501
502        for note in self.fEnabledKeys:
503            pos = self._getRectFromMidiNote(note)
504
505            if self._isNoteBlack(note):
506                continue
507
508            if note < 12:
509                octave = 0
510            elif note < 24:
511                octave = 1
512            elif note < 36:
513                octave = 2
514            elif note < 48:
515                octave = 3
516            elif note < 60:
517                octave = 4
518            elif note < 72:
519                octave = 5
520            elif note < 84:
521                octave = 6
522            elif note < 96:
523                octave = 7
524            elif note < 108:
525                octave = 8
526            elif note < 120:
527                octave = 9
528            elif note < 132:
529                octave = 10
530            else:
531                # cannot paint this note
532                continue
533
534            octave -= self.fStartOctave
535
536            target = QRectF(pos.x() + (self.fWidth * octave), 0, pos.width(), pos.height())
537            source = QRectF(pos.x(), 0, pos.width(), pos.height())
538
539            paintedWhite = True
540            painter.drawPixmap(target, self.fPixmapDown, source)
541
542        # -------------------------------------------------------------
543        # Clear white keys border
544
545        if paintedWhite:
546            for octave in range(self.fOctaves):
547                for note in kBlackNotes:
548                    pos = self._getRectFromMidiNote(note)
549
550                    target = QRectF(pos.x() + (self.fWidth * octave), 0, pos.width(), pos.height())
551                    source = QRectF(pos.x(), 0, pos.width(), pos.height())
552
553                    painter.drawPixmap(target, self.fPixmapNormal, source)
554
555        # -------------------------------------------------------------
556        # Paint (black) pressed keys
557
558        for note in self.fEnabledKeys:
559            pos = self._getRectFromMidiNote(note)
560
561            if not self._isNoteBlack(note):
562                continue
563
564            if note < 12:
565                octave = 0
566            elif note < 24:
567                octave = 1
568            elif note < 36:
569                octave = 2
570            elif note < 48:
571                octave = 3
572            elif note < 60:
573                octave = 4
574            elif note < 72:
575                octave = 5
576            elif note < 84:
577                octave = 6
578            elif note < 96:
579                octave = 7
580            elif note < 108:
581                octave = 8
582            elif note < 120:
583                octave = 9
584            elif note < 132:
585                octave = 10
586            else:
587                # cannot paint this note
588                continue
589
590            octave -= self.fStartOctave
591
592            target = QRectF(pos.x() + (self.fWidth * octave), 0, pos.width(), pos.height())
593            source = QRectF(pos.x(), 0, pos.width(), pos.height())
594
595            painter.drawPixmap(target, self.fPixmapDown, source)
596
597        # Paint C-number note info
598        painter.setFont(self.fFont)
599        painter.setPen(Qt.black)
600
601        for i in range(self.fOctaves):
602            octave = self.fStartOctave + i - 1
603            painter.drawText(i * 168 + (4 if octave == -1 else 3),
604                             35, 20, 20,
605                             Qt.AlignCenter,
606                             "C{}".format(octave))
607
608    def _isNoteBlack(self, note):
609        baseNote = note % 12
610        return bool(baseNote in kBlackNotes)
611
612    def _getRectFromMidiNote(self, note):
613        baseNote = note % 12
614        return self.fKey2RectMap[baseNote]
615
616# ------------------------------------------------------------------------------------------------------------
617# Horizontal scroll area for keyboard
618
619class PixmapKeyboardHArea(QScrollArea):
620    def __init__(self, parent):
621        QScrollArea.__init__(self, parent)
622
623        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
624        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
625
626        self.keyboard = PixmapKeyboard(self)
627        self.keyboard.setOctaves(10)
628        self.setWidget(self.keyboard)
629
630        self.setEnabled(False)
631        self.setFixedHeight(int(self.keyboard.height() + self.horizontalScrollBar().height()/2 + 2))
632
633        QTimer.singleShot(0, self.slot_initScrollbarValue)
634
635    # FIXME use change event
636    def setEnabled(self, yesNo):
637        self.keyboard.setEnabled(yesNo)
638        QScrollArea.setEnabled(self, yesNo)
639
640    @pyqtSlot()
641    def slot_initScrollbarValue(self):
642        self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().maximum()/2))
643
644# ------------------------------------------------------------------------------------------------------------
645# Main Testing
646
647if __name__ == '__main__':
648    import sys
649    from PyQt5.QtWidgets import QApplication
650    import resources_rc
651
652    app = QApplication(sys.argv)
653
654    gui = PixmapKeyboard(None)
655    gui.setEnabled(True)
656    gui.show()
657
658    sys.exit(app.exec_())
659