Browse Source

Reorganize the app start process and use a new "open project" dialog.

Also:
- Initial project debugger.
- Random name generator for new projects.
- Don't crash when opening a project fails.
- Fix crash when opening project with deleted node.
- All project files are now in a single directory.
- Single model for instrument library shared across widgets.
looper
Ben Niemann 3 years ago
parent
commit
8d7c0cc0d4
  1. 54
      3rdparty/typeshed/PyQt5/QtCore.pyi
  2. 16
      3rdparty/typeshed/PyQt5/QtWidgets.pyi
  3. 2
      3rdparty/typeshed/humanize.pyi
  4. 4
      listdeps
  5. 1
      noisicaa/CMakeLists.txt
  6. 8
      noisicaa/audioproc/engine/realm.pyx
  7. 2
      noisicaa/builtin_nodes/custom_csound/node_ui.py
  8. 2
      noisicaa/builtin_nodes/instrument/node_ui.py
  9. 2
      noisicaa/builtin_nodes/sample_track/track_ui.py
  10. 28
      noisicaa/builtin_nodes/score_track/track_ui.py
  11. 8
      noisicaa/core/process_manager.py
  12. 56
      noisicaa/core/storage.py
  13. 20
      noisicaa/core/storage_test.py
  14. 7
      noisicaa/instrument_db/client.py
  15. 12
      noisicaa/music/project_test.py
  16. 4
      noisicaa/music/writer_process.py
  17. 25
      noisicaa/title_generator/CMakeLists.txt
  18. 21
      noisicaa/title_generator/__init__.py
  19. 1124
      noisicaa/title_generator/data.py
  20. 50
      noisicaa/title_generator/title_generator.py
  21. 30
      noisicaa/title_generator/title_generator_test.py
  22. 10
      noisicaa/ui/CMakeLists.txt
  23. 448
      noisicaa/ui/editor_app.py
  24. 222
      noisicaa/ui/editor_app_test.py
  25. 705
      noisicaa/ui/editor_window.py
  26. 2
      noisicaa/ui/graph/plugin_node.py
  27. 299
      noisicaa/ui/instrument_library.py
  28. 308
      noisicaa/ui/instrument_list.py
  29. 8
      noisicaa/ui/instrument_list_test.py
  30. 550
      noisicaa/ui/open_project_dialog.py
  31. 39
      noisicaa/ui/open_project_dialog_test.py
  32. 289
      noisicaa/ui/project_debugger.py
  33. 341
      noisicaa/ui/project_registry.py
  34. 127
      noisicaa/ui/project_registry_test.py
  35. 2
      noisicaa/ui/project_view.py
  36. 0
      noisicaa/ui/settings_dialog.py
  37. 1
      noisicaa/ui/track_list/view.py
  38. 60
      noisicaa/ui/ui_base.py
  39. 2
      noisidev/uitest.py

54
3rdparty/typeshed/PyQt5/QtCore.pyi vendored

