Compare commits

...

14 Commits

  1. 2
      3rdparty/typeshed/PyQt5/QtGui.pyi
  2. 2
      3rdparty/typeshed/PyQt5/QtWidgets.pyi
  3. 1
      noisicaa/builtin_nodes/beat_track/track_ui.py
  4. 40
      noisicaa/builtin_nodes/pianoroll_track/clipboard.proto
  5. 84
      noisicaa/builtin_nodes/pianoroll_track/model.py
  6. 91
      noisicaa/builtin_nodes/pianoroll_track/model_test.py
  7. 367
      noisicaa/builtin_nodes/pianoroll_track/track_ui.py
  8. 25
      noisicaa/builtin_nodes/pianoroll_track/track_ui_test.py
  9. 1
      noisicaa/builtin_nodes/pianoroll_track/wscript
  10. 35
      noisicaa/builtin_nodes/score_track/model_test.py
  11. 7
      noisicaa/builtin_nodes/score_track/track_ui.py
  12. 3
      noisicaa/music/__init__.py
  13. 40
      noisicaa/music/base_track.proto
  14. 56
      noisicaa/music/base_track.py
  15. 29
      noisicaa/music/clipboard.proto
  16. 36
      noisicaa/music/project.py
  17. 2
      noisicaa/music/wscript
  18. 271
      noisicaa/ui/clipboard.py
  19. 21
      noisicaa/ui/editor_app.py
  20. 47
      noisicaa/ui/editor_window.py
  21. 44
      noisicaa/ui/pianoroll.proto
  22. 187
      noisicaa/ui/pianoroll.py
  23. 22
      noisicaa/ui/project_view.py
  24. 70
      noisicaa/ui/selection_set.py
  25. 2
      noisicaa/ui/track_list/base_track_editor.py
  26. 34
      noisicaa/ui/track_list/editor.py
  27. 2
      noisicaa/ui/track_list/editor_test.py
  28. 175
      noisicaa/ui/track_list/measured_track_editor.py
  29. 3
      noisicaa/ui/track_list/view.py
  30. 21
      noisicaa/ui/ui_base.py
  31. 3
      noisicaa/ui/wscript
  32. 32
      noisidev/uitest.py

2
3rdparty/typeshed/PyQt5/QtGui.pyi vendored

@ -556,6 +556,7 @@ class QConicalGradient(QGradient):
class QClipboard(QtCore.QObject):
dataChanged = ... # type: PYQT_SIGNAL
class Mode(int): ...
Clipboard = ... # type: 'QClipboard.Mode'
@ -564,7 +565,6 @@ class QClipboard(QtCore.QObject):
def selectionChanged(self) -> None: ...
def findBufferChanged(self) -> None: ...
def dataChanged(self) -> None: ...
def changed(self, mode: 'QClipboard.Mode') -> None: ...
def setPixmap(self, a0: QPixmap, mode: 'QClipboard.Mode' = ...) -> None: ...
def setImage(self, a0: 'QImage', mode: 'QClipboard.Mode' = ...) -> None: ...

2
3rdparty/typeshed/PyQt5/QtWidgets.pyi vendored

@ -1053,6 +1053,7 @@ class QActionGroup(QtCore.QObject):
class QApplication(QtGui.QGuiApplication):
focusChanged = ... # type: PYQT_SIGNAL
class ColorSpec(int): ...
NormalColor = ... # type: 'QApplication.ColorSpec'
@ -1068,7 +1069,6 @@ class QApplication(QtGui.QGuiApplication):
def closeAllWindows() -> None: ...
@staticmethod
def aboutQt() -> None: ...
def focusChanged(self, old: QWidget, now: QWidget) -> None: ...
def styleSheet(self) -> str: ...
def autoSipEnabled(self) -> bool: ...
def notify(self, a0: QtCore.QObject, a1: QtCore.QEvent) -> bool: ...

1
noisicaa/builtin_nodes/beat_track/track_ui.py

@ -230,6 +230,7 @@ class BeatMeasureEditor(measured_track_editor.MeasureEditor):
class BeatTrackEditor(measured_track_editor.MeasuredTrackEditor):
measure_type = 'beat'
measure_editor_cls = BeatMeasureEditor
def __init__(self, **kwargs: Any) -> None:

40
noisicaa/builtin_nodes/pianoroll_track/clipboard.proto

@ -0,0 +1,40 @@
/*
* @begin:license
*
* Copyright (c) 2015-2019, Benjamin Niemann <pink@odahoda.de>
*
* 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
* (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, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*
* @end:license
*/
syntax = "proto2";
import "noisicaa/audioproc/public/musical_time.proto";
import "noisicaa/music/clipboard.proto";
import "noisicaa/music/model_base.proto";
import "noisicaa/builtin_nodes/pianoroll_track/model.proto";
package noisicaa.pb;
message PianoRollSegments {
required MusicalTime time = 1;
repeated ObjectTree segments = 2;
repeated PianoRollSegmentRef segment_refs = 3;
}
extend ClipboardContents {
optional PianoRollSegments pianoroll_segments = 417000;
}

84
noisicaa/builtin_nodes/pianoroll_track/model.py

