#!/usr/bin/env python3 # -*- coding: utf-8 -*- # A piano roll viewer/editor # Copyright (C) 2012-2015 Filipe Coelho # Copyright (C) 2014-2015 Perry Nguyen # # This program 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 2 of # the License, or 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. # # For a full copy of the GNU General Public License see the doc/GPL.txt file. # ------------------------------------------------------------------------------------------------------------ # Imports (Config) from carla_config import * # ------------------------------------------------------------------------------------------------------------ # Imports (Global) config_UseQt5 = False if config_UseQt5: from PyQt5.QtCore import Qt, QRectF, QPointF, pyqtSignal from PyQt5.QtGui import QColor, QFont, QPen, QPainter from PyQt5.QtWidgets import QGraphicsItem, QGraphicsLineItem, QGraphicsOpacityEffect, QGraphicsRectItem, QGraphicsSimpleTextItem from PyQt5.QtWidgets import QGraphicsScene, QGraphicsView from PyQt4.QtWidgets import QWidget, QLabel, QComboBox, QHBoxLayout, QVBoxLayout, QStyle else: from PyQt4.QtCore import Qt, QRectF, QPointF, pyqtSignal from PyQt4.QtGui import QColor, QFont, QPen, QPainter from PyQt4.QtGui import QGraphicsItem, QGraphicsLineItem, QGraphicsOpacityEffect, QGraphicsRectItem, QGraphicsSimpleTextItem from PyQt4.QtGui import QGraphicsScene, QGraphicsView from PyQt4.QtGui import QWidget, QLabel, QComboBox, QSlider, QHBoxLayout, QVBoxLayout, QStyle # ------------------------------------------------------------------------------------------------------------ # Imports (Custom) from carla_shared import * from carla_utils import * # ------------------------------------------------------------------------------------------------------------ # Imports (ExternalUI) from carla_app import CarlaApplication from externalui import ExternalUI # ------------------------------------------------------------------------------------------------------------ # MIDI definitions, copied from CarlaMIDI.h MAX_MIDI_CHANNELS = 16 MAX_MIDI_NOTE = 128 MAX_MIDI_VALUE = 128 MAX_MIDI_CONTROL = 120 # 0x77 MIDI_STATUS_BIT = 0xF0 MIDI_CHANNEL_BIT = 0x0F # MIDI Messages List MIDI_STATUS_NOTE_OFF = 0x80 # note (0-127), velocity (0-127) MIDI_STATUS_NOTE_ON = 0x90 # note (0-127), velocity (0-127) MIDI_STATUS_POLYPHONIC_AFTERTOUCH = 0xA0 # note (0-127), pressure (0-127) MIDI_STATUS_CONTROL_CHANGE = 0xB0 # see 'Control Change Messages List' MIDI_STATUS_PROGRAM_CHANGE = 0xC0 # program (0-127), none MIDI_STATUS_CHANNEL_PRESSURE = 0xD0 # pressure (0-127), none MIDI_STATUS_PITCH_WHEEL_CONTROL = 0xE0 # LSB (0-127), MSB (0-127) # MIDI Message type def MIDI_IS_CHANNEL_MESSAGE(status): return status >= MIDI_STATUS_NOTE_OFF and status < MIDI_STATUS_BIT def MIDI_IS_SYSTEM_MESSAGE(status): return status >= MIDI_STATUS_BIT and status <= 0xFF def MIDI_IS_OSC_MESSAGE(status): return status == '/' or status == '#' # MIDI Channel message type def MIDI_IS_STATUS_NOTE_OFF(status): return MIDI_IS_CHANNEL_MESSAGE(status) and (status & MIDI_STATUS_BIT) == MIDI_STATUS_NOTE_OFF def MIDI_IS_STATUS_NOTE_ON(status): return MIDI_IS_CHANNEL_MESSAGE(status) and (status & MIDI_STATUS_BIT) == MIDI_STATUS_NOTE_ON def MIDI_IS_STATUS_POLYPHONIC_AFTERTOUCH(status): return MIDI_IS_CHANNEL_MESSAGE(status) and (status & MIDI_STATUS_BIT) == MIDI_STATUS_POLYPHONIC_AFTERTOUCH def MIDI_IS_STATUS_CONTROL_CHANGE(status): return MIDI_IS_CHANNEL_MESSAGE(status) and (status & MIDI_STATUS_BIT) == MIDI_STATUS_CONTROL_CHANGE def MIDI_IS_STATUS_PROGRAM_CHANGE(status): return MIDI_IS_CHANNEL_MESSAGE(status) and (status & MIDI_STATUS_BIT) == MIDI_STATUS_PROGRAM_CHANGE def MIDI_IS_STATUS_CHANNEL_PRESSURE(status): return MIDI_IS_CHANNEL_MESSAGE(status) and (status & MIDI_STATUS_BIT) == MIDI_STATUS_CHANNEL_PRESSURE def MIDI_IS_STATUS_PITCH_WHEEL_CONTROL(status): return MIDI_IS_CHANNEL_MESSAGE(status) and (status & MIDI_STATUS_BIT) == MIDI_STATUS_PITCH_WHEEL_CONTROL # MIDI Utils def MIDI_GET_STATUS_FROM_DATA(data): return data[0] & MIDI_STATUS_BIT if MIDI_IS_CHANNEL_MESSAGE(data[0]) else data[0] def MIDI_GET_CHANNEL_FROM_DATA(data): return data[0] & MIDI_CHANNEL_BIT if MIDI_IS_CHANNEL_MESSAGE(data[0]) else 0 # ------------------------------------------------------------------------------------------------------------ # Graphics Items class NoteExpander(QGraphicsRectItem): def __init__(self, length, height, parent): QGraphicsRectItem.__init__(self, 0, 0, length, height, parent) self.parent = parent self.setFlag(QGraphicsItem.ItemSendsGeometryChanges) self.setAcceptHoverEvents(True) clearpen = QPen(QColor(0,0,0,0)) self.setPen(clearpen) self.orig_brush = QColor(0, 0, 0, 0) self.hover_brush = QColor(200, 200, 200) self.stretch = False def mousePressEvent(self, event): QGraphicsRectItem.mousePressEvent(self, event) self.stretch = True def hoverEnterEvent(self, event): QGraphicsRectItem.hoverEnterEvent(self, event) if self.parent.isSelected(): self.parent.setBrush(self.parent.select_brush) else: self.parent.setBrush(self.parent.orig_brush) self.setBrush(self.hover_brush) def hoverLeaveEvent(self, event): QGraphicsRectItem.hoverLeaveEvent(self, event) if self.parent.isSelected(): self.parent.setBrush(self.parent.select_brush) elif self.parent.hovering: self.parent.setBrush(self.parent.hover_brush) else: self.parent.setBrush(self.parent.orig_brush) self.setBrush(self.orig_brush) class NoteItem(QGraphicsRectItem): '''a note on the pianoroll sequencer''' def __init__(self, height, length, note_info): QGraphicsRectItem.__init__(self, 0, 0, length, height) self.setFlag(QGraphicsItem.ItemIsMovable) self.setFlag(QGraphicsItem.ItemIsSelectable) self.setFlag(QGraphicsItem.ItemSendsGeometryChanges) self.setAcceptHoverEvents(True) clearpen = QPen(QColor(0,0,0,0)) self.setPen(clearpen) self.orig_brush = QColor(note_info[3], 0, 0) self.hover_brush = QColor(note_info[3] + 100, 200, 100) self.select_brush = QColor(note_info[3] + 100, 100, 100) self.setBrush(self.orig_brush) self.note = note_info self.length = length self.piano = self.scene self.pressed = False self.hovering = False self.moving_diff = (0,0) self.expand_diff = 0 l = 5 self.front = NoteExpander(l, height, self) self.back = NoteExpander(l, height, self) self.back.setPos(length - l, 0) def paint(self, painter, option, widget=None): paint_option = option paint_option.state &= ~QStyle.State_Selected QGraphicsRectItem.paint(self, painter, paint_option, widget) def setSelected(self, boolean): QGraphicsRectItem.setSelected(self, boolean) if boolean: self.setBrush(self.select_brush) else: self.setBrush(self.orig_brush) def hoverEnterEvent(self, event): self.hovering = True QGraphicsRectItem.hoverEnterEvent(self, event) if not self.isSelected(): self.setBrush(self.hover_brush) def hoverLeaveEvent(self, event): self.hovering = False QGraphicsRectItem.hoverLeaveEvent(self, event) if not self.isSelected(): self.setBrush(self.orig_brush) elif self.isSelected(): self.setBrush(self.select_brush) def mousePressEvent(self, event): QGraphicsRectItem.mousePressEvent(self, event) self.setSelected(True) self.pressed = True def mouseMoveEvent(self, event): pass def moveEvent(self, event): offset = event.scenePos() - event.lastScenePos() if self.back.stretch: self.expand(self.back, offset) else: self.move_pos = self.scenePos() + offset \ + QPointF(self.moving_diff[0],self.moving_diff[1]) pos = self.piano().enforce_bounds(self.move_pos) pos_x, pos_y = pos.x(), pos.y() pos_sx, pos_sy = self.piano().snap(pos_x, pos_y) self.moving_diff = (pos_x-pos_sx, pos_y-pos_sy) if self.front.stretch: right = self.rect().right() - offset.x() + self.expand_diff if (self.scenePos().x() == self.piano().piano_width and offset.x() < 0) \ or right < 10: self.expand_diff = 0 return self.expand(self.front, offset) self.setPos(pos_sx, self.scenePos().y()) else: self.setPos(pos_sx, pos_sy) def expand(self, rectItem, offset): rect = self.rect() right = rect.right() + self.expand_diff if rectItem == self.back: right += offset.x() if right > self.piano().grid_width: right = self.piano().grid_width elif right < 10: right = 10 new_x = self.piano().snap(right) else: right -= offset.x() new_x = self.piano().snap(right+2.75) if self.piano().snap_value: new_x -= 2.75 # where does this number come from?! self.expand_diff = right - new_x self.back.setPos(new_x - 5, 0) rect.setRight(new_x) self.setRect(rect) def updateNoteInfo(self, pos_x, pos_y): self.note[0] = self.piano().get_note_num_from_y(pos_y) self.note[1] = self.piano().get_note_start_from_x(pos_x) self.note[2] = self.piano().get_note_length_from_x( self.rect().right() - self.rect().left()) #print("note: {}".format(self.note)) def mouseReleaseEvent(self, event): QGraphicsRectItem.mouseReleaseEvent(self, event) self.pressed = False if event.button() == Qt.LeftButton: self.moving_diff = (0,0) self.expand_diff = 0 self.back.stretch = False self.front.stretch = False (pos_x, pos_y,) = self.piano().snap(self.pos().x(), self.pos().y()) self.setPos(pos_x, pos_y) self.updateNoteInfo(pos_x, pos_y) def updateVelocity(self, event): offset = event.scenePos().x() - event.lastScenePos().x() self.note[3] += int(offset/5) if self.note[3] > 127: self.note[3] = 127 elif self.note[3] < 0: self.note[3] = 0 print("new vel: {}".format(self.note[3])) self.orig_brush = QColor(self.note[3], 0, 0) self.select_brush = QColor(self.note[3] + 100, 100, 100) self.setBrush(self.orig_brush) class PianoKeyItem(QGraphicsRectItem): def __init__(self, width, height, parent): QGraphicsRectItem.__init__(self, 0, 0, width, height, parent) self.setPen(QPen(QColor(0,0,0,80))) self.width = width self.height = height self.setFlag(QGraphicsItem.ItemSendsGeometryChanges) self.setAcceptHoverEvents(True) self.hover_brush = QColor(200, 0, 0) self.click_brush = QColor(255, 100, 100) self.pressed = False def hoverEnterEvent(self, event): QGraphicsRectItem.hoverEnterEvent(self, event) self.orig_brush = self.brush() self.setBrush(self.hover_brush) def hoverLeaveEvent(self, event): if self.pressed: self.pressed = False self.setBrush(self.hover_brush) QGraphicsRectItem.hoverLeaveEvent(self, event) self.setBrush(self.orig_brush) #def mousePressEvent(self, event): # self.pressed = True # self.setBrush(self.click_brush) def mouseMoveEvent(self, event): """this may eventually do something""" pass def mouseReleaseEvent(self, event): self.pressed = False QGraphicsRectItem.mouseReleaseEvent(self, event) self.setBrush(self.hover_brush) class PianoRoll(QGraphicsScene): '''the piano roll''' midievent = pyqtSignal(list) measureupdate = pyqtSignal(int) modeupdate = pyqtSignal(str) def __init__(self, time_sig = '4/4', num_measures = 4, quantize_val = '1/8'): QGraphicsScene.__init__(self) self.setBackgroundBrush(QColor(50, 50, 50)) self.mousePos = QPointF() self.notes = [] self.selected_notes = [] self.piano_keys = [] self.marquee_select = False self.insert_mode = False self.velocity_mode = False self.place_ghost = False self.ghost_note = None self.default_ghost_vel = 100 self.ghost_vel = self.default_ghost_vel ## dimensions self.padding = 2 ## piano dimensions self.note_height = 10 self.start_octave = -2 self.end_octave = 8 self.notes_in_octave = 12 self.total_notes = (self.end_octave - self.start_octave) \ * self.notes_in_octave + 1 self.piano_height = self.note_height * self.total_notes self.octave_height = self.notes_in_octave * self.note_height self.piano_width = 34 ## height self.header_height = 20 self.total_height = self.piano_height - self.note_height + self.header_height #not sure why note_height is subtracted ## width self.full_note_width = 250 # i.e. a 4/4 note self.snap_value = None self.quantize_val = quantize_val ### dummy vars that will be changed self.time_sig = 0 self.measure_width = 0 self.num_measures = 0 self.max_note_length = 0 self.grid_width = 0 self.value_width = 0 self.grid_div = 0 self.piano = None self.header = None self.play_head = None self.setTimeSig(time_sig) self.setMeasures(num_measures) self.setGridDiv() self.default_length = 1. / self.grid_div # ------------------------------------------------------------------------- # Callbacks def movePlayHead(self, transport_info): # TODO: need conversion between frames and PPQ x = 105. # works for 120bpm total_duration = self.time_sig[0] * self.num_measures * x pos = transport_info['frame'] / x frac = (pos % total_duration) / total_duration self.play_head.setPos(QPointF(frac * self.grid_width, 0)) def setTimeSig(self, time_sig): try: new_time_sig = list(map(float, time_sig.split('/'))) if len(new_time_sig)==2: self.time_sig = new_time_sig self.measure_width = self.full_note_width * self.time_sig[0]/self.time_sig[1] self.max_note_length = self.num_measures * self.time_sig[0]/self.time_sig[1] self.grid_width = self.measure_width * self.num_measures self.setGridDiv() except ValueError: pass def setMeasures(self, measures): try: self.num_measures = float(measures) self.max_note_length = self.num_measures * self.time_sig[0]/self.time_sig[1] self.grid_width = self.measure_width * self.num_measures self.refreshScene() except: pass def setDefaultLength(self, length): try: v = list(map(float, length.split('/'))) if len(v) < 3: self.default_length = \ v[0] if len(v)==1 else \ v[0] / v[1] pos = self.enforce_bounds(self.mousePos) if self.insert_mode: self.makeGhostNote(pos.x(), pos.y()) except ValueError: pass def setGridDiv(self, div=None): if not div: div = self.quantize_val try: val = list(map(int, div.split('/'))) if len(val) < 3: self.quantize_val = div self.grid_div = val[0] if len(val)==1 else val[1] self.value_width = self.full_note_width / float(self.grid_div) if self.grid_div else None self.setQuantize(div) self.refreshScene() except ValueError: pass def setQuantize(self, value): try: val = list(map(float, value.split('/'))) if len(val) == 1: self.quantize(val[0]) self.quantize_val = value elif len(val) == 2: self.quantize(val[0] / val[1]) self.quantize_val = value except ValueError: pass # ------------------------------------------------------------------------- # Event Callbacks def keyPressEvent(self, event): QGraphicsScene.keyPressEvent(self, event) if event.key() == Qt.Key_F: if not self.insert_mode: self.velocity_mode = False self.insert_mode = True self.makeGhostNote(self.mousePos.x(), self.mousePos.y()) self.modeupdate.emit('insert_mode') elif self.insert_mode: self.insert_mode = False if self.place_ghost: self.place_ghost = False self.removeItem(self.ghost_note) self.ghost_note = None self.modeupdate.emit('') elif event.key() == Qt.Key_D: if self.velocity_mode: self.velocity_mode = False self.modeupdate.emit('') else: if self.insert_mode: self.removeItem(self.ghost_note) self.ghost_note = None self.insert_mode = False self.place_ghost = False self.velocity_mode = True self.modeupdate.emit('velocity_mode') elif event.key() == Qt.Key_A: if all((note.isSelected() for note in self.notes)): for note in self.notes: note.setSelected(False) self.selected_notes = [] else: for note in self.notes: note.setSelected(True) self.selected_notes = self.notes[:] elif event.key() in (Qt.Key_Delete, Qt.Key_Backspace): self.notes = [note for note in self.notes if note not in self.selected_notes] for note in self.selected_notes: self.removeItem(note) self.midievent.emit(["midievent-remove", note.note[0], note.note[1], note.note[2], note.note[3]]) del note self.selected_notes = [] def mousePressEvent(self, event): QGraphicsScene.mousePressEvent(self, event) if not (any(key.pressed for key in self.piano_keys) or any(note.pressed for note in self.notes)): for note in self.selected_notes: note.setSelected(False) self.selected_notes = [] if event.button() == Qt.LeftButton: if self.insert_mode: self.place_ghost = True else: self.marquee_select = True self.marquee_rect = QRectF(event.scenePos().x(), event.scenePos().y(), 1, 1) self.marquee = QGraphicsRectItem(self.marquee_rect) self.marquee.setBrush(QColor(255, 255, 255, 100)) self.addItem(self.marquee) else: for s_note in self.notes: if s_note.pressed and s_note in self.selected_notes: break elif s_note.pressed and s_note not in self.selected_notes: for note in self.selected_notes: note.setSelected(False) self.selected_notes = [s_note] break for note in self.selected_notes: if not self.velocity_mode: note.mousePressEvent(event) def mouseMoveEvent(self, event): QGraphicsScene.mouseMoveEvent(self, event) self.mousePos = event.scenePos() if not (any((key.pressed for key in self.piano_keys))): m_pos = event.scenePos() if self.insert_mode and self.place_ghost: #placing a note m_width = self.ghost_rect.x() + self.ghost_rect_orig_width if m_pos.x() > m_width: m_new_x = self.snap(m_pos.x()) self.ghost_rect.setRight(m_new_x) self.ghost_note.setRect(self.ghost_rect) #self.adjust_note_vel(event) else: m_pos = self.enforce_bounds(m_pos) if self.insert_mode: #ghostnote follows mouse around (m_new_x, m_new_y) = self.snap(m_pos.x(), m_pos.y()) self.ghost_rect.moveTo(m_new_x, m_new_y) try: self.ghost_note.setRect(self.ghost_rect) except RuntimeError: self.ghost_note = None self.makeGhostNote(m_new_x, m_new_y) elif self.marquee_select: marquee_orig_pos = event.buttonDownScenePos(Qt.LeftButton) if marquee_orig_pos.x() < m_pos.x() and marquee_orig_pos.y() < m_pos.y(): self.marquee_rect.setBottomRight(m_pos) elif marquee_orig_pos.x() < m_pos.x() and marquee_orig_pos.y() > m_pos.y(): self.marquee_rect.setTopRight(m_pos) elif marquee_orig_pos.x() > m_pos.x() and marquee_orig_pos.y() < m_pos.y(): self.marquee_rect.setBottomLeft(m_pos) elif marquee_orig_pos.x() > m_pos.x() and marquee_orig_pos.y() > m_pos.y(): self.marquee_rect.setTopLeft(m_pos) self.marquee.setRect(self.marquee_rect) self.selected_notes = [] for item in self.collidingItems(self.marquee): if item in self.notes: self.selected_notes.append(item) for note in self.notes: if note in self.selected_notes: note.setSelected(True) else: note.setSelected(False) elif self.velocity_mode: if Qt.LeftButton == event.buttons(): for note in self.selected_notes: note.updateVelocity(event) elif not self.marquee_select: #move selected if Qt.LeftButton == event.buttons(): x = y = False if any(note.back.stretch for note in self.selected_notes): x = True elif any(note.front.stretch for note in self.selected_notes): y = True for note in self.selected_notes: note.back.stretch = x note.front.stretch = y note.moveEvent(event) def mouseReleaseEvent(self, event): if not (any((key.pressed for key in self.piano_keys)) or any((note.pressed for note in self.notes))): if event.button() == Qt.LeftButton: if self.place_ghost and self.insert_mode: self.place_ghost = False note_start = self.get_note_start_from_x(self.ghost_rect.x()) note_num = self.get_note_num_from_y(self.ghost_rect.y()) note_length = self.get_note_length_from_x(self.ghost_rect.width()) self.drawNote(note_num, note_start, note_length, self.ghost_vel) self.midievent.emit(["midievent-add", note_num, note_start, note_length, self.ghost_vel]) self.makeGhostNote(self.mousePos.x(), self.mousePos.y()) elif self.marquee_select: self.marquee_select = False self.removeItem(self.marquee) elif not self.marquee_select: for note in self.selected_notes: old_info = note.note[:] note.mouseReleaseEvent(event) if self.velocity_mode: note.setSelected(True) if not old_info == note.note: self.midievent.emit(["midievent-remove", old_info[0], old_info[1], old_info[2], old_info[3]]) self.midievent.emit(["midievent-add", note.note[0], note.note[1], note.note[2], note.note[3]]) # ------------------------------------------------------------------------- # Internal Functions def drawHeader(self): self.header = QGraphicsRectItem(0, 0, self.grid_width, self.header_height) #self.header.setZValue(1.0) self.header.setPos(self.piano_width, 0) self.addItem(self.header) def drawPiano(self): piano_keys_width = self.piano_width - self.padding labels = ('B','Bb','A','Ab','G','Gb','F','E','Eb','D','Db','C') black_notes = (2,4,6,9,11) piano_label = QFont() piano_label.setPointSize(6) self.piano = QGraphicsRectItem(0, 0, piano_keys_width, self.piano_height) self.piano.setPos(0, self.header_height) self.addItem(self.piano) key = PianoKeyItem(piano_keys_width, self.note_height, self.piano) label = QGraphicsSimpleTextItem('C8', key) label.setPos(18, 1) label.setFont(piano_label) key.setBrush(QColor(255, 255, 255)) for i in range(self.end_octave - self.start_octave, self.start_octave - self.start_octave, -1): for j in range(self.notes_in_octave, 0, -1): if j in black_notes: key = PianoKeyItem(piano_keys_width/1.4, self.note_height, self.piano) key.setBrush(QColor(0, 0, 0)) key.setZValue(1.0) key.setPos(0, self.note_height * j + self.octave_height * (i - 1)) elif (j - 1) and (j + 1) in black_notes: key = PianoKeyItem(piano_keys_width, self.note_height * 2, self.piano) key.setBrush(QColor(255, 255, 255)) key.setPos(0, self.note_height * j + self.octave_height * (i - 1) - self.note_height/2.) elif (j - 1) in black_notes: key = PianoKeyItem(piano_keys_width, self.note_height * 3./2, self.piano) key.setBrush(QColor(255, 255, 255)) key.setPos(0, self.note_height * j + self.octave_height * (i - 1) - self.note_height/2.) elif (j + 1) in black_notes: key = PianoKeyItem(piano_keys_width, self.note_height * 3./2, self.piano) key.setBrush(QColor(255, 255, 255)) key.setPos(0, self.note_height * j + self.octave_height * (i - 1)) if j == 12: label = QGraphicsSimpleTextItem('{}{}'.format(labels[j - 1], self.end_octave - i), key ) label.setPos(18, 6) label.setFont(piano_label) self.piano_keys.append(key) def drawGrid(self): black_notes = [2,4,6,9,11] scale_bar = QGraphicsRectItem(0, 0, self.grid_width, self.note_height, self.piano) scale_bar.setPos(self.piano_width, 0) scale_bar.setBrush(QColor(100,100,100)) clearpen = QPen(QColor(0,0,0,0)) for i in range(self.end_octave - self.start_octave, self.start_octave - self.start_octave, -1): for j in range(self.notes_in_octave, 0, -1): scale_bar = QGraphicsRectItem(0, 0, self.grid_width, self.note_height, self.piano) scale_bar.setPos(self.piano_width, self.note_height * j + self.octave_height * (i - 1)) scale_bar.setPen(clearpen) if j not in black_notes: scale_bar.setBrush(QColor(120,120,120)) else: scale_bar.setBrush(QColor(100,100,100)) measure_pen = QPen(QColor(0, 0, 0, 120), 3) half_measure_pen = QPen(QColor(0, 0, 0, 40), 2) line_pen = QPen(QColor(0, 0, 0, 40)) for i in range(0, int(self.num_measures) + 1): measure = QGraphicsLineItem(0, 0, 0, self.piano_height + self.header_height - measure_pen.width(), self.header) measure.setPos(self.measure_width * i, 0.5 * measure_pen.width()) measure.setPen(measure_pen) if i < self.num_measures: number = QGraphicsSimpleTextItem('%d' % (i + 1), self.header) number.setPos(self.measure_width * i + 5, 2) number.setBrush(Qt.white) for j in self.frange(0, self.time_sig[0]*self.grid_div/self.time_sig[1], 1.): line = QGraphicsLineItem(0, 0, 0, self.piano_height, self.header) line.setZValue(1.0) line.setPos(self.measure_width * i + self.value_width * j, self.header_height) if j == self.time_sig[0]*self.grid_div/self.time_sig[1] / 2.0: line.setPen(half_measure_pen) else: line.setPen(line_pen) def drawPlayHead(self): self.play_head = QGraphicsLineItem(self.piano_width, self.header_height, self.piano_width, self.total_height) self.play_head.setPen(QPen(QColor(255,255,255,50), 2)) self.play_head.setZValue(1.) self.addItem(self.play_head) def refreshScene(self): list(map(self.removeItem, self.notes)) self.selected_notes = [] self.piano_keys = [] self.clear() self.drawPiano() self.drawHeader() self.drawGrid() self.drawPlayHead() for note in self.notes[:]: if note.note[1] >= (self.num_measures * self.time_sig[0]): self.notes.remove(note) self.midievent.emit(["midievent-remove", note.note[0], note.note[1], note.note[2], note.note[3]]) elif note.note[2] > self.max_note_length: new_note = note.note[:] new_note[2] = self.max_note_length self.notes.remove(note) self.drawNote(new_note[0], new_note[1], self.max_note_length, new_note[3], False) self.midievent.emit(["midievent-remove", note.note[0], note.note[1], note.note[2], note.note[3]]) self.midievent.emit(["midievent-add", new_note[0], new_note[1], new_note[2], new_note[3]]) list(map(self.addItem, self.notes)) if self.views(): self.views()[0].setSceneRect(self.itemsBoundingRect()) def clearNotes(self): self.clear() self.notes = [] self.selected_notes = [] self.drawPiano() self.drawHeader() self.drawGrid() def makeGhostNote(self, pos_x, pos_y): """creates the ghostnote that is placed on the scene before the real one is.""" if self.ghost_note: self.removeItem(self.ghost_note) length = self.full_note_width * self.default_length (start, note) = self.snap(pos_x, pos_y) self.ghost_vel = self.default_ghost_vel self.ghost_rect = QRectF(start, note, length, self.note_height) self.ghost_rect_orig_width = self.ghost_rect.width() self.ghost_note = QGraphicsRectItem(self.ghost_rect) self.ghost_note.setBrush(QColor(230, 221, 45, 100)) self.addItem(self.ghost_note) def drawNote(self, note_num, note_start=None, note_length=None, note_velocity=None, add=True): """ note_num: midi number, 0 - 127 note_start: 0 - (num_measures * time_sig[0]) so this is in beats note_length: 0 - (num_measures * time_sig[0]/time_sig[1]) this is in measures note_velocity: 0 - 127 """ info = [note_num, note_start, note_length, note_velocity] if not note_start % (self.num_measures * self.time_sig[0]) == note_start: #self.midievent.emit(["midievent-remove", note_num, note_start, note_length, note_velocity]) while not note_start % (self.num_measures * self.time_sig[0]) == note_start: self.setMeasures(self.num_measures+1) self.measureupdate.emit(self.num_measures) self.refreshScene() x_start = self.get_note_x_start(note_start) if note_length > self.max_note_length: note_length = self.max_note_length + 0.25 x_length = self.get_note_x_length(note_length) y_pos = self.get_note_y_pos(note_num) note = NoteItem(self.note_height, x_length, info) note.setPos(x_start, y_pos) self.notes.append(note) if add: self.addItem(note) # ------------------------------------------------------------------------- # Helper Functions def frange(self, x, y, t): while x < y: yield x x += t def quantize(self, value): self.snap_value = float(self.full_note_width) * value if value else None def snap(self, pos_x, pos_y = None): if self.snap_value: pos_x = int(round((pos_x - self.piano_width) / self.snap_value)) \ * self.snap_value + self.piano_width if pos_y: pos_y = int((pos_y - self.header_height) / self.note_height) \ * self.note_height + self.header_height return (pos_x, pos_y) if pos_y else pos_x def adjust_note_vel(self, event): m_pos = event.scenePos() #bind velocity to vertical mouse movement self.ghost_vel += (event.lastScenePos().y() - m_pos.y())/10 if self.ghost_vel < 0: self.ghost_vel = 0 elif self.ghost_vel > 127: self.ghost_vel = 127 m_width = self.ghost_rect.x() + self.ghost_rect_orig_width if m_pos.x() < m_width: m_pos.setX(m_width) m_new_x = self.snap(m_pos.x()) self.ghost_rect.setRight(m_new_x) self.ghost_note.setRect(self.ghost_rect) def enforce_bounds(self, pos): if pos.x() < self.piano_width: pos.setX(self.piano_width) elif pos.x() > self.grid_width + self.piano_width: pos.setX(self.grid_width + self.piano_width) if pos.y() < self.header_height + self.padding: pos.setY(self.header_height + self.padding) return pos def get_note_start_from_x(self, note_x): return (note_x - self.piano_width) / (self.grid_width / self.num_measures / self.time_sig[0]) def get_note_x_start(self, note_start): return self.piano_width + \ (self.grid_width / self.num_measures / self.time_sig[0]) * note_start def get_note_x_length(self, note_length): return float(self.time_sig[1]) / self.time_sig[0] * note_length * self.grid_width / self.num_measures def get_note_length_from_x(self, note_x): return float(self.time_sig[0]) / self.time_sig[1] * self.num_measures / self.grid_width \ * note_x def get_note_y_pos(self, note_num): return self.header_height + self.note_height * (self.total_notes - note_num - 1) def get_note_num_from_y(self, note_y_pos): return -(((note_y_pos - self.header_height) / self.note_height) - self.total_notes + 1) class PianoRollView(QGraphicsView): def __init__(self, time_sig = '4/4', num_measures = 4, quantize_val = '1/8'): QGraphicsView.__init__(self) self.piano = PianoRoll(time_sig, num_measures, quantize_val) self.setScene(self.piano) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) x = 0 * self.sceneRect().width() + self.sceneRect().left() y = 0.4 * self.sceneRect().height() + self.sceneRect().top() self.centerOn(x, y) self.setAlignment(Qt.AlignLeft) self.o_transform = self.transform() self.zoom_x = 1 self.zoom_y = 1 def setZoomX(self, scale_x): self.setTransform(self.o_transform) self.zoom_x = 1 + scale_x / float(99) * 2 self.scale(self.zoom_x, self.zoom_y) def setZoomY(self, scale_y): self.setTransform(self.o_transform) self.zoom_y = 1 + scale_y / float(99) self.scale(self.zoom_x, self.zoom_y) # ------------------------------------------------------------------------------------------------------------ # External UI class ModeIndicator(QWidget): def __init__(self): QWidget.__init__(self) #self.setGeometry(0, 0, 30, 20) self.setFixedSize(30,20) self.mode = None def paintEvent(self, event): painter = QPainter() painter.begin(self) painter.setPen(QPen(QColor(0, 0, 0, 0))) if self.mode == 'velocity_mode': painter.setBrush(QColor(127, 0, 0)) elif self.mode == 'insert_mode': painter.setBrush(QColor(0, 100, 127)) else: painter.setBrush(QColor(0, 0, 0, 0)) painter.drawRect(0, 0, 30, 20) painter.end() def changeMode(self, new_mode): self.mode = new_mode self.update() class MainWindow(ExternalUI, QWidget): def __init__(self): ExternalUI.__init__(self) QWidget.__init__(self) # to be filled with note-on events, while waiting for their matching note-off self.fPendingNoteOns = [] # (channel, note, velocity, time) self.fTransportInfo = { "playing": False, "frame": 0, "bar": 0, "beat": 0, "tick": 0, "bpm": 120.0, "sigNum": 4.0, "sigDenom": 4.0 } self.PPQ = 48. self.initUI() self.piano.midievent.connect(self.sendMsg) self.piano.measureupdate.connect(self.updateMeasureBox) self.piano.modeupdate.connect(self.modeIndicator.changeMode) self.fIdleTimer = self.startTimer(30) self.setWindowTitle(self.fUiName) self.ready() def initUI(self): self.view = PianoRollView( time_sig = "{}/{}".format( int(self.fTransportInfo["sigNum"]), int(self.fTransportInfo["sigNum"])), num_measures = 4, quantize_val = '1/8') self.piano = self.view.piano self.timeSigLabel = QLabel('time signature') self.timeSigLabel.setAlignment(Qt.AlignRight | Qt.AlignCenter) self.timeSigLabel.setMaximumWidth(100) self.timeSigBox = QComboBox() self.timeSigBox.setEditable(True) self.timeSigBox.setMaximumWidth(100) self.timeSigBox.addItems( ('1/4', '2/4', '3/4', '4/4', '5/4', '6/4', '12/8')) self.timeSigBox.setCurrentIndex(3) self.measureLabel = QLabel('measures') self.measureLabel.setAlignment(Qt.AlignRight | Qt.AlignCenter) self.measureLabel.setMaximumWidth(100) self.measureBox = QComboBox() self.measureBox.setMaximumWidth(100) self.measureBox.addItems(list(map(str, range(1,17)))) self.measureBox.setCurrentIndex(3) self.defaultLengthLabel = QLabel('default length') self.defaultLengthLabel.setAlignment(Qt.AlignRight | Qt.AlignCenter) self.defaultLengthLabel.setMaximumWidth(100) self.defaultLengthBox = QComboBox() self.defaultLengthBox.setEditable(True) self.defaultLengthBox.setMaximumWidth(100) self.defaultLengthBox.addItems(('1/16', '1/15', '1/12', '1/9', '1/8', '1/6', '1/4', '1/3', '1/2', '1')) self.defaultLengthBox.setCurrentIndex(4) self.quantizeLabel = QLabel('quantize') self.quantizeLabel.setAlignment(Qt.AlignRight | Qt.AlignCenter) self.quantizeLabel.setMaximumWidth(100) self.quantizeBox = QComboBox() self.quantizeBox.setEditable(True) self.quantizeBox.setMaximumWidth(100) self.quantizeBox.addItems(('0', '1/16', '1/15', '1/12', '1/9', '1/8', '1/6', '1/4', '1/3', '1/2', '1')) self.quantizeBox.setCurrentIndex(5) self.hSlider = QSlider(Qt.Horizontal) self.hSlider.setTracking(True) #hSlider.setMaximum(1920*6*3*4) self.vSlider = QSlider(Qt.Vertical) self.vSlider.setTracking(True) self.vSlider.setInvertedAppearance(True) self.vSlider.setMaximumHeight(500) self.modeIndicator = ModeIndicator() self.timeSigBox.currentIndexChanged[str].connect(self.piano.setTimeSig) self.measureBox.currentIndexChanged[str].connect(self.piano.setMeasures) self.defaultLengthBox.currentIndexChanged[str].connect(self.piano.setDefaultLength) self.quantizeBox.currentIndexChanged[str].connect(self.piano.setGridDiv) self.hSlider.valueChanged.connect(self.view.setZoomX) self.vSlider.valueChanged.connect(self.view.setZoomY) self.hBox = QHBoxLayout() self.hBox.addWidget(self.modeIndicator) self.hBox.addWidget(self.timeSigLabel) self.hBox.addWidget(self.timeSigBox) self.hBox.addWidget(self.measureLabel) self.hBox.addWidget(self.measureBox) self.hBox.addWidget(self.defaultLengthLabel) self.hBox.addWidget(self.defaultLengthBox) self.hBox.addWidget(self.quantizeLabel) self.hBox.addWidget(self.quantizeBox) self.hBox.addWidget(self.hSlider) self.viewBox = QHBoxLayout() self.viewBox.addWidget(self.vSlider) self.viewBox.addWidget(self.view) self.layout = QVBoxLayout() self.layout.addLayout(self.hBox) self.layout.addLayout(self.viewBox) self.setLayout(self.layout) self.view.setFocus() # ------------------------------------------------------------------- # DSP Callbacks def dspParameterChanged(self, index, value): pass def dspStateChanged(self, key, value): pass # ------------------------------------------------------------------- # ExternalUI Callbacks def uiShow(self): self.show() def uiFocus(self): self.setWindowState((self.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive) self.show() self.raise_() self.activateWindow() def uiHide(self): self.hide() def uiQuit(self): self.closeExternalUI() self.close() app.quit() def uiTitleChanged(self, uiTitle): self.setWindowTitle(uiTitle) # ------------------------------------------------------------------- # Qt events def timerEvent(self, event): if event.timerId() == self.fIdleTimer: self.idleExternalUI() QGraphicsView.timerEvent(self, event) def closeEvent(self, event): self.closeExternalUI() QGraphicsView.closeEvent(self, event) # ------------------------------------------------------------------- # Custom callback def updateMeasureBox(self, index): self.measureBox.setCurrentIndex(index-1) def sendMsg(self, data): msg = data[0] if msg == "midievent-remove": note, start, length, vel = data[1:5] note_start = start * 60. / self.fTransportInfo["bpm"] * 4. / self.fTransportInfo["sigDenom"] * self.PPQ note_stop = note_start + length * 60. / self.fTransportInfo["bpm"] * 4. * self.fTransportInfo["sigNum"] / self.fTransportInfo["sigDenom"] * self.PPQ self.send([msg, note_start, 3, MIDI_STATUS_NOTE_ON, note, vel]) self.send([msg, note_stop, 3, MIDI_STATUS_NOTE_OFF, note, vel]) elif msg == "midievent-add": note, start, length, vel = data[1:5] note_start = start * 60. / self.fTransportInfo["bpm"] * self.PPQ note_stop = note_start + length * 60. / self.fTransportInfo["bpm"] * 4. * self.fTransportInfo["sigNum"] / self.fTransportInfo["sigDenom"] * self.PPQ self.send([msg, note_start, 3, MIDI_STATUS_NOTE_ON, note, vel]) self.send([msg, note_stop, 3, MIDI_STATUS_NOTE_OFF, note, vel]) def msgCallback(self, msg): #try: self.msgCallback2(msg) #except: #print("Custom msgCallback error, skipped for", msg) def msgCallback2(self, msg): msg = charPtrToString(msg) if msg == "midi-clear-all": # clear all notes self.piano.clearNotes() elif msg == "midievent-add": # adds single midi event time = int(self.readlineblock()) size = int(self.readlineblock()) data = [] for x in range(size): data.append(int(self.readlineblock())) self.handleMidiEvent(time, size, data) elif msg == "transport": playing = bool(self.readlineblock() == "true") frame, bar, beat, tick = [int(i) for i in self.readlineblock().split(":")] bpm, sigNum, sigDenom = [float(i) for i in self.readlineblock().split(":")] if beat != self.fTransportInfo["beat"]: print(beat) old_frame = self.fTransportInfo['frame'] self.fTransportInfo = { "playing": playing, "frame": frame, "bar": bar, "beat": beat, "tick": tick, "bpm": bpm, "sigNum": sigNum, "sigDenom": sigDenom } if old_frame != frame: self.piano.movePlayHead(self.fTransportInfo) elif msg == "show": self.uiShow() elif msg == "focus": self.uiFocus() elif msg == "hide": self.uiHide() elif msg == "quit": self.fQuitReceived = True self.uiQuit() elif msg == "uiTitle": uiTitle = self.readlineblock() self.uiTitleChanged(uiTitle) else: print("unknown message: \"" + msg + "\"") # ------------------------------------------------------------------- # Internal stuff def handleMidiEvent(self, time, size, data): #print("Got MIDI Event on UI", time, size, data) # NOTE: for now time comes in frames, which might not be desirable # we'll convert it to a smaller value for now (seconds) # later on we can have time as PPQ or similar time /= self.PPQ status = MIDI_GET_STATUS_FROM_DATA(data) channel = MIDI_GET_CHANNEL_FROM_DATA(data) if status == MIDI_STATUS_NOTE_ON: note = data[1] velo = data[2] # append (channel, note, velo, time) for later self.fPendingNoteOns.append((channel, note, velo, time)) elif status == MIDI_STATUS_NOTE_OFF: note = data[1] velo = data[2] # find previous note-on that matches this note and channel for noteOnMsg in self.fPendingNoteOns: channel_, note_, velo_, time_ = noteOnMsg if channel_ != channel: continue if note_ != note: continue # found it #print("{} {} {} {}\n".format(note, time_, time-time_, velo_)) start = time_ / 60. * self.fTransportInfo["bpm"] / 4. * self.fTransportInfo["sigDenom"] length = (time - time_) / 60. * self.fTransportInfo["bpm"] / 4. / self.fTransportInfo["sigNum"] * self.fTransportInfo["sigDenom"] self.piano.drawNote(note, start, length, velo_) # remove from list self.fPendingNoteOns.remove(noteOnMsg) break #--------------- main ------------------ if __name__ == '__main__': import resources_rc pathBinaries, pathResources = getPaths() gCarla.utils = CarlaUtils(os.path.join(pathBinaries, "libcarla_utils." + DLL_EXTENSION)) gCarla.utils.set_process_name("MidiSequencer") app = CarlaApplication("MidiSequencer") gui = MainWindow() app.exit_exec()