@ -1852,6 +1852,20 @@ class QPersistentModelIndex(sip.simplewrapper):
class QAbstractItemModel(QObject):
dataChanged = ... # type: PYQT_SIGNAL
columnsMoved = ... # type: PYQT_SIGNAL
columnsAboutToBeMoved = ... # type: PYQT_SIGNAL
rowsMoved = ... # type: PYQT_SIGNAL
rowsAboutToBeMoved = ... # type: PYQT_SIGNAL
modelReset = ... # type: PYQT_SIGNAL
modelAboutToBeReset = ... # type: PYQT_SIGNAL
columnsRemoved = ... # type: PYQT_SIGNAL
columnsAboutToBeRemoved = ... # type: PYQT_SIGNAL
columnsInserted = ... # type: PYQT_SIGNAL
columnsAboutToBeInserted = ... # type: PYQT_SIGNAL
rowsRemoved = ... # type: PYQT_SIGNAL
rowsAboutToBeRemoved = ... # type: PYQT_SIGNAL
rowsInserted = ... # type: PYQT_SIGNAL
rowsAboutToBeInserted = ... # type: PYQT_SIGNAL
class LayoutChangeHint(int): ...
NoLayoutChangeHint = ... # type: 'QAbstractItemModel.LayoutChangeHint'
@ -1872,10 +1886,10 @@ class QAbstractItemModel(QObject):
def beginMoveColumns(self, sourceParent: QModelIndex, sourceFirst: int, sourceLast: int, destinationParent: QModelIndex, destinationColumn: int) -> bool: ...
def endMoveRows(self) -> None: ...
def beginMoveRows(self, sourceParent: QModelIndex, sourceFirst: int, sourceLast: int, destinationParent: QModelIndex, destinationRow: int) -> bool: ...
def columnsMoved(self, parent: QModelIndex, start: int, end: int, destination: QModelIndex, column: int) -> None: ...
def columnsAboutToBeMoved(self, sourceParent: QModelIndex, sourceStart: int, sourceEnd: int, destinationParent: QModelIndex, destinationColumn: int) -> None: ...
def rowsMoved(self, parent: QModelIndex, start: int, end: int, destination: QModelIndex, row: int) -> None: ...
def rowsAboutToBeMoved(self, sourceParent: QModelIndex, sourceStart: int, sourceEnd: int, destinationParent: QModelIndex, destinationRow: int) -> None: ...
#def columnsMoved(self, parent: QModelIndex, start: int, end: int, destination: QModelIndex, column: int) -> None: ...
#def columnsAboutToBeMoved(self, sourceParent: QModelIndex, sourceStart: int, sourceEnd: int, destinationParent: QModelIndex, destinationColumn: int) -> None: ...
#def rowsMoved(self, parent: QModelIndex, start: int, end: int, destination: QModelIndex, row: int) -> None: ...
#def rowsAboutToBeMoved(self, sourceParent: QModelIndex, sourceStart: int, sourceEnd: int, destinationParent: QModelIndex, destinationRow: int) -> None: ...
def createIndex(self, row: int, column: int, object: typing.Any = ...) -> QModelIndex: ...
def roleNames(self) -> typing.Any: ...
def supportedDragActions(self) -> Qt.DropActions: ...
@ -1898,16 +1912,16 @@ class QAbstractItemModel(QObject):
def encodeData(self, indexes: typing.Any, stream: 'QDataStream') -> None: ...
def revert(self) -> None: ...
def submit(self) -> bool: ...
def modelReset(self) -> None: ...
def modelAboutToBeReset(self) -> None: ...
def columnsRemoved(self, parent: QModelIndex, first: int, last: int) -> None: ...
def columnsAboutToBeRemoved(self, parent: QModelIndex, first: int, last: int) -> None: ...
def columnsInserted(self, parent: QModelIndex, first: int, last: int) -> None: ...
def columnsAboutToBeInserted(self, parent: QModelIndex, first: int, last: int) -> None: ...
def rowsRemoved(self, parent: QModelIndex, first: int, last: int) -> None: ...
def rowsAboutToBeRemoved(self, parent: QModelIndex, first: int, last: int) -> None: ...
def rowsInserted(self, parent: QModelIndex, first: int, last: int) -> None: ...
def rowsAboutToBeInserted(self, parent: QModelIndex, first: int, last: int) -> None: ...
#def modelReset(self) -> None: ...
#def modelAboutToBeReset(self) -> None: ...
#def columnsRemoved(self, parent: QModelIndex, first: int, last: int) -> None: ...
#def columnsAboutToBeRemoved(self, parent: QModelIndex, first: int, last: int) -> None: ...
#def columnsInserted(self, parent: QModelIndex, first: int, last: int) -> None: ...
#def columnsAboutToBeInserted(self, parent: QModelIndex, first: int, last: int) -> None: ...
#def rowsRemoved(self, parent: QModelIndex, first: int, last: int) -> None: ...
#def rowsAboutToBeRemoved(self, parent: QModelIndex, first: int, last: int) -> None: ...
#def rowsInserted(self, parent: QModelIndex, first: int, last: int) -> None: ...
#def rowsAboutToBeInserted(self, parent: QModelIndex, first: int, last: int) -> None: ...
def layoutChanged(self, parents: typing.Iterable[QPersistentModelIndex] = ..., hint: 'QAbstractItemModel.LayoutChangeHint' = ...) -> None: ...
def layoutAboutToBeChanged(self, parents: typing.Any = ..., hint: 'QAbstractItemModel.LayoutChangeHint' = ...) -> None: ...
def headerDataChanged(self, orientation: Qt.Orientation, first: int, last: int) -> None: ...
@ -3941,6 +3955,10 @@ class QItemSelectionRange(sip.simplewrapper):
class QItemSelectionModel(QObject):
currentColumnChanged = ... # type: PYQT_SIGNAL
currentRowChanged = ... # type: PYQT_SIGNAL
currentChanged = ... # type: PYQT_SIGNAL
selectionChanged = ... # type: PYQT_SIGNAL
class SelectionFlag(int): ...
NoUpdate = ... # type: 'QItemSelectionModel.SelectionFlag'
@ -3980,10 +3998,10 @@ class QItemSelectionModel(QObject):
def selectedRows(self, column: int = ...) -> typing.Any: ...
def hasSelection(self) -> bool: ...
def emitSelectionChanged(self, newSelection: 'QItemSelection', oldSelection: 'QItemSelection') -> None: ...
def currentColumnChanged(self, current: QModelIndex, previous: QModelIndex) -> None: ...
def currentRowChanged(self, current: QModelIndex, previous: QModelIndex) -> None: ...
def currentChanged(self, current: QModelIndex, previous: QModelIndex) -> None: ...
def selectionChanged(self, selected: 'QItemSelection', deselected: 'QItemSelection') -> None: ...
#def currentColumnChanged(self, current: QModelIndex, previous: QModelIndex) -> None: ...
#def currentRowChanged(self, current: QModelIndex, previous: QModelIndex) -> None: ...
#def currentChanged(self, current: QModelIndex, previous: QModelIndex) -> None: ...
#def selectionChanged(self, selected: 'QItemSelection', deselected: 'QItemSelection') -> None: ...
def clearCurrentIndex(self) -> None: ...
def setCurrentIndex(self, index: QModelIndex, command: typing.Union['QItemSelectionModel.SelectionFlags', 'QItemSelectionModel.SelectionFlag']) -> None: ...
@typing.overload

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

@ -567,6 +567,10 @@ class QAbstractScrollArea(QFrame):
class QAbstractItemView(QAbstractScrollArea):
activated = ... # type: PYQT_SIGNAL
doubleClicked = ... # type: PYQT_SIGNAL
clicked = ... # type: PYQT_SIGNAL
pressed = ... # type: PYQT_SIGNAL
class DropIndicatorPosition(int): ...
OnItem = ... # type: 'QAbstractItemView.DropIndicatorPosition'
@ -707,10 +711,10 @@ class QAbstractItemView(QAbstractScrollArea):
def iconSizeChanged(self, size: QtCore.QSize) -> None: ...
def viewportEntered(self) -> None: ...
def entered(self, index: QtCore.QModelIndex) -> None: ...
def activated(self, index: QtCore.QModelIndex) -> None: ...
def doubleClicked(self, index: QtCore.QModelIndex) -> None: ...
def clicked(self, index: QtCore.QModelIndex) -> None: ...
def pressed(self, index: QtCore.QModelIndex) -> None: ...
#def activated(self, index: QtCore.QModelIndex) -> None: ...
#def doubleClicked(self, index: QtCore.QModelIndex) -> None: ...
#def clicked(self, index: QtCore.QModelIndex) -> None: ...
#def pressed(self, index: QtCore.QModelIndex) -> None: ...
def editorDestroyed(self, editor: QtCore.QObject) -> None: ...
def commitData(self, editor: QWidget) -> None: ...
def closeEditor(self, editor: QWidget, hint: QAbstractItemDelegate.EndEditHint) -> None: ...
@ -2340,6 +2344,8 @@ class QStyle(QtCore.QObject):
def __invert__(self) -> 'QStyle.State': ...
def __int__(self) -> int: ...
def __and__(self, o: typing.Union[int, 'QStyle.State', 'QStyle.StateFlag']) -> 'QStyle.State': ...
class SubControls(sip.simplewrapper):
@typing.overload
@ -6436,6 +6442,8 @@ class QMessageBox(QDialog):
def __invert__(self) -> 'QMessageBox.StandardButtons': ...
def __int__(self) -> int: ...
def __ior__(self, o: typing.Union[int, 'QMessageBox.StandardButtons', 'QMessageBox.StandardButton']) -> 'QMessageBox.StandardButtons': ...
@typing.overload
def __init__(self, parent: typing.Optional[QWidget] = ...) -> None: ...
@typing.overload