@ -23,8 +23,9 @@
import bisect
import functools
import logging
from typing import Any, Callable, Iterator, Dict, Tuple
from typing import Any, Callable, Iterator, Dict, List, Set, Tuple
from noisicaa.core.typing_extra import down_cast
from noisicaa import audioproc
from noisicaa import core
from noisicaa import music
@ -34,6 +35,7 @@ from noisicaa.music import node_connector
from . import node_description
from . import processor_messages
from . import _model
from . import clipboard_pb2
logger = logging.getLogger(__name__)
@ -313,6 +315,10 @@ class PianoRollSegmentRef(_model.PianoRollSegmentRef):
assert segment is not None
self.segment = segment
@property
def end_time(self) -> audioproc.MusicalTime:
return self.time + self.segment.duration
class PianoRollTrack(_model.PianoRollTrack):
def create_node_connector(
@ -400,3 +406,79 @@ class PianoRollTrack(_model.PianoRollTrack):
segment1.add_event(value_types.MidiEvent(
rel_split_time, interval.end_event.midi_event.midi))
segment1.remove_event(interval.end_event)
def copy_segments(
self, segment_refs: List[PianoRollSegmentRef]
) -> clipboard_pb2.PianoRollSegments:
data = clipboard_pb2.PianoRollSegments()
time = min(segment_ref.time for segment_ref in segment_refs)
data.time.CopyFrom(time.to_proto())
segment_ids = set() # type: Set[int]
for segment_ref in segment_refs:
segment = segment_ref.segment
if segment.id not in segment_ids:
serialized_segment = data.segments.add()
serialized_segment.CopyFrom(segment.serialize())
segment_ids.add(segment.id)
serialized_segment_ref = data.segment_refs.add()
serialized_segment_ref.time.CopyFrom(segment_ref.time.relative_to(time).to_proto())
serialized_segment_ref.segment = segment.id
return data
def cut_segments(
self, segment_refs: List[PianoRollSegmentRef]
) -> clipboard_pb2.PianoRollSegments:
data = self.copy_segments(segment_refs)
for segment_ref in segment_refs:
del self.segments[segment_ref.index]
self.__garbage_collect_segments()
return data
def paste_segments(
self, data: clipboard_pb2.PianoRollSegments, time: audioproc.MusicalTime
) -> List[PianoRollSegmentRef]:
segment_map = {} # type: Dict[int, PianoRollSegment]
for serialized_segment in data.segments:
segment = down_cast(PianoRollSegment, self._pool.clone_tree(serialized_segment))
self.segment_heap.append(segment)
segment_map[serialized_segment.root] = segment
segment_refs = [] # type: List[PianoRollSegmentRef]
for serialized_segment_ref in data.segment_refs:
ref_time = audioproc.MusicalTime.from_proto(serialized_segment_ref.time)
ref = self._pool.create(
PianoRollSegmentRef,
time=time + (ref_time - audioproc.MusicalTime(0, 1)),
segment=segment_map[serialized_segment_ref.segment])
self.segments.append(ref)
segment_refs.append(ref)
return segment_refs
def link_segments(
self, data: clipboard_pb2.PianoRollSegments, time: audioproc.MusicalTime
) -> List[PianoRollSegmentRef]:
segment_map = {} # type: Dict[int, PianoRollSegment]
for segment in self.segment_heap:
segment_map[segment.id] = segment
segment_refs = [] # type: List[PianoRollSegmentRef]
for serialized_segment_ref in data.segment_refs:
assert serialized_segment_ref.segment in segment_map
ref_time = audioproc.MusicalTime.from_proto(serialized_segment_ref.time)
ref = self._pool.create(
PianoRollSegmentRef,
time=time + (ref_time - audioproc.MusicalTime(0, 1)),
segment=segment_map[serialized_segment_ref.segment])
self.segments.append(ref)
segment_refs.append(ref)
return segment_refs

91
noisicaa/builtin_nodes/pianoroll_track/model_test.py

@ -103,6 +103,97 @@ class PianoRollTrackTest(base_track_test.TrackTestMixin, unittest.AsyncTestCase)
MEVT(MT(2, 4), NOTE_OFF(0, 63)),
})
async def test_copy_segment(self):
track = await self._add_track()
with self.project.apply_mutations('test'):
segment_ref = track.create_segment(MT(0, 4), MD(4, 4))
segment = segment_ref.segment
segment.add_event(MEVT(MT(0, 4), NOTE_ON(0, 70, 100)))
segment.add_event(MEVT(MT(4, 4), NOTE_OFF(0, 70)))
segment.add_event(MEVT(MT(0, 4), NOTE_ON(0, 60, 100)))
segment.add_event(MEVT(MT(1, 4), NOTE_OFF(0, 60)))
segment.add_event(MEVT(MT(1, 4), NOTE_ON(0, 61, 100)))
segment.add_event(MEVT(MT(2, 4), NOTE_OFF(0, 61)))
segment.add_event(MEVT(MT(2, 4), NOTE_ON(0, 62, 100)))
segment.add_event(MEVT(MT(3, 4), NOTE_OFF(0, 62)))
segment.add_event(MEVT(MT(3, 4), NOTE_ON(0, 63, 100)))
segment.add_event(MEVT(MT(4, 4), NOTE_OFF(0, 63)))
data = track.copy_segments([segment_ref])
self.assertEqual(len(data.segments), 1)
async def test_cut_segment(self):
track = await self._add_track()
with self.project.apply_mutations('test'):
segment_ref = track.create_segment(MT(0, 4), MD(4, 4))
segment = segment_ref.segment
segment.add_event(MEVT(MT(0, 4), NOTE_ON(0, 70, 100)))
segment.add_event(MEVT(MT(4, 4), NOTE_OFF(0, 70)))
segment.add_event(MEVT(MT(0, 4), NOTE_ON(0, 60, 100)))
segment.add_event(MEVT(MT(1, 4), NOTE_OFF(0, 60)))
segment.add_event(MEVT(MT(1, 4), NOTE_ON(0, 61, 100)))
segment.add_event(MEVT(MT(2, 4), NOTE_OFF(0, 61)))
segment.add_event(MEVT(MT(2, 4), NOTE_ON(0, 62, 100)))
segment.add_event(MEVT(MT(3, 4), NOTE_OFF(0, 62)))
segment.add_event(MEVT(MT(3, 4), NOTE_ON(0, 63, 100)))
segment.add_event(MEVT(MT(4, 4), NOTE_OFF(0, 63)))
with self.project.apply_mutations('test'):
data = track.cut_segments([segment_ref])
self.assertEqual(len(data.segments), 1)
self.assertEqual(len(track.segments), 0)
async def test_paste_segment(self):
track = await self._add_track()
with self.project.apply_mutations('test'):
segment_ref = track.create_segment(MT(0, 4), MD(4, 4))
segment = segment_ref.segment
segment.add_event(MEVT(MT(0, 4), NOTE_ON(0, 70, 100)))
segment.add_event(MEVT(MT(4, 4), NOTE_OFF(0, 70)))
segment.add_event(MEVT(MT(0, 4), NOTE_ON(0, 60, 100)))
segment.add_event(MEVT(MT(1, 4), NOTE_OFF(0, 60)))
segment.add_event(MEVT(MT(1, 4), NOTE_ON(0, 61, 100)))
segment.add_event(MEVT(MT(2, 4), NOTE_OFF(0, 61)))
segment.add_event(MEVT(MT(2, 4), NOTE_ON(0, 62, 100)))
segment.add_event(MEVT(MT(3, 4), NOTE_OFF(0, 62)))
segment.add_event(MEVT(MT(3, 4), NOTE_ON(0, 63, 100)))
segment.add_event(MEVT(MT(4, 4), NOTE_OFF(0, 63)))
data = track.copy_segments([segment_ref])
with self.project.apply_mutations('test'):
segment_refs = track.paste_segments(data, MT(8, 4))
self.assertEqual(len(segment_refs), 1)
self.assertEqual(len(track.segments), 2)
self.assertEqual(len(track.segment_heap), 2)
self.assertEqual(segment_refs[0].time, MT(8, 4))
async def test_link_segment(self):
track = await self._add_track()
with self.project.apply_mutations('test'):
segment_ref = track.create_segment(MT(0, 4), MD(4, 4))
segment = segment_ref.segment
segment.add_event(MEVT(MT(0, 4), NOTE_ON(0, 70, 100)))
segment.add_event(MEVT(MT(4, 4), NOTE_OFF(0, 70)))
segment.add_event(MEVT(MT(0, 4), NOTE_ON(0, 60, 100)))
segment.add_event(MEVT(MT(1, 4), NOTE_OFF(0, 60)))
segment.add_event(MEVT(MT(1, 4), NOTE_ON(0, 61, 100)))
segment.add_event(MEVT(MT(2, 4), NOTE_OFF(0, 61)))
segment.add_event(MEVT(MT(2, 4), NOTE_ON(0, 62, 100)))
segment.add_event(MEVT(MT(3, 4), NOTE_OFF(0, 62)))
segment.add_event(MEVT(MT(3, 4), NOTE_ON(0, 63, 100)))
segment.add_event(MEVT(MT(4, 4), NOTE_OFF(0, 63)))
data = track.copy_segments([segment_ref])
with self.project.apply_mutations('test'):
segment_refs = track.link_segments(data, MT(8, 4))
self.assertEqual(len(segment_refs), 1)
self.assertEqual(len(track.segments), 2)
self.assertEqual(len(track.segment_heap), 1)
self.assertEqual(segment_refs[0].time, MT(8, 4))
self.assertIs(segment_refs[0].segment, segment)
async def test_connector(self):
track = await self._add_track()