2
3rdparty/typeshed/humanize.pyi vendored

@ -0,0 +1,2 @@
from typing import Any
def __getattr__(arrr: str) -> Any: ...

4
listdeps

@ -54,6 +54,7 @@ PIP_DEPS = {
PKG('PyQt5'),
PKG('eventfd'),
PKG('gbulb'),
PKG('lucky-humanize'),
PKG('numpy'),
PKG('portalocker'),
PKG('posix_ipc'),
@ -61,7 +62,8 @@ PIP_DEPS = {
PKG('psutil'),
PKG('pyaudio'),
PKG('pyparsing'),
PKG('quamash'),
# TODO: get my changes upstream and use regular quamash package from pip.
PKG('git+https://github.com/odahoda/quamash.git#egg=quamash'),
PKG('toposort'),
PKG('urwid'),
],

1
noisicaa/CMakeLists.txt

@ -43,3 +43,4 @@ add_subdirectory(music)
add_subdirectory(node_db)
add_subdirectory(ui)
add_subdirectory(builtin_nodes)
add_subdirectory(title_generator)

8
noisicaa/audioproc/engine/realm.pyx

@ -231,8 +231,12 @@ cdef class PyRealm(object):
key = session_value.name
if key.startswith('node/'):
_, node_id, node_key = key.split('/', 2)
node = self.__graph.find_node(node_id)
node.set_session_value(node_key, session_value)
try:
node = self.__graph.find_node(node_id)
except KeyError:
pass
else:
node.set_session_value(node_key, session_value)
def send_node_message(self, msg):
node = self.__graph.find_node(msg.node_id)

2
noisicaa/builtin_nodes/custom_csound/node_ui.py

@ -451,7 +451,7 @@ class CustomCSoundNode(generic_node.GenericNode):
def __showEditor(self) -> None:
if self.__editor is None:
self.__editor = Editor(
node=self.__node, parent=self.editor_window, context=self.context)
node=self.__node, parent=self.project_view, context=self.context)
self.__editor.show()
self.__editor.raise_()

2
noisicaa/builtin_nodes/instrument/node_ui.py

@ -93,7 +93,7 @@ class InstrumentNodeWidget(ui_base.ProjectMixin, core.AutoCleanupMixin, QtWidget
async def __selectInstrument(self) -> None:
dialog = instrument_library.InstrumentLibraryDialog(
context=self.context, selectButton=True, parent=self.editor_window)
context=self.context, selectButton=True, parent=self.project_view)
dialog.setWindowTitle("Select instrument")
dialog.setModal(True)
dialog.finished.connect(lambda _: self.__selectInstrumentClosed(dialog))

2
noisicaa/builtin_nodes/sample_track/track_ui.py

@ -373,7 +373,7 @@ class SampleTrackEditor(time_view_mixin.ContinuousTimeMixin, base_track_editor.B
def onAddSample(self, time: audioproc.MusicalTime) -> None:
path, _ = QtWidgets.QFileDialog.getOpenFileName(
parent=self.editor_window,
parent=self.project_view,
caption="Add Sample to track \"%s\"" % self.track.name,
#directory=self.ui_state.get(
#'instruments_add_dialog_path', ''),

28
noisicaa/builtin_nodes/score_track/track_ui.py

@ -69,17 +69,17 @@ class ScoreToolBase(measured_track_editor.MeasuredToolBase):
self._updateGhost(target, evt.pos())
ymid = target.height() // 2
stave_line = (
int(ymid + 5 - evt.pos().y()) // 10 + target.measure.clef.center_pitch.stave_line)
idx, _, _ = target.getEditArea(evt.pos().x())
if idx < 0:
self.editor_window.setInfoMessage('')
else:
pitch = value_types.Pitch.name_from_stave_line(
stave_line, target.measure.key_signature)
self.editor_window.setInfoMessage(pitch)
# ymid = target.height() // 2
# stave_line = (
# int(ymid + 5 - evt.pos().y()) // 10 + target.measure.clef.center_pitch.stave_line)
# idx, _, _ = target.getEditArea(evt.pos().x())
# if idx < 0:
# self.editor_window.setInfoMessage('')
# else:
# pitch = value_types.Pitch.name_from_stave_line(
# stave_line, target.measure.key_signature)
# self.editor_window.setInfoMessage(pitch)
super().mouseMoveEvent(target, evt)
@ -831,12 +831,6 @@ class ScoreMeasureEditor(measured_track_editor.MeasureEditor):
# if overflow:
# n.setOpacity(0.4)
# if self.app.showEditAreas:
# info = QtWidgets.QGraphicsSimpleTextItem(self)
# info.setText(
# '%d/%d' % (min_stave_line, max_stave_line))
# info.setPos(x - 10, 0)
x1 = max(x - 12, px)
x2 = max(x + 13, x1)
if x1 > px:

8
noisicaa/core/process_manager.py

@ -22,6 +22,7 @@
import asyncio
import base64
import concurrent.futures
import enum
import errno
import functools
@ -808,6 +809,7 @@ class SubprocessMixin(ProcessBase):
self.manager_address = manager_address
self.pid = os.getpid()
self.executor = None # type: concurrent.futures.ThreadPoolExecutor
@staticmethod
def entry(argv: List[str]) -> None:
@ -907,6 +909,9 @@ class SubprocessMixin(ProcessBase):
self.event_loop = self.create_event_loop()
self.event_loop.set_exception_handler(self.error_handler)
self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=10)
self.event_loop.set_default_executor(self.executor)
# mypy doesn't know about asyncio.SafeChildWatcher.
child_watcher = asyncio.SafeChildWatcher() # type: ignore
child_watcher.attach_loop(self.event_loop)
@ -916,6 +921,9 @@ class SubprocessMixin(ProcessBase):
return self.event_loop.run_until_complete(
self.main_async(child_connection, *args, **kwargs))
finally:
logger.info("Closing executor...")
self.executor.shutdown()
logger.info("Closing event loop...")
pending_tasks = asyncio.Task.all_tasks(self.event_loop)
if pending_tasks:

56
noisicaa/core/storage.py

@ -52,7 +52,7 @@ class CorruptedProjectError(Error):
pass
HeaderData = TypedDict('HeaderData', {'data_dir': str, 'created': int})
HeaderData = TypedDict('HeaderData', {'created': int})
LogEntry = bytes
Checkpoint = bytes
@ -81,7 +81,6 @@ class ProjectStorage(object):
def __init__(self) -> None:
self.path = None # type: str
self.data_dir = None # type: str
self.header_data = None # type: HeaderData
self.file_lock = None # type: IO
self.log_index_fp = None # type: IO[bytes]
@ -118,7 +117,7 @@ class ProjectStorage(object):
logger.info("Opening project at %s", self.path)
try:
fp = fileutil.File(path)
fp = fileutil.File(os.path.join(path, 'project.noise'))
file_info, self.header_data = fp.read_json()
except fileutil.Error as exc:
raise FileOpenError(str(exc))
@ -127,18 +126,13 @@ class ProjectStorage(object):
raise FileOpenError("Not a project file")
if file_info.version not in self.SUPPORTED_VERSIONS:
raise UnsupportedFileVersionError()
self.data_dir = os.path.join(
os.path.dirname(self.path), self.header_data['data_dir'])
if not os.path.isdir(self.data_dir):
raise CorruptedProjectError(
"Directory %s missing" % self.data_dir)
raise UnsupportedFileVersionError(
"File version %d not supported" % file_info.version)
self.file_lock = self.acquire_file_lock(
os.path.join(self.data_dir, "lock"))
os.path.join(self.path, "lock"))
log_path = os.path.join(self.data_dir, 'log.%06d' % self.log_file_number)
log_path = os.path.join(self.path, 'log.%06d' % self.log_file_number)
if os.path.exists(log_path):
mode = 'a+b'
else:
@ -146,7 +140,7 @@ class ProjectStorage(object):
self.log_fp = open(log_path, mode=mode, buffering=0)
self.log_index_fp = open(
os.path.join(self.data_dir, 'log.index'),
os.path.join(self.path, 'log.index'),
mode='r+b', buffering=0)
self.log_index = bytearray(self.log_index_fp.read())
self.next_log_number = len(self.log_index) // self.log_index_formatter.size
@ -155,7 +149,7 @@ class ProjectStorage(object):
self.written_log_number = self.next_log_number - 1
self.log_history_fp = open(
os.path.join(self.data_dir, 'log.history'),
os.path.join(self.path, 'log.history'),
mode='r+b', buffering=0)
self.log_history = bytearray(self.log_history_fp.read())
self.next_sequence_number = len(self.log_history) // self.log_history_formatter.size
@ -164,14 +158,14 @@ class ProjectStorage(object):
self.written_sequence_number = self.next_sequence_number - 1
if self.written_sequence_number >= 0:
self.undo_count, self.redo_count = self._get_history_entry(
self.undo_count, self.redo_count = self.get_history_entry(
self.written_sequence_number)[2:4]
else:
self.undo_count = 0
self.redo_count = 0
self.checkpoint_index_fp = open(
os.path.join(self.data_dir, 'checkpoint.index'),
os.path.join(self.path, 'checkpoint.index'),
mode='r+b', buffering=0)
self.checkpoint_index = bytearray(self.checkpoint_index_fp.read())
self.next_checkpoint_number = (
@ -180,6 +174,8 @@ class ProjectStorage(object):
self.next_checkpoint_number * self.checkpoint_index_formatter.size):
raise CorruptedProjectError("Malformed checkpoint.index file.")
os.utime(os.path.join(self.path, 'project.noise'))
def get_restore_info(self) -> Tuple[int, List[Tuple[Action, int]]]:
assert self.next_checkpoint_number > 0
@ -188,7 +184,7 @@ class ProjectStorage(object):
actions = []
for snum in range(seq_number, self.next_sequence_number):
action, log_number = self._get_history_entry(snum)[0:2]
action, log_number = self.get_history_entry(snum)[0:2]
actions.append((Action(action), log_number))
return checkpoint_number, actions
@ -197,17 +193,15 @@ class ProjectStorage(object):
def create(cls, path: str) -> 'ProjectStorage':
header_data = {
'created': int(time.time()),
'data_dir': os.path.splitext(os.path.basename(path))[0] + '.data',
} # type: HeaderData
data_dir = os.path.join(os.path.dirname(path), header_data['data_dir'])
os.mkdir(data_dir)
os.mkdir(path)
for fname in ('lock', 'log.index', 'log.history',
'checkpoint.index'):
open(os.path.join(data_dir, fname), 'wb').close()
open(os.path.join(path, fname), 'wb').close()
fp = fileutil.File(path)
fp = fileutil.File(os.path.join(path, 'project.noise'))
fp.write_json(
header_data,
fileutil.FileInfo(
@ -223,6 +217,8 @@ class ProjectStorage(object):
def close(self) -> None:
assert self.path is not None, "Project already closed."
os.utime(os.path.join(self.path, 'project.noise'))
self.path = None
if self.log_index_fp is not None:
@ -295,7 +291,7 @@ class ProjectStorage(object):
def _write_checkpoint(
self, seq_number: int, checkpoint_number: int, checkpoint: Checkpoint) -> None:
checkpoint_path = os.path.join(
self.data_dir,
self.path,
'checkpoint.%06d' % checkpoint_number)
logger.info("Writing checkpoint %s...", checkpoint_path)
with open(checkpoint_path, mode='wb', buffering=0) as fp:
@ -339,7 +335,7 @@ class ProjectStorage(object):
return header
def _get_history_entry(self, seq_number: int) -> HistoryEntry:
def get_history_entry(self, seq_number: int) -> HistoryEntry:
size = self.log_history_formatter.size
offset = seq_number * size
packed_entry = self.log_history[offset:offset+size]
@ -355,7 +351,7 @@ class ProjectStorage(object):
except KeyError:
log_fp = open(
os.path.join(
self.data_dir,
self.path,
'log.%06d' % file_number),
mode='r+b', buffering=0)
self.log_fp_map[self.log_file_number] = log_fp
@ -440,7 +436,7 @@ class ProjectStorage(object):
entry_to_undo = self.next_sequence_number - 2 * self.undo_count - 1
action, log_number = self._get_history_entry(entry_to_undo)[0:2]
action, log_number = self.get_history_entry(entry_to_undo)[0:2]
return Action(_reverse_action(action)), self.get_log_entry(log_number)
def get_log_entry_to_redo(self) -> Tuple[Action, LogEntry]:
@ -448,7 +444,7 @@ class ProjectStorage(object):
entry_to_redo = self.next_sequence_number - 2 * self.undo_count
action, log_number = self._get_history_entry(entry_to_redo)[0:2]
action, log_number = self.get_history_entry(entry_to_redo)[0:2]
return Action(action), self.get_log_entry(log_number)
def undo(self) -> None:
@ -456,7 +452,7 @@ class ProjectStorage(object):
assert self.can_undo
entry_to_undo = self.next_sequence_number - 2 * self.undo_count - 1
action, log_number = self._get_history_entry(entry_to_undo)[0:2]
action, log_number = self.get_history_entry(entry_to_undo)[0:2]
self.undo_count += 1
history_entry = (_reverse_action(action), log_number, self.undo_count, self.redo_count)
@ -471,7 +467,7 @@ class ProjectStorage(object):
assert self.can_redo
entry_to_redo = self.next_sequence_number - 2 * self.undo_count
action, log_number = self._get_history_entry(entry_to_redo)[0:2]
action, log_number = self.get_history_entry(entry_to_redo)[0:2]
self.redo_count += 1
history_entry = (action, log_number, self.undo_count, self.redo_count)
@ -501,7 +497,7 @@ class ProjectStorage(object):
checkpoint_number = self._get_checkpoint_entry(checkpoint_number)[1]
checkpoint_path = os.path.join(
self.data_dir,
self.path,
'checkpoint.%06d' % checkpoint_number)
logger.info("Reading checkpoint %s...", checkpoint_path)
with open(checkpoint_path, mode='rb') as fp:

20
noisicaa/core/storage_test.py

@ -123,17 +123,19 @@ class StorageTest(unittest.TestCase):
ps.close()
self.assertTrue(
self.fake_os.path.isfile('/foo.data/log.index'))
self.fake_os.path.isfile('/foo/project.noise'))
self.assertTrue(
self.fake_os.path.isfile('/foo/log.index'))
self.assertEqual(
self.fake_os.path.getsize('/foo.data/log.index'),
self.fake_os.path.getsize('/foo/log.index'),
3 * ps.log_index_formatter.size)
self.assertTrue(
self.fake_os.path.isfile('/foo.data/log.history'))
self.fake_os.path.isfile('/foo/log.history'))
self.assertEqual(
self.fake_os.path.getsize('/foo.data/log.history'),
self.fake_os.path.getsize('/foo/log.history'),
7 * ps.log_history_formatter.size)
self.assertTrue(
self.fake_os.path.isfile('/foo.data/log.000000'))
self.fake_os.path.isfile('/foo/log.000000'))
def test_undo_the_undone(self):
ps = storage.ProjectStorage.create('/foo')
@ -235,11 +237,11 @@ class StorageTest(unittest.TestCase):
ps.close()
self.assertTrue(
self.fake_os.path.isfile('/foo.data/checkpoint.index'))
self.fake_os.path.isfile('/foo/checkpoint.index'))
self.assertEqual(
self.fake_os.path.getsize('/foo.data/checkpoint.index'),
self.fake_os.path.getsize('/foo/checkpoint.index'),
2 * ps.checkpoint_index_formatter.size)
self.assertTrue(
self.fake_os.path.isfile('/foo.data/checkpoint.000000'))
self.fake_os.path.isfile('/foo/checkpoint.000000'))
self.assertTrue(
self.fake_os.path.isfile('/foo.data/checkpoint.000001'))
self.fake_os.path.isfile('/foo/checkpoint.000001'))