367
noisicaa/builtin_nodes/pianoroll_track/track_ui.py

@ -37,6 +37,7 @@ from noisicaa import audioproc
from noisicaa import core
from noisicaa import music
from noisicaa.ui import ui_base
from noisicaa.ui import clipboard
from noisicaa.ui import pianoroll
from noisicaa.ui import slots
from noisicaa.ui import int_dial
@ -45,6 +46,7 @@ from noisicaa.ui.track_list import base_track_editor
from noisicaa.ui.track_list import time_view_mixin
from noisicaa.builtin_nodes.pianoroll import processor_messages
from . import model
from . import clipboard_pb2
logger = logging.getLogger(__name__)
@ -78,39 +80,7 @@ class PianoRollToolMixin(tools.ToolBase): # pylint: disable=abstract-method
increase_button.setEnabled(tr.gridYSize() < tr.MAX_GRID_Y_SIZE)
decrease_button.setEnabled(tr.gridYSize() > tr.MIN_GRID_Y_SIZE)
def __selectAll(self) -> None:
for segment in self.track.segments:
self.track.addToSelection(segment)
def __clearSelection(self) -> None:
self.track.clearSelection()
def __createSegment(self, time: audioproc.MusicalTime) -> None:
tr = self.track
with tr.project.apply_mutations('%s: Add segment' % tr.track.name):
tr.track.create_segment(
time, audioproc.MusicalDuration(16, 4))
def __deleteSegments(self, segments: List['SegmentEditor']) -> None:
tr = self.track
with tr.project.apply_mutations('%s: Remove segment(s)' % tr.track.name):
for segment in segments:
tr.track.remove_segment(segment.segmentRef())
def __splitSegment(self, segment: 'SegmentEditor', split_time: audioproc.MusicalTime) -> None:
assert segment.startTime() < split_time < segment.endTime()
tr = self.track
with tr.project.apply_mutations('%s: Split segment' % tr.track.name):
tr.track.split_segment(segment.segmentRef(), split_time)
def buildContextMenu(self, menu: QtWidgets.QMenu, evt: QtGui.QContextMenuEvent) -> None:
affected_segments = self.track.selection()
if not affected_segments:
segment = self.track.segmentAt(evt.pos().x())
if segment:
affected_segments.append(segment)
view_menu = menu.addMenu("View")
increase_row_height_button = QtWidgets.QToolButton()
@ -155,60 +125,6 @@ class PianoRollToolMixin(tools.ToolBase): # pylint: disable=abstract-method
current_channel_menu.addAction(
self.track.set_current_channel_actions[ch])
menu.addSeparator()
select_all_action = QtWidgets.QAction(menu)
select_all_action.setObjectName('select-all')
select_all_action.setText("Select All")
select_all_action.triggered.connect(self.__selectAll)
menu.addAction(select_all_action)
clear_selection_action = QtWidgets.QAction(menu)
clear_selection_action.setObjectName('clear-selection')
clear_selection_action.setText("Clear Selection")
clear_selection_action.triggered.connect(self.__clearSelection)
menu.addAction(clear_selection_action)
menu.addSeparator()
add_segment_action = QtWidgets.QAction(menu)
add_segment_action.setObjectName('add-segment')
add_segment_action.setText("Add Segment")
add_segment_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'list-add.svg')))
add_segment_action.triggered.connect(
functools.partial(self.__createSegment, self.track.xToTime(evt.pos().x())))
menu.addAction(add_segment_action)
delete_segment_action = QtWidgets.QAction(menu)
delete_segment_action.setObjectName('delete-segment')
delete_segment_action.setText(
"Delete Segment" if len(affected_segments) < 2 else "Delete Segments")
delete_segment_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'list-remove.svg')))
if affected_segments:
delete_segment_action.triggered.connect(
functools.partial(self.__deleteSegments, affected_segments))
else:
delete_segment_action.setEnabled(False)
menu.addAction(delete_segment_action)
playback_position = self.track.playbackPosition()
split_segment = self.track.segmentAtTime(playback_position)
if (split_segment is not None
and not split_segment.startTime() < playback_position < split_segment.endTime()):
split_segment = None
split_segment_action = QtWidgets.QAction(menu)
split_segment_action.setObjectName('split-segment')
split_segment_action.setText("Split Segment")
split_segment_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'pianoroll-split-segment.svg')))
if split_segment is not None:
split_segment_action.triggered.connect(
functools.partial(self.__splitSegment, split_segment, playback_position))
else:
split_segment_action.setEnabled(False)
menu.addAction(split_segment_action)
def contextMenuEvent(self, evt: QtGui.QContextMenuEvent) -> None:
menu = QtWidgets.QMenu(self.track)
@ -233,6 +149,38 @@ class ArrangeSegmentsTool(PianoRollToolMixin, tools.ToolBase):
self.__ref_time = None # type: audioproc.MusicalTime
self.__time = None # type: audioproc.MusicalTime
self.__select_all_action = QtWidgets.QAction(self)
self.__select_all_action.setObjectName('select-all')
self.__select_all_action.setText("Select All")
self.__select_all_action.setShortcut('ctrl+a')
self.__select_all_action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
self.__select_all_action.triggered.connect(self.__selectAll)
self.__clear_selection_action = QtWidgets.QAction(self)
self.__clear_selection_action.setObjectName('clear-selection')
self.__clear_selection_action.setText("Clear Selection")
self.__clear_selection_action.setShortcut('ctrl+shift+a')
self.__clear_selection_action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
self.__clear_selection_action.triggered.connect(self.__clearSelection)
self.__add_segment_action = QtWidgets.QAction(self)
self.__add_segment_action.setObjectName('add-segment')
self.__add_segment_action.setText("Add Segment")
self.__add_segment_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'list-add.svg')))
self.__add_segment_action.setShortcut('ins')
self.__add_segment_action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
self.__add_segment_action.triggered.connect(self.__createSegment)
self.__delete_segment_action = QtWidgets.QAction(self)
self.__delete_segment_action.setObjectName('delete-segment')
self.__delete_segment_action.setText("Delete Segment(s)")
self.__delete_segment_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'list-remove.svg')))
self.__delete_segment_action.setShortcut('del')
self.__delete_segment_action.setShortcutContext(Qt.WidgetWithChildrenShortcut)
self.__delete_segment_action.triggered.connect(self.__deleteSegments)
def iconName(self) -> str:
return 'pianoroll-arrange-segments'
@ -243,15 +191,104 @@ class ArrangeSegmentsTool(PianoRollToolMixin, tools.ToolBase):
segment.setAttribute(Qt.WA_TransparentForMouseEvents, True)
segment.setReadOnly(True)
def activated(self) -> None:
self.track.addAction(self.__select_all_action)
self.track.addAction(self.__clear_selection_action)
self.track.addAction(self.__add_segment_action)
self.track.addAction(self.__delete_segment_action)
super().activated()
def deactivated(self) -> None:
self.track.removeAction(self.__select_all_action)
self.track.removeAction(self.__clear_selection_action)
self.track.removeAction(self.__add_segment_action)
self.track.removeAction(self.__delete_segment_action)
self.track.setInsertTime(audioproc.MusicalTime(-1, 1))
self.track.clearSelection()
self.track.unsetCursor()
super().deactivated()
def __selectAll(self) -> None:
for segment in self.track.segments:
self.track.addToSelection(segment)
def __clearSelection(self) -> None:
self.track.clearSelection()
def __createSegment(self) -> None:
time = self.track.insertTime()
if time < audioproc.MusicalTime(0, 1):
time = audioproc.MusicalTime(0, 1)
tr = self.track
with tr.project.apply_mutations('%s: Add segment' % tr.track.name):
tr.track.create_segment(
time, audioproc.MusicalDuration(16, 4))
def __deleteSegments(self) -> None:
segments = self.track.selection()
tr = self.track
with tr.project.apply_mutations('%s: Remove segment(s)' % tr.track.name):
for segment in segments:
tr.track.remove_segment(segment.segmentRef())
def __splitSegment(self, segment: 'SegmentEditor', split_time: audioproc.MusicalTime) -> None:
assert segment.startTime() < split_time < segment.endTime()
tr = self.track
with tr.project.apply_mutations('%s: Split segment' % tr.track.name):
tr.track.split_segment(segment.segmentRef(), split_time)
def buildContextMenu(self, menu: QtWidgets.QMenu, evt: QtGui.QContextMenuEvent) -> None:
super().buildContextMenu(menu, evt)
menu.addSeparator()
menu.addAction(self.app.clipboard.cut_action)
menu.addAction(self.app.clipboard.copy_action)
menu.addAction(self.app.clipboard.paste_action)
menu.addAction(self.app.clipboard.paste_as_link_action)
menu.addSeparator()
menu.addAction(self.__select_all_action)
menu.addAction(self.__clear_selection_action)
menu.addSeparator()
menu.addAction(self.__add_segment_action)
menu.addAction(self.__delete_segment_action)
playback_position = self.track.playbackPosition()
split_segment = self.track.segmentAtTime(playback_position)
if (split_segment is not None
and not split_segment.startTime() < playback_position < split_segment.endTime()):
split_segment = None
split_segment_action = QtWidgets.QAction(menu)
split_segment_action.setObjectName('split-segment')
split_segment_action.setText("Split Segment")
split_segment_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'pianoroll-split-segment.svg')))
if split_segment is not None:
split_segment_action.triggered.connect(
functools.partial(self.__splitSegment, split_segment, playback_position))
else:
split_segment_action.setEnabled(False)
menu.addAction(split_segment_action)
def contextMenuEvent(self, evt: QtGui.QContextMenuEvent) -> None:
if self.__action is not None:
evt.accept()
return
if self.track.insertTime() < audioproc.MusicalTime(0, 1):
self.track.setInsertTime(self.track.xToTime(evt.pos().x()))
segment = self.track.segmentAt(evt.pos().x())
if segment is not None and not segment.selected():
self.track.clearSelection()
self.track.addToSelection(segment)
super().contextMenuEvent(evt)
def mousePressEvent(self, evt: QtGui.QMouseEvent) -> None:
@ -278,11 +315,12 @@ class ArrangeSegmentsTool(PianoRollToolMixin, tools.ToolBase):
self.track.clearSelection()
if evt.button() == Qt.LeftButton and evt.modifiers() == Qt.NoModifier:
for seditor in self.track.segments:
for seditor in reversed(self.track.segments):
x1 = self.track.timeToX(seditor.startTime())
x2 = self.track.timeToX(seditor.endTime())
if abs(x2 - evt.pos().x()) < 4:
self.track.setInsertTime(audioproc.MusicalTime(-1, 1))
self.track.clearSelection()
self.track.addToSelection(seditor)
self.__action = 'move-end'
@ -293,6 +331,7 @@ class ArrangeSegmentsTool(PianoRollToolMixin, tools.ToolBase):
return
if abs(x1 - evt.pos().x()) < 4:
self.track.setInsertTime(audioproc.MusicalTime(-1, 1))
self.track.clearSelection()
self.track.addToSelection(seditor)
self.__action = 'move-start'
@ -303,6 +342,7 @@ class ArrangeSegmentsTool(PianoRollToolMixin, tools.ToolBase):
return
if x1 <= evt.pos().x() < x2:
self.track.setInsertTime(audioproc.MusicalTime(-1, 1))
self.__action = 'drag'
if seditor.selected():
self.__drag_segments = self.track.selection()
@ -314,6 +354,10 @@ class ArrangeSegmentsTool(PianoRollToolMixin, tools.ToolBase):
evt.accept()
return
self.track.setInsertTime(self.track.xToTime(evt.pos().x()))
evt.accept()
return
super().mousePressEvent(evt)
def mouseMoveEvent(self, evt: QtGui.QMouseEvent) -> None:
@ -358,7 +402,7 @@ class ArrangeSegmentsTool(PianoRollToolMixin, tools.ToolBase):
evt.accept()
return
for seditor in self.track.segments:
for seditor in reversed(self.track.segments):
x1 = self.track.timeToX(seditor.startTime())
x2 = self.track.timeToX(seditor.endTime())
@ -440,6 +484,7 @@ class ArrangeSegmentsTool(PianoRollToolMixin, tools.ToolBase):
seditor = self.track.segmentAt(evt.pos().x())
if seditor is not None:
self.track.setCurrentToolType(tools.ToolType.PIANOROLL_EDIT_EVENTS)
seditor.activate()
evt.accept()
return
@ -676,12 +721,24 @@ class SegmentEditor(
def setDuration(self, duration: audioproc.MusicalDuration) -> None:
self.__grid.setDuration(duration)
def activate(self) -> None:
self.__grid.setFocus()
def resizeEvent(self, evt: QtGui.QResizeEvent) -> None:
self.__grid.resize(self.width(), self.height())
super().resizeEvent(evt)
class InsertCursor(QtWidgets.QWidget):
def paintEvent(self, evt: QtGui.QPaintEvent) -> None:
painter = QtGui.QPainter(self)
painter.fillRect(0, 0, 1, self.height(), QtGui.QColor(160, 160, 255))
painter.fillRect(1, 0, 1, self.height(), QtGui.QColor(0, 0, 255))
painter.fillRect(2, 0, 1, self.height(), QtGui.QColor(160, 160, 255))
class PianoRollTrackEditor(
clipboard.CopyableMixin,
time_view_mixin.ContinuousTimeMixin,
base_track_editor.BaseTrackEditor):
yOffset, setYOffset, yOffsetChanged = slots.slot(int, 'yOffset', default=0)
@ -692,12 +749,15 @@ class PianoRollTrackEditor(
int, 'currentChannel', default=0)
showVelocity, setShowVelocity, showVelocityChanged = slots.slot(
bool, 'showVelocity', default=False)
insertTime, setInsertTime, insertTimeChanged = slots.slot(
audioproc.MusicalTime, 'insertTime', default=audioproc.MusicalTime(-1, 1))
MIN_GRID_Y_SIZE = 2
MAX_GRID_Y_SIZE = 64
def __init__(self, **kwargs: Any) -> None:
self.segments = [] # type: List[SegmentEditor]
self.__segment_map = {} # type: Dict[int, SegmentEditor]
self.__selection = set() # type: Set[int]
self.__last_selected = None # type: SegmentEditor
@ -758,6 +818,9 @@ class PianoRollTrackEditor(
self.__velocity_group.setVisible(self.showVelocity())
self.showVelocityChanged.connect(self.__velocity_group.setVisible)
self.__insert_cursor = InsertCursor(self)
self.updateInsertTime()
for segment_ref in self.track.segments:
self.__addSegment(len(self.segments), segment_ref)
self.__listeners.add(self.track.segments_changed.add(self.__segmentsChanged))
@ -770,6 +833,9 @@ class PianoRollTrackEditor(
self.scaleXChanged.connect(lambda _: self.__repositionSegments())
self.gridYSizeChanged.connect(lambda _: self.__updateYScrollbar())
self.playbackPositionChanged.connect(lambda _: self.updatePlaybackPosition())
self.insertTimeChanged.connect(lambda _: self.updateInsertTime())
self.xOffsetChanged.connect(lambda _: self.updateInsertTime())
self.scaleXChanged.connect(lambda _: self.updateInsertTime())
self.setCurrentChannel(
self.get_session_value(self.__session_prefix + 'current-channel', 0))
@ -817,6 +883,8 @@ class PianoRollTrackEditor(
if segment.segmentRef().id in selected_ids:
segment.setSelected(True)
self.__selection.add(segment.segmentRef().id)
self.setCanCopy(bool(self.__selection))
self.setCanCut(bool(self.__selection))
@property
def track(self) -> model.PianoRollTrack:
@ -832,7 +900,9 @@ class PianoRollTrackEditor(
def __addSegment(self, insert_index: int, segment_ref: model.PianoRollSegmentRef) -> None:
seditor = SegmentEditor(track_editor=self, segment_ref=segment_ref, context=self.context)
self.__segment_map[segment_ref.id] = seditor
self.segments.insert(insert_index, seditor)
seditor.setEnabled(self.isCurrent())
seditor.setScaleX(self.scaleX())
self.scaleXChanged.connect(seditor.setScaleX)
@ -849,14 +919,18 @@ class PianoRollTrackEditor(
seditor.setSelected(segment_ref.id in self.__selection)
down_cast(PianoRollToolMixin, self.currentTool()).activateSegment(seditor)
for segment in self.segments:
segment.raise_()
self.__insert_cursor.raise_()
self.__keys.raise_()
self.__velocity_group.raise_()
self.__y_scrollbar.raise_()
self.update()
def __removeSegment(self, remove_index: int, point: QtCore.QPoint) -> None:
def __removeSegment(self, remove_index: int, segment_ref: model.PianoRollSegmentRef) -> None:
seditor = self.segments.pop(remove_index)
del self.__segment_map[seditor.segmentRef().id]
seditor.cleanup()
seditor.hide()
seditor.setParent(None)
@ -886,35 +960,39 @@ class PianoRollTrackEditor(
for segment in self.segments:
segment.setEnabled(is_current)
def __selectionChanged(self) -> None:
self.set_session_value(
self.__session_prefix + 'selected-segments',
','.join(str(segment_id) for segment_id in sorted(self.__selection)))
self.setCanCut(bool(self.__selection))
self.setCanCopy(bool(self.__selection))
def addToSelection(self, segment: SegmentEditor) -> None:
self.__selection.add(segment.segmentRef().id)
self.__last_selected = segment
segment.setSelected(True)
self.set_session_value(
self.__session_prefix + 'selected-segments',
','.join(str(segment_id) for segment_id in sorted(self.__selection)))
self.__selectionChanged()
def removeFromSelection(self, segment: SegmentEditor) -> None:
self.__selection.discard(segment.segmentRef().id)
if segment is self.__last_selected:
self.__last_selected = None
segment.setSelected(False)
self.set_session_value(
self.__session_prefix + 'selected-segments',
','.join(str(segment_id) for segment_id in sorted(self.__selection)))
self.__selectionChanged()
def clearSelection(self) -> None:
for segment in self.selection():
segment.setSelected(False)
self.__selection.clear()
self.__last_selected = None
self.set_session_value(
self.__session_prefix + 'selected-segments',
','.join(str(segment_id) for segment_id in sorted(self.__selection)))
self.__selectionChanged()
def lastSelected(self) -> SegmentEditor:
return self.__last_selected
def numSelected(self) -> int:
return len(self.__selection)
def selection(self) -> List[SegmentEditor]:
segments = [] # type: List[SegmentEditor]
for segment in self.segments:
@ -923,6 +1001,83 @@ class PianoRollTrackEditor(
return segments
def copyToClipboard(self) -> music.ClipboardContents:
segments = self.selection()
assert len(segments) > 0
segment_data = self.track.copy_segments(
[segment.segmentRef() for segment in segments])
self.setInsertTime(max(segment.endTime() for segment in segments))
data = music.ClipboardContents()
data.Extensions[clipboard_pb2.pianoroll_segments].CopyFrom(segment_data)
return data
def cutToClipboard(self) -> music.ClipboardContents:
segments = self.selection()
assert len(segments) > 0
with self.project.apply_mutations('%s: cut segment(s)' % self.track.name):
segment_data = self.track.cut_segments(
[segment.segmentRef() for segment in segments])
self.clearSelection()
self.setInsertTime(min(segment.startTime() for segment in segments))
data = music.ClipboardContents()
data.Extensions[clipboard_pb2.pianoroll_segments].CopyFrom(segment_data)
return data
def canPaste(self, data: music.ClipboardContents) -> bool:
return data.HasExtension(clipboard_pb2.pianoroll_segments)
def pasteFromClipboard(self, data: music.ClipboardContents) -> None:
assert data.HasExtension(clipboard_pb2.pianoroll_segments)
segment_data = data.Extensions[clipboard_pb2.pianoroll_segments]
time = self.insertTime()
if time < audioproc.MusicalTime(0, 1):
time = audioproc.MusicalTime(0, 1)
with self.project.apply_mutations('%s: paste segment(s)' % self.track.name):
segments = self.track.paste_segments(segment_data, time)
self.setInsertTime(max(segment.end_time for segment in segments))
self.clearSelection()
for segment in segments:
self.addToSelection(self.__segment_map[segment.id])
def canPasteAsLink(self, data: music.ClipboardContents) -> bool:
if not data.HasExtension(clipboard_pb2.pianoroll_segments):
return False
existing_segments = {segment.id for segment in self.track.segment_heap}
segment_data = data.Extensions[clipboard_pb2.pianoroll_segments]
for serialized_ref in segment_data.segment_refs:
if serialized_ref.segment not in existing_segments:
return False
return True
def pasteAsLinkFromClipboard(self, data: music.ClipboardContents) -> None:
assert data.HasExtension(clipboard_pb2.pianoroll_segments)
segment_data = data.Extensions[clipboard_pb2.pianoroll_segments]
time = self.insertTime()
if time < audioproc.MusicalTime(0, 1):
time = audioproc.MusicalTime(0, 1)
with self.project.apply_mutations('%s: link segment(s)' % self.track.name):
segments = self.track.link_segments(segment_data, time)
self.setInsertTime(max(segment.end_time for segment in segments))
self.clearSelection()
for segment in segments:
self.addToSelection(self.__segment_map[segment.id])
def updatePlaybackPosition(self) -> None:
time = self.playbackPosition()
for segment in self.segments:
@ -931,6 +1086,20 @@ class PianoRollTrackEditor(
else:
segment.setPlaybackPosition(audioproc.MusicalTime(-1, 1))
def updateInsertTime(self) -> None:
time = self.insertTime()
if time < audioproc.MusicalTime(0, 1):
self.__insert_cursor.hide()
return
x = self.timeToX(time) - self.xOffset() - 1
if not -3 < x <= self.width():
self.__insert_cursor.hide()
return
self.__insert_cursor.setGeometry(x, 0, 3, self.height())
self.__insert_cursor.show()
def gridStep(self) -> audioproc.MusicalDuration:
for s in (64, 32, 16, 8, 4, 2):
if self.scaleX() / s > 96:
@ -967,7 +1136,7 @@ class PianoRollTrackEditor(
return self.segmentAtTime(self.xToTime(x))
def segmentAtTime(self, time: audioproc.MusicalTime) -> 'SegmentEditor':
for seditor in self.segments:
for seditor in reversed(self.segments):
if seditor.startTime() <= time < seditor.endTime():
return seditor
return None

25
noisicaa/builtin_nodes/pianoroll_track/track_ui_test.py

@ -188,10 +188,7 @@ class PianoRollTrackEditorTest(track_editor_tests.TrackEditorItemTestMixin, uite
with self._trackItem() as ti:
self.moveMouse(QtCore.QPoint(ti.timeToX(MT(2, 4)), ti.height() // 2))
menu = self.openContextMenu()
action = menu.findChild(QtWidgets.QAction, 'add-segment')
assert action is not None
self.assertTrue(action.isEnabled())
action.trigger()
self.triggerMenuAction(menu, 'add-segment')
self.assertEqual(len(self.track.segments), 1)
self.assertEqual(self.track.segments[0].time, MT(2, 4))
@ -203,10 +200,7 @@ class PianoRollTrackEditorTest(track_editor_tests.TrackEditorItemTestMixin, uite
with self._trackItem() as ti:
self.moveMouse(QtCore.QPoint(ti.timeToX(MT(2, 4)), ti.height() // 2))
menu = self.openContextMenu()
action = menu.findChild(QtWidgets.QAction, 'delete-segment')
assert action is not None
self.assertTrue(action.isEnabled())
action.trigger()
self.triggerMenuAction(menu, 'delete-segment')
self.assertEqual(len(self.track.segments), 0)
@ -218,10 +212,7 @@ class PianoRollTrackEditorTest(track_editor_tests.TrackEditorItemTestMixin, uite
ti.setPlaybackPosition(MT(3, 4))
self.moveMouse(QtCore.QPoint(ti.timeToX(MT(3, 4)), ti.height() // 2))
menu = self.openContextMenu()
action = menu.findChild(QtWidgets.QAction, 'split-segment')
assert action is not None
self.assertTrue(action.isEnabled())
action.trigger()
self.triggerMenuAction(menu, 'split-segment')
self.assertEqual(len(self.track.segments), 2)
self.assertEqual(self.track.segments[0].time, MT(0, 4))
@ -278,10 +269,7 @@ class PianoRollTrackEditorTest(track_editor_tests.TrackEditorItemTestMixin, uite
with self._trackItem() as ti:
menu = self.openContextMenu()
action = menu.findChild(QtWidgets.QAction, 'select-all')
assert action is not None
self.assertTrue(action.isEnabled())
action.trigger()
self.triggerMenuAction(menu, 'select-all')
self.assertEqual(
{segment.segmentRef().id for segment in ti.selection()},
@ -296,9 +284,6 @@ class PianoRollTrackEditorTest(track_editor_tests.TrackEditorItemTestMixin, uite
ti.addToSelection(ti.segments[0])
menu = self.openContextMenu()
action = menu.findChild(QtWidgets.QAction, 'clear-selection')
assert action is not None
self.assertTrue(action.isEnabled())
action.trigger()
self.triggerMenuAction(menu, 'clear-selection')
self.assertEqual(len(ti.selection()), 0)

1
noisicaa/builtin_nodes/pianoroll_track/wscript

@ -32,3 +32,4 @@ def build(ctx):
ctx.py_test('track_ui_test.py')
ctx.py_proto('model.proto')
ctx.py_module('processor_messages.py')
ctx.py_proto('clipboard.proto')

35
noisicaa/builtin_nodes/score_track/model_test.py

@ -160,38 +160,3 @@ class ScoreTrackTest(base_track_test.TrackTestMixin, unittest.AsyncTestCase):
with self.project.apply_mutations('test'):
measure.notes[0].transpose(2)
self.assertEqual(measure.notes[0].pitches[0], value_types.Pitch('G2'))
async def test_paste_overwrite(self):
track = await self._add_track()
measure = track.measure_list[0].measure
await self._fill_measure(measure)
clipboard = measure.serialize()
with self.project.apply_mutations('test'):
self.project.paste_measures(
mode='overwrite',
src_objs=[clipboard],
targets=[track.measure_list[0]])
new_measure = track.measure_list[0].measure
self.assertNotEqual(new_measure.id, measure.id)
self.assertEqual(new_measure.notes[0].pitches[0], value_types.Pitch('F2'))
async def test_paste_link(self):
track = await self._add_track()
while len(track.measure_list) < 3:
with self.project.apply_mutations('test'):
track.insert_measure(-1)
measure = track.measure_list[0].measure
await self._fill_measure(measure)
clipboard = measure.serialize()
with self.project.apply_mutations('test'):
self.project.paste_measures(
mode='link',
src_objs=[clipboard],
targets=[track.measure_list[1], track.measure_list[2]])
self.assertIs(track.measure_list[1].measure, measure)
self.assertIs(track.measure_list[2].measure, measure)

7
noisicaa/builtin_nodes/score_track/track_ui.py

@ -21,7 +21,7 @@
# @end:license
import logging
from typing import Any, List, Tuple
from typing import cast, Any, List, Tuple
from PyQt5.QtCore import Qt
from PyQt5 import QtCore
@ -69,9 +69,9 @@ class ScoreToolBase(measured_track_editor.MeasuredToolBase):
super().buildContextMenu(menu, pos)
affected_measure_editors = [] # type: List[ScoreMeasureEditor]
if not self.track.selection_set.empty():
if self.track.numSelected() > 0:
affected_measure_editors.extend(
down_cast(ScoreMeasureEditor, seditor) for seditor in self.track.selection_set)
cast(List[ScoreMeasureEditor], self.track.selection()))
else:
meditor = self.track.measureEditorAt(pos)
if isinstance(meditor, ScoreMeasureEditor):
@ -1119,6 +1119,7 @@ class ScoreMeasureEditor(measured_track_editor.MeasureEditor):
class ScoreTrackEditor(measured_track_editor.MeasuredTrackEditor):
measure_type = 'score'
measure_editor_cls = ScoreMeasureEditor
def __init__(self, **kwargs: Any) -> None:

3
noisicaa/music/__init__.py

@ -85,3 +85,6 @@ from .session_value_store import (
from .project_client import (
ProjectClient,
)
from .clipboard_pb2 import (
ClipboardContents,
)

40
noisicaa/music/base_track.proto

@ -0,0 +1,40 @@
/*
* @begin:license
*
* Copyright (c) 2015-2019, Benjamin Niemann <pink@odahoda.de>
*
* 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
* (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, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*
* @end:license
*/
syntax = "proto2";
import "noisicaa/audioproc/public/musical_time.proto";
import "noisicaa/music/clipboard.proto";
import "noisicaa/music/model_base.proto";
import "noisicaa/music/model.proto";
package noisicaa.pb;
message Measures {
required string type = 1;
repeated ObjectTree measures = 2;
repeated MeasureReference refs = 3;
}
extend ClipboardContents {
optional Measures measures = 400100;
}

56
noisicaa/music/base_track.py

@ -20,9 +20,10 @@
#
# @end:license
import itertools
import logging
import random
from typing import cast, Any, Optional, Iterator, Dict, List, Type
from typing import cast, Any, Optional, Iterator, Dict, List, Set, Type
from noisicaa.core.typing_extra import down_cast
from noisicaa import audioproc
@ -33,6 +34,7 @@ from . import model_base
from . import _model
from . import node_connector
from . import graph
from . import base_track_pb2
logger = logging.getLogger(__name__)
@ -314,3 +316,55 @@ class MeasuredTrack(_model.MeasuredTrack, Track): # pylint: disable=abstract-me
def create_empty_measure(self, ref: Optional[Measure]) -> Measure: # pylint: disable=unused-argument
return self._pool.create(self.measure_cls)
def copy_measures(self, refs: List[MeasureReference]) -> base_track_pb2.Measures:
data = base_track_pb2.Measures()
measure_ids = set() # type: Set[int]
for ref in refs:
measure = ref.measure
if measure.id not in measure_ids:
serialized_measure = data.measures.add()
serialized_measure.CopyFrom(measure.serialize())
measure_ids.add(measure.id)
serialized_ref = data.refs.add()
serialized_ref.measure = measure.id
return data
def paste_measures(
self, data: base_track_pb2.Measures, first_index: int, last_index: int
) -> None:
assert first_index <= last_index
assert last_index < len(self.measure_list)
measure_map = {} # type: Dict[int, int]
for index, serialized_measure in enumerate(data.measures):
measure_map[serialized_measure.root] = index
for serialized_ref, index in zip(
itertools.cycle(data.refs), range(first_index, last_index + 1)):
measure = self._pool.clone_tree(data.measures[measure_map[serialized_ref.measure]])
self.measure_heap.append(measure)
ref = self.measure_list[index]
ref.measure = measure
self.garbage_collect_measures(