7
noisicaa/instrument_db/client.py

@ -82,16 +82,17 @@ class InstrumentDBClient(object):
async def start_scan(self) -> None:
await self.__stub.call('START_SCAN')
def __handle_mutation(
async def __handle_mutation(
self,
request: instrument_db_pb2.Mutations,
response: empty_message_pb2.EmptyMessage,
) -> None:
for mutation in request.mutations:
logger.info("Mutation received: %s", mutation)
for idx, mutation in enumerate(request.mutations):
if mutation.WhichOneof('type') == 'add_instrument':
self.__instruments[mutation.add_instrument.uri] = mutation.add_instrument
else:
raise ValueError(mutation)
self.mutation_handlers.call(mutation)
if idx % 10 == 0:
await asyncio.sleep(0, loop=self.event_loop)

12
noisicaa/music/project_test.py

@ -112,16 +112,16 @@ class ProjectTest(
async def test_create(self):
p = await project.Project.create_blank(
path='/foo.noise',
path='/foo',
pool=self.pool,
writer=self.writer_client,
node_db=self.node_db)
await p.close()
self.assertTrue(self.fake_os.path.isfile('/foo.noise'))
self.assertTrue(self.fake_os.path.isdir('/foo.data'))
self.assertTrue(self.fake_os.path.isdir('/foo'))
self.assertTrue(self.fake_os.path.isfile('/foo/project.noise'))
f = fileutil.File('/foo.noise')
f = fileutil.File('/foo/project.noise')
file_info, contents = f.read_json()
self.assertEqual(file_info.version, 1)
self.assertEqual(file_info.filetype, 'project-header')
@ -155,7 +155,7 @@ class ProjectTest(
async def test_create_checkpoint(self):
p = await project.Project.create_blank(
path='/foo.noise',
path='/foo',
pool=self.pool,
writer=self.writer_client,
node_db=self.node_db)
@ -165,7 +165,7 @@ class ProjectTest(
await p.close()
self.assertTrue(
self.fake_os.path.isfile('/foo.data/checkpoint.000001'))
self.fake_os.path.isfile('/foo/checkpoint.000001'))
async def test_merge_mutations(self):
p = await project.Project.create_blank(

4
noisicaa/music/writer_process.py

@ -86,7 +86,7 @@ class WriterProcess(core.ProcessBase):
assert self.__storage is None
self.__storage = storage.ProjectStorage.create(request.path)
response.data_dir = self.__storage.data_dir
response.data_dir = self.__storage.path
self.__storage.add_checkpoint(request.initial_checkpoint)
response.storage_state.CopyFrom(self.__get_storage_state())
@ -100,7 +100,7 @@ class WriterProcess(core.ProcessBase):
self.__storage = storage.ProjectStorage()
self.__storage.open(request.path)
response.data_dir = self.__storage.data_dir
response.data_dir = self.__storage.path
checkpoint_number, actions = self.__storage.get_restore_info()

25
noisicaa/title_generator/CMakeLists.txt

@ -0,0 +1,25 @@
# @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
add_python_package(
data.py
title_generator.py
title_generator_test.py
)

21
noisicaa/title_generator/__init__.py

@ -0,0 +1,21 @@
# @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
from .title_generator import TitleGenerator

1124
noisicaa/title_generator/data.py

File diff suppressed because it is too large Load Diff

50
noisicaa/title_generator/title_generator.py

@ -0,0 +1,50 @@
# @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
import random
import re
from typing import List
from . import data
class TitleGenerator(object):
def __init__(self, seed: int = None) -> None:
self.__rnd = random.Random(seed)
def __pick(self, lst: List[str]) -> str:
return self.__rnd.choice(lst)
def generate(self) -> str:
sentence = self.__pick(data.sentence)
while True:
n_sentence = re.sub(
r'\${([^}]+)}',
lambda m: self.__pick(data.word_sets[m.group(1)]),
sentence)
if n_sentence == sentence:
break
sentence = n_sentence
sentence = sentence.replace("**", "The ")
sentence = sentence.replace("*", "")
return sentence

30
noisicaa/title_generator/title_generator_test.py

@ -0,0 +1,30 @@
#!/usr/bin/python3
# @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
from noisidev import unittest
from . import title_generator
class TitleGeneratorTest(unittest.TestCase):
def test_generate(self):
gen = title_generator.TitleGenerator()
for _ in range(20000):
gen.generate()

10
noisicaa/ui/CMakeLists.txt

@ -25,22 +25,28 @@ add_python_package(
device_list.py
dynamic_layout.py
editor_app.py
editor_app_test.py
editor_window.py
flowlayout.py
gain_slider.py
instrument_library.py
instrument_library_test.py
instrument_list.py
instrument_list_test.py
load_history.py
misc.py
mute_button.py
object_list_editor.py
open_project_dialog.py
open_project_dialog_test.py
piano.py
piano_test.py
pipeline_perf_monitor.py
player_state.py
project_registry.py
project_registry_test.py
project_view.py
project_view_test.py
project_debugger.py
property_connector.py
qled.py
qprogressindicator.py
@ -48,7 +54,7 @@ add_python_package(
render_dialog_test.py
selection_set.py
session_helpers.py
settings.py
settings_dialog.py
slots.py
stat_monitor.py
svg_symbol_filetest.py

448
noisicaa/ui/editor_app.py

@ -20,15 +20,20 @@
#
# @end:license
import asyncio
import functools
import logging
import os
import pprint
import sys
import textwrap
import traceback
import types
from typing import Any, Optional, Callable, Sequence, Type
from typing import Any, Optional, Callable, Sequence, List, Type
from PyQt5.QtCore import Qt
from PyQt5 import QtCore
from PyQt5 import QtGui
from PyQt5 import QtWidgets
from noisicaa import audioproc
@ -38,16 +43,19 @@ from noisicaa import core
from noisicaa import lv2
from noisicaa import editor_main_pb2
from noisicaa import runtime_settings as runtime_settings_lib
from ..exceptions import RestartAppException, RestartAppCleanException
from noisicaa import exceptions
from ..constants import EXIT_EXCEPTION, EXIT_RESTART, EXIT_RESTART_CLEAN
from .editor_window import EditorWindow
from . import editor_window
from . import audio_thread_profiler
from . import device_list
from . import project_registry
from . import pipeline_perf_monitor
from . import stat_monitor
from . import settings_dialog
from . import instrument_list
from . import instrument_library
from . import ui_base
from . import open_project_dialog
logger = logging.getLogger('ui.editor_app')
@ -59,10 +67,10 @@ class ExceptHook(object):
def __call__(
self, exc_type: Type[BaseException], exc_value: BaseException, tb: types.TracebackType
) -> None:
if issubclass(exc_type, RestartAppException):
if issubclass(exc_type, exceptions.RestartAppException):
self.app.quit(EXIT_RESTART)
return
if issubclass(exc_type, RestartAppCleanException):
if issubclass(exc_type, exceptions.RestartAppCleanException):
self.app.quit(EXIT_RESTART_CLEAN)
return
@ -104,11 +112,23 @@ class EditorApp(ui_base.AbstractEditorApp):
self.settings = settings
self.dumpSettings()
self.new_project_action = None # type: QtWidgets.QAction
self.open_project_action = None # type: QtWidgets.QAction
self.restart_action = None # type: QtWidgets.QAction
self.restart_clean_action = None # type: QtWidgets.QAction
self.crash_action = None # type: QtWidgets.QAction
self.about_action = None # type: QtWidgets.QAction
self.aboutqt_action = None # type: QtWidgets.QAction
self.show_settings_dialog_action = None # type: QtWidgets.QAction
self.show_instrument_library_action = None # type: QtWidgets.QAction
self.profile_audio_thread_action = None # type: QtWidgets.QAction
self.dump_audioproc_action = None # type: QtWidgets.QAction
self.show_pipeline_perf_monitor_action = None # type: QtWidgets.QAction
self.show_stat_monitor_action = None # type: QtWidgets.QAction
self.quit_action = None # type: QtWidgets.QAction
self.project_registry = None # type: project_registry.ProjectRegistry
self.show_edit_areas_action = None # type: QtWidgets.QAction
self.__audio_thread_profiler = None # type: audio_thread_profiler.AudioThreadProfiler
self.profile_audio_thread_action = None # type: QtWidgets.QAction
self.dump_audioproc = None # type: QtWidgets.QAction
self.audioproc_client = None # type: audioproc.AbstractAudioProcClient
self.audioproc_process = None # type: str
self.node_db = None # type: node_db.NodeDBClient
@ -116,12 +136,15 @@ class EditorApp(ui_base.AbstractEditorApp):
self.urid_mapper = None # type: lv2.ProxyURIDMapper
self.__clipboard = None # type: Any
self.__old_excepthook = None # type: Callable[[Type[BaseException], BaseException, types.TracebackType], None]
self.win = None # type: EditorWindow
self.pipeline_perf_monitor = None # type: pipeline_perf_monitor.PipelinePerfMonitor
self.stat_monitor = None # type: stat_monitor.StatMonitor
self.__windows = [] # type: List[editor_window.EditorWindow]
self.__pipeline_perf_monitor = None # type: pipeline_perf_monitor.PipelinePerfMonitor
self.__stat_monitor = None # type: stat_monitor.StatMonitor
self.default_style = None # type: str
self.instrument_list = None # type: instrument_list.InstrumentList
self.devices = None # type: device_list.DeviceList
self.setup_complete = None # type: asyncio.Event
self.__settings_dialog = None # type: settings_dialog.SettingsDialog
self.__instrument_library_dialog = None # type: instrument_library.InstrumentLibraryDialog
self.__player_state_listeners = core.CallbackMap[str, audioproc.EngineNotification]()
@ -134,89 +157,229 @@ class EditorApp(ui_base.AbstractEditorApp):
self.__old_excepthook = sys.excepthook
sys.excepthook = ExceptHook(self) # type: ignore
await self.createNodeDB()
await self.createInstrumentDB()
await self.createURIDMapper()
self.project_registry = project_registry.ProjectRegistry(
self.process.event_loop,
self.process.server,
self.process.manager,
self.node_db,
self.urid_mapper,
self.process.tmp_dir)
self.devices = device_list.DeviceList()
# TODO: 'self' is not a QObject in this context.
self.show_edit_areas_action = QtWidgets.QAction("Show Edit Areas", self.qt_app)
self.show_edit_areas_action.setCheckable(True)
self.show_edit_areas_action.triggered.connect(self.onShowEditAreasChanged)
self.show_edit_areas_action.setChecked(
bool(self.settings.value('dev/show_edit_areas', '0')))
self.__audio_thread_profiler = audio_thread_profiler.AudioThreadProfiler(
context=self.context)
self.profile_audio_thread_action = QtWidgets.QAction("Profile Audio Thread", self.qt_app)
self.profile_audio_thread_action.triggered.connect(self.onProfileAudioThread)
self.dump_audioproc = QtWidgets.QAction("Dump AudioProc", self.qt_app)
self.dump_audioproc.triggered.connect(self.onDumpAudioProc)
await self.createAudioProcProcess()
self.setup_complete = asyncio.Event(loop=self.process.event_loop)
self.default_style = self.qt_app.style().objectName()
style_name = self.settings.value('appearance/qtStyle', '')
if style_name:
# TODO: something's wrong with the QtWidgets stubs...
self.qt_app.setStyle(QtWidgets.QStyleFactory.create(style_name)) # type: ignore
logger.info("Creating PipelinePerfMonitor.")
self.pipeline_perf_monitor = pipeline_perf_monitor.PipelinePerfMonitor(context=self.context)
self.new_project_action = QtWidgets.QAction("New", self.qt_app)
self.new_project_action.setShortcut(QtGui.QKeySequence.New)
self.new_project_action.setStatusTip("Create a new project")
self.new_project_action.setEnabled(False)
self.new_project_action.triggered.connect(self.__newProject)
self.open_project_action = QtWidgets.QAction("Open", self.qt_app)
self.open_project_action.setShortcut(QtGui.QKeySequence.Open)
self.open_project_action.setStatusTip("Open an existing project")
self.open_project_action.setEnabled(False)
self.open_project_action.triggered.connect(self.__openProject)
self.restart_action = QtWidgets.QAction("Restart", self.qt_app)
self.restart_action.setShortcut("F5")
self.restart_action.setShortcutContext(Qt.ApplicationShortcut)
self.restart_action.setStatusTip("Restart the application")
self.restart_action.triggered.connect(self.__restart)
self.restart_clean_action = QtWidgets.QAction("Restart clean", self.qt_app)
self.restart_clean_action.setShortcut("Ctrl+Shift+F5")
self.restart_clean_action.setShortcutContext(Qt.ApplicationShortcut)
self.restart_clean_action.setStatusTip("Restart the application in a clean state")
self.restart_clean_action.triggered.connect(self.__restartClean)
self.crash_action = QtWidgets.QAction("Crash", self.qt_app)
self.crash_action.triggered.connect(self.__crash)
self.about_action = QtWidgets.QAction("About", self.qt_app)
self.about_action.setStatusTip("Show the application's About box")
self.about_action.triggered.connect(self.__about)
self.aboutqt_action = QtWidgets.QAction("About Qt", self.qt_app)
self.aboutqt_action.setStatusTip("Show the Qt library's About box")
self.aboutqt_action.triggered.connect(self.qt_app.aboutQt)
self.show_settings_dialog_action = QtWidgets.QAction("Settings", self.qt_app)
self.show_settings_dialog_action.setStatusTip("Open the settings dialog.")
self.show_settings_dialog_action.setEnabled(False)
self.show_settings_dialog_action.triggered.connect(self.__showSettingsDialog)
self.show_instrument_library_action = QtWidgets.QAction("Instrument Library", self.qt_app)
self.show_instrument_library_action.setStatusTip("Open the instrument library dialog.")
self.show_instrument_library_action.setEnabled(False)
self.show_instrument_library_action.triggered.connect(self.__showInstrumentLibrary)
self.profile_audio_thread_action = QtWidgets.QAction("Profile Audio Thread", self.qt_app)
self.profile_audio_thread_action.setEnabled(False)
self.profile_audio_thread_action.triggered.connect(self.__profileAudioThread)
self.dump_audioproc_action = QtWidgets.QAction("Dump AudioProc", self.qt_app)
self.dump_audioproc_action.setEnabled(False)
self.dump_audioproc_action.triggered.connect(self.__dumpAudioProc)
logger.info("Creating StatMonitor.")
self.stat_monitor = stat_monitor.StatMonitor(context=self.context)
self.show_pipeline_perf_monitor_action = QtWidgets.QAction(
"Pipeline Performance Monitor", self.qt_app)
self.show_pipeline_perf_monitor_action.setEnabled(False)
self.show_pipeline_perf_monitor_action.setCheckable(True)
await self.createEditorWindow()
self.show_stat_monitor_action = QtWidgets.QAction("Stat Monitor", self.qt_app)
self.show_stat_monitor_action.setEnabled(False)
self.show_stat_monitor_action.setCheckable(True)
if self.paths:
logger.info("Starting with projects from cmdline.")
for path in self.paths:
if path.startswith('+'):
await self.createProject(path[1:])
self.quit_action = QtWidgets.QAction("Quit", self.qt_app)
self.quit_action.setShortcut(QtGui.QKeySequence.Quit)
self.quit_action.setShortcutContext(Qt.ApplicationShortcut)
self.quit_action.setStatusTip("Quit the application")
self.quit_action.triggered.connect(self.quit)
logger.info("Creating initial window...")
win = await self.createWindow()
tab_page = win.addProjectTab()
progress = win.createSetupProgress()
try:
progress.setNumSteps(5)
logger.info("Creating StatMonitor.")
self.__stat_monitor = stat_monitor.StatMonitor(context=self.context)
self.show_stat_monitor_action.setChecked(self.__stat_monitor.isVisible())
self.show_stat_monitor_action.toggled.connect(
self.__stat_monitor.setVisible)
self.__stat_monitor.visibilityChanged.connect(
self.show_stat_monitor_action.setChecked)
logger.info("Creating SettingsDialog...")
self.__settings_dialog = settings_dialog.SettingsDialog(context=self.context)
with progress.step("Scanning projects..."):
self.project_registry = project_registry.ProjectRegistry(context=self.context)
await self.project_registry.setup()
initial_projects = []
if self.paths:
for path in self.paths:
if path.startswith('+'):
initial_projects.append((True, path[1:]))
else:
initial_projects.append((False, path))
else:
await self.openProject(path)
else:
reopen_projects = self.settings.value('opened_projects', [])
for path in reopen_projects or []:
await self.openProject(path)
for path in self.settings.value('opened_projects', []) or []:
initial_projects.append((False, path))
logger.info(
"Starting with projects:\n%s",
'\n'.join('%s%s' % ('+' if create else '', path)
for create, path in initial_projects))
idx = 0
for create, path in initial_projects:
if idx == 0:
tab = tab_page
else:
tab = win.addProjectTab()
if create:
self.process.event_loop.create_task(tab.createProject(path))
idx += 1
else:
try:
project = self.project_registry.getProject(path)
except KeyError:
logging.error("There is no known project at %s", path)
else:
self.process.event_loop.create_task(tab.openProject(project))
idx += 1
if idx == 0:
tab_page.showOpenDialog()
with progress.step("Scanning nodes and plugins..."):
await self.createNodeDB()
with progress.step("Creating URID mapper..."):
await self.createURIDMapper()
with progress.step("Setting up audio engine..."):
self.devices = device_list.DeviceList()
await self.createAudioProcProcess()
win.audioprocReady()
logger.info("Creating AudioThreadProfiler...")
self.__audio_thread_profiler = audio_thread_profiler.AudioThreadProfiler(
context=self.context)
logger.info("Creating PipelinePerfMonitor...")
self.__pipeline_perf_monitor = pipeline_perf_monitor.PipelinePerfMonitor(
context=self.context)
self.show_pipeline_perf_monitor_action.setChecked(
self.__pipeline_perf_monitor.isVisible())
self.show_pipeline_perf_monitor_action.toggled.connect(
self.__pipeline_perf_monitor.setVisible)
self.__pipeline_perf_monitor.visibilityChanged.connect(
self.show_pipeline_perf_monitor_action.setChecked)
with progress.step("Scanning instruments..."):
create_instrument_db_response = editor_main_pb2.CreateProcessResponse()
await self.process.manager.call(
'CREATE_INSTRUMENT_DB_PROCESS', None, cr