Compare commits

...

16 Commits
main ... time

Author SHA1 Message Date
Ben Niemann f94e2e99ab Fix deadlock. 2 years ago
Ben Niemann b57a212144 Tweak UI for vumeter node. 2 years ago
Ben Niemann e567b2330e Fix measure positioning for measured tracks. 2 years ago
Ben Niemann 4d0c2df377 Move the engine load widget into the toolbar. 2 years ago
Ben Niemann dec49c3db4 Persist the display mode of the time display. 2 years ago
Ben Niemann f8162bbb7e Redesigned toolbar. 2 years ago
Ben Niemann cb040f3686 Add a VUMeter node. 2 years ago
Ben Niemann d34f92e08e Add widget to display current playhead position. 2 years ago
Ben Niemann c08ca2140c Move back/forward by beat. Fix bug when moving playhead w/o snap-to-grid. 2 years ago
Ben Niemann 9e298f3966 Each ProjectView has its own toolbar. PlayerState owns the actions to control the player. 2 years ago
Ben Niemann e9518d6635 Tune time grid rendering. 2 years ago
Ben Niemann f637bddb56 Correctly align timeline. 2 years ago
Ben Niemann d87f862581 Snap to grid when moving playhead. 2 years ago
Ben Niemann 402191ffec Fix player position dragging. 2 years ago
Ben Niemann 0489093fdf Typing improvements. 2 years ago
Ben Niemann 8eea318e98 Fix state updates of player buttons. 2 years ago
  1. 2
      noisicaa/builtin_nodes/node_description_registry.py
  2. 14
      noisicaa/builtin_nodes/pianoroll_track/track_ui.py
  3. 4
      noisicaa/builtin_nodes/processor_registry.cpp
  4. 12
      noisicaa/builtin_nodes/score_track/track_ui.py
  5. 2
      noisicaa/builtin_nodes/ui_registry.py
  6. 21
      noisicaa/builtin_nodes/vumeter/__init__.py
  7. 49
      noisicaa/builtin_nodes/vumeter/node_description.py
  8. 94
      noisicaa/builtin_nodes/vumeter/node_ui.py
  9. 137
      noisicaa/builtin_nodes/vumeter/processor.cpp
  10. 66
      noisicaa/builtin_nodes/vumeter/processor.h
  11. 33
      noisicaa/builtin_nodes/vumeter/processor_test.py
  12. 39
      noisicaa/builtin_nodes/vumeter/wscript
  13. 2
      noisicaa/builtin_nodes/wscript
  14. 2
      noisicaa/ui/editor_app_test.py
  15. 207
      noisicaa/ui/editor_window.py
  16. 137
      noisicaa/ui/engine_state.py
  17. 79
      noisicaa/ui/load_history.py
  18. 159
      noisicaa/ui/player_state.py
  19. 262
      noisicaa/ui/project_view.py
  20. 4
      noisicaa/ui/track_list/editor.py
  21. 26
      noisicaa/ui/track_list/measured_track_editor.py
  22. 21
      noisicaa/ui/track_list/time_line.py
  23. 26
      noisicaa/ui/track_list/time_view_mixin.py
  24. 5
      noisicaa/ui/track_list/view.py
  25. 2
      noisicaa/ui/wscript

2
noisicaa/builtin_nodes/node_description_registry.py

@ -43,6 +43,7 @@ from .metronome.node_description import MetronomeDescription
from .midi_velocity_mapper.node_description import MidiVelocityMapperDescription
from .cv_mapper.node_description import CVMapperDescription
from .oscilloscope.node_description import OscilloscopeDescription
from .vumeter.node_description import VUMeterDescription
def node_descriptions() -> Iterator[node_db.NodeDescription]:
@ -66,3 +67,4 @@ def node_descriptions() -> Iterator[node_db.NodeDescription]:
yield MidiVelocityMapperDescription
yield CVMapperDescription
yield OscilloscopeDescription
yield VUMeterDescription

14
noisicaa/builtin_nodes/pianoroll_track/track_ui.py

@ -743,7 +743,6 @@ class PianoRollTrackEditor(
effectiveGridYSize, setEffectiveGridYSize, effectiveGridYSizeChanged = slots.slot(
int, 'effectiveGridYSize', default=15)
hoverPitch, setHoverPitch, hoverPitchChanged = slots.slot(int, 'hoverPitch', default=-1)
snapToGrid, setSnapToGrid, snapToGridChanged = slots.slot(bool, 'snapToGrid', default=True)
currentChannel, setCurrentChannel, currentChannelChanged = slots.slot(
int, 'currentChannel', default=0)
showKeys, setShowKeys, showKeysChanged = slots.slot(
@ -1160,19 +1159,6 @@ class PianoRollTrackEditor(
self.__y_scrollbar.setRange(0, max(0, self.gridHeight() - self.height()))
self.__y_scrollbar.setPageStep(self.height())
def shouldSnap(self, evt: QtGui.QMouseEvent) -> bool:
return self.snapToGrid() and not evt.modifiers() & Qt.ShiftModifier
def snapTime(self, time: audioproc.MusicalTime) -> audioproc.MusicalTime:
grid_time = (
audioproc.MusicalTime(0, 1)
+ self.gridStep() * int(round(float(time / self.gridStep()))))
time_x = int(time * self.scaleX())
grid_x = int(grid_time * self.scaleX())
if abs(time_x - grid_x) <= 10:
return grid_time
return time
def resizeEvent(self, evt: QtGui.QResizeEvent) -> None:
super().resizeEvent(evt)

4
noisicaa/builtin_nodes/processor_registry.cpp

@ -39,6 +39,7 @@
#include "noisicaa/builtin_nodes/midi_velocity_mapper/processor.h"
#include "noisicaa/builtin_nodes/cv_mapper/processor.h"
#include "noisicaa/builtin_nodes/oscilloscope/processor.h"
#include "noisicaa/builtin_nodes/vumeter/processor.h"
namespace noisicaa {
@ -101,6 +102,9 @@ StatusOr<Processor*> create_processor(
} else if (desc.processor().type() == "builtin://oscilloscope") {
assert(desc.type() == pb::NodeDescription::PROCESSOR);
return new ProcessorOscilloscope(realm_name, node_id, host_system, desc);
} else if (desc.processor().type() == "builtin://vumeter") {
assert(desc.type() == pb::NodeDescription::PROCESSOR);
return new ProcessorVUMeter(realm_name, node_id, host_system, desc);
}
return ERROR_STATUS("Invalid processor type %s", desc.processor().type().c_str());

12
noisicaa/builtin_nodes/score_track/track_ui.py

@ -195,18 +195,6 @@ class ScoreToolBase(measured_track_editor.MeasuredToolBase):
self._updateGhost(measure, 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)
super().mouseMoveMeasureEvent(measure, evt)

2
noisicaa/builtin_nodes/ui_registry.py

@ -45,6 +45,7 @@ from .metronome.node_ui import MetronomeNode
from .midi_velocity_mapper.node_ui import MidiVelocityMapperNode
from .cv_mapper.node_ui import CVMapperNode
from .oscilloscope.node_ui import OscilloscopeNode
from .vumeter.node_ui import VUMeterNode
if typing.TYPE_CHECKING:
from noisicaa.ui.graph import base_node
@ -69,6 +70,7 @@ node_ui_cls_map = {
'builtin://midi-velocity-mapper': MidiVelocityMapperNode,
'builtin://cv-mapper': CVMapperNode,
'builtin://oscilloscope': OscilloscopeNode,
'builtin://vumeter': VUMeterNode,
} # type: Dict[str, Type[base_node.Node]]
track_editor_cls_map = {

21
noisicaa/builtin_nodes/vumeter/__init__.py

@ -0,0 +1,21 @@
#!/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

49
noisicaa/builtin_nodes/vumeter/node_description.py

@ -0,0 +1,49 @@
#!/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 noisicaa import node_db
VUMeterDescription = node_db.NodeDescription(
uri='builtin://vumeter',
display_name='VU Meter',
type=node_db.NodeDescription.PROCESSOR,
node_ui=node_db.NodeUIDescription(
type='builtin://vumeter',
),
builtin_icon='node-type-builtin',
processor=node_db.ProcessorDescription(
type='builtin://vumeter',
),
ports=[
node_db.PortDescription(
name='in:left',
direction=node_db.PortDescription.INPUT,
types=[node_db.PortDescription.AUDIO],
),
node_db.PortDescription(
name='in:right',
direction=node_db.PortDescription.INPUT,
types=[node_db.PortDescription.AUDIO],
),
]
)

94
noisicaa/builtin_nodes/vumeter/node_ui.py

@ -0,0 +1,94 @@
#!/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
import logging
from typing import Any, Dict
from PyQt5.QtCore import Qt
from PyQt5 import QtCore
from PyQt5 import QtGui
from PyQt5 import QtWidgets
from noisicaa import core
from noisicaa import music
from noisicaa.ui import ui_base
from noisicaa.ui import vumeter
from noisicaa.ui.graph import base_node
logger = logging.getLogger(__name__)
class VUMeterNodeWidget(ui_base.ProjectMixin, core.AutoCleanupMixin, QtWidgets.QWidget):
def __init__(self, node: music.BaseNode, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.__node = node
self.__vu_meter = vumeter.VUMeter(self)
self.__meter_urid = self.app.urid_mapper.map(
'http://noisicaa.odahoda.de/lv2/processor_vumeter#meter')
listener = self.audioproc_client.node_messages.add(
'%016x' % self.__node.id, self.__nodeMessage)
self.add_cleanup_function(listener.remove)
self.setMinimumSize(QtCore.QSize(10, 10))
self.__current_orientation = None # type: Qt.Orientation
layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.__vu_meter)
self.setLayout(layout)
def __nodeMessage(self, msg: Dict[str, Any]) -> None:
meter = 'http://noisicaa.odahoda.de/lv2/processor_vumeter#meter'
if meter in msg:
current_left, peak_left, current_right, peak_right = msg[meter]
self.__vu_meter.setLeftValue(current_left)
self.__vu_meter.setLeftPeak(peak_left)
self.__vu_meter.setRightValue(current_right)
self.__vu_meter.setRightPeak(peak_right)
def resizeEvent(self, evt: QtGui.QResizeEvent) -> None:
super().resizeEvent(evt)
w, h = self.width(), self.height()
if w > h:
orientation = Qt.Horizontal
else:
orientation = Qt.Vertical
if orientation == self.__current_orientation:
return
self.__vu_meter.setOrientation(orientation)
self.__current_orientation = orientation
class VUMeterNode(base_node.Node):
def createBodyWidget(self) -> QtWidgets.QWidget:
widget = VUMeterNodeWidget(node=self.node(), context=self.context)
self.add_cleanup_function(widget.cleanup)
return widget

137
noisicaa/builtin_nodes/vumeter/processor.cpp

@ -0,0 +1,137 @@
/*
* @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
*/
#include <math.h>
#include "lv2/lv2plug.in/ns/ext/atom/forge.h"
#include "noisicaa/host_system/host_system.h"
#include "noisicaa/audioproc/public/processor_message.pb.h"
#include "noisicaa/audioproc/engine/misc.h"
#include "noisicaa/audioproc/engine/message_queue.h"
#include "noisicaa/builtin_nodes/vumeter/processor.h"
namespace noisicaa {
static const float min_db = -70.0f;
static const float max_db = 20.0f;
ProcessorVUMeter::ProcessorVUMeter(
const string& realm_name, const string& node_id, HostSystem *host_system,
const pb::NodeDescription& desc)
: Processor(
realm_name, node_id, "noisicaa.audioproc.engine.processor.vumeter", host_system, desc) {}
Status ProcessorVUMeter::setup_internal() {
RETURN_IF_ERROR(Processor::setup_internal());
_meter_urid = _host_system->lv2->map(
"http://noisicaa.odahoda.de/lv2/processor_vumeter#meter");
_window_size = min(
(uint32_t)(0.05 * _host_system->sample_rate()), // 50ms
_host_system->sample_rate());
_history_pos = 0;
_peak_decay = 20 / (0.4 * _host_system->sample_rate());
for (int ch = 0 ; ch < 2 ; ++ch) {
_history[ch].reset(new float[_window_size]);
for (uint32_t i = 0 ; i < _window_size ; ++i) {
_history[ch].get()[i] = min_db;
}
_peak_hold[ch] = 0;
_peak[ch] = min_db;
}
return Status::Ok();
}
void ProcessorVUMeter::cleanup_internal() {
for (int ch = 0 ; ch < 2 ; ++ch) {
_history[ch].reset();
}
Processor::cleanup_internal();
}
Status ProcessorVUMeter::process_block_internal(BlockContext* ctxt, TimeMapper* time_mapper) {
static const int LEFT = 0;
static const int RIGHT = 1;
float* buf[2] = {
(float*)_buffers[LEFT]->data(),
(float*)_buffers[RIGHT]->data()
};
for (uint32_t i = 0 ; i < _host_system->block_size() ; ++i) {
for (int ch = 0 ; ch < 2 ; ++ch) {
float value = logf(fabsf(*(buf[ch]))) / 0.11512925f;
value = max(min_db, min(value, max_db));
_history[ch].get()[_history_pos] = value;
if (value > _peak[ch]) {
_peak_hold[ch] = int(0.5 * _host_system->sample_rate());
_peak[ch] = value;
} else if (_peak_hold[ch] == 0) {
_peak[ch] = max(min_db, _peak[ch] - _peak_decay);
} else {
--_peak_hold[ch];
}
++buf[ch];
}
_history_pos = (_history_pos + 1) % _window_size;
}
float current[2] = { min_db, min_db };
for (uint32_t i = 0 ; i < _window_size ; ++i) {
for (int ch = 0 ; ch < 2 ; ++ch) {
current[ch] = max(current[ch], _history[ch].get()[i]);
}
}
uint8_t atom[200];
LV2_Atom_Forge forge;
lv2_atom_forge_init(&forge, &_host_system->lv2->urid_map);
lv2_atom_forge_set_buffer(&forge, atom, sizeof(atom));
LV2_Atom_Forge_Frame oframe;
lv2_atom_forge_object(&forge, &oframe, _host_system->lv2->urid.core_nodemsg, 0);
lv2_atom_forge_key(&forge, _meter_urid);
LV2_Atom_Forge_Frame tframe;
lv2_atom_forge_tuple(&forge, &tframe);
for (int ch = 0 ; ch < 2 ; ++ch) {
lv2_atom_forge_float(&forge, current[ch]);
lv2_atom_forge_float(&forge, _peak[ch]);
}
lv2_atom_forge_pop(&forge, &tframe);
lv2_atom_forge_pop(&forge, &oframe);
NodeMessage::push(ctxt->out_messages, _node_id, (LV2_Atom*)atom);
return Status::Ok();
}
}

66
noisicaa/builtin_nodes/vumeter/processor.h

@ -0,0 +1,66 @@
// -*- mode: c++ -*-
/*
* @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
*/
#ifndef _NOISICAA_BUILTIN_NODES_VUMETER_PROCESSOR_H
#define _NOISICAA_BUILTIN_NODES_VUMETER_PROCESSOR_H
#include <memory>
#include "lv2/lv2plug.in/ns/ext/urid/urid.h"
#include "noisicaa/core/status.h"
#include "noisicaa/audioproc/engine/processor.h"
namespace noisicaa {
using namespace std;
class HostSystem;
class ProcessorVUMeter : public Processor {
public:
ProcessorVUMeter(
const string& realm_name, const string& node_id, HostSystem* host_system,
const pb::NodeDescription& desc);
protected:
Status setup_internal() override;
void cleanup_internal() override;
Status process_block_internal(BlockContext* ctxt, TimeMapper* time_mapper) override;
private:
LV2_URID _meter_urid;
size_t _window_size;
uint32_t _history_pos;
unique_ptr<float> _history[2];
unique_ptr<float> _history_right;
float _peak_decay;
uint32_t _peak_hold[2];
float _peak[2];
};
} // namespace noisicaa
#endif

33
noisicaa/builtin_nodes/vumeter/processor_test.py

@ -0,0 +1,33 @@
# @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 noisidev import unittest_processor_mixins
class ProcessorVUMeterTest(
unittest_processor_mixins.ProcessorTestMixin,
unittest.TestCase):
def test_process_block(self):
self.node_description = self.node_db['builtin://vumeter']
self.create_processor()
self.fill_buffer('in:left', 1.0)
self.fill_buffer('in:right', -1.0)
self.process_block()

39
noisicaa/builtin_nodes/vumeter/wscript

@ -0,0 +1,39 @@
# -*- mode: python -*-
# @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
def build(ctx):
ctx.py_module('__init__.py')
ctx.py_module('node_description.py')
ctx.py_module('node_ui.py')
ctx.py_test('processor_test.py')
ctx.shlib(
target='noisicaa-builtin_nodes-vumeter-processor',
features=['cxxshlib'],
source=[
ctx.cpp_module('processor.cpp'),
],
use=[
'noisicaa-audioproc-public',
'noisicaa-host_system',
]
)

2
noisicaa/builtin_nodes/wscript

@ -69,6 +69,7 @@ def build(ctx):
'noisicaa-builtin_nodes-midi_velocity_mapper-processor',
'noisicaa-builtin_nodes-cv_mapper-processor',
'noisicaa-builtin_nodes-oscilloscope-processor',
'noisicaa-builtin_nodes-vumeter-processor',
],
)
@ -93,3 +94,4 @@ def build(ctx):
ctx.recurse('midi_velocity_mapper')
ctx.recurse('cv_mapper')
ctx.recurse('oscilloscope')
ctx.recurse('vumeter')

2
noisicaa/ui/editor_app_test.py

@ -58,7 +58,7 @@ class EditorAppTest(unittest_mixins.ProcessManagerMixin, qttest.QtTestCase):
os.makedirs(self.music_dir)
self.setup_node_db_process(inline=True)
self.setup_urid_mapper_process(inline=True)
self.setup_urid_mapper_process(inline=False)
self.setup_instrument_db_process(inline=True)
self.setup_audioproc_process(inline=True)
self.setup_writer_process(inline=True)

207
noisicaa/ui/editor_window.py

@ -23,7 +23,6 @@
import asyncio
import contextlib
import logging
import os.path
import time
import traceback
import typing
@ -34,7 +33,6 @@ from PyQt5 import QtCore
from PyQt5 import QtGui
from PyQt5 import QtWidgets
from noisicaa import constants
from noisicaa.core import storage
from noisicaa import audioproc
from . import project_view
@ -42,8 +40,8 @@ from . import project_debugger
from . import ui_base
from . import qprogressindicator
from . import project_registry as project_registry_lib
from . import load_history
from . import open_project_dialog
from . import engine_state as engine_state_lib
if typing.TYPE_CHECKING:
from noisicaa import core
@ -87,11 +85,18 @@ class SetupProgressWidget(QtWidgets.QWidget):
class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget):
currentPageChanged = QtCore.pyqtSignal(QtWidgets.QWidget)
hasProjectView = QtCore.pyqtSignal(bool)
def __init__(self, parent: QtWidgets.QTabWidget, **kwargs: Any) -> None:
def __init__(
self, *,
parent: QtWidgets.QTabWidget,
engine_state: engine_state_lib.EngineState,
**kwargs: Any
) -> None:
super().__init__(parent=parent, **kwargs)
self.__tab_widget = parent
self.__engine_state = engine_state
self.__page = None # type: QtWidgets.QWidget
self.__page_cleanup_func = None # type: Callable[[], None]
@ -106,6 +111,11 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget):
def projectView(self) -> Optional[project_view.ProjectView]:
return self.__project_view
def setProjectView(self, name: str, view: project_view.ProjectView) -> None:
self.__project_view = view
self.__setPage(name, view)
self.hasProjectView.emit(True)
def projectDebugger(self) -> Optional[project_debugger.ProjectDebugger]:
return self.__project_debugger
@ -194,11 +204,13 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget):
exc, "Failed to open project \"%s\"." % project.name)
else:
view = project_view.ProjectView(project_connection=project, context=self.context)
view = project_view.ProjectView(
project_connection=project,
engine_state=self.__engine_state,
context=self.context)
view.setObjectName('project-view')
await view.setup()
self.__project_view = view
self.__setPage(project.name, view)
self.setProjectView(project.name, view)
async def createProject(self, path: str) -> None:
project = project_registry_lib.Project(
@ -214,11 +226,13 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget):
else:
await self.app.project_registry.refresh()
view = project_view.ProjectView(project_connection=project, context=self.context)
view = project_view.ProjectView(
project_connection=project,
engine_state=self.__engine_state,
context=self.context)
view.setObjectName('project-view')
await view.setup()
self.__project_view = view
self.__setPage(project.name, view)
self.setProjectView(project.name, view)
async def createLoadtestProject(self, path: str, spec: Dict[str, Any]) -> None:
project = project_registry_lib.Project(
@ -234,11 +248,13 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget):
else:
await self.app.project_registry.refresh()
view = project_view.ProjectView(project_connection=project, context=self.context)
view = project_view.ProjectView(
project_connection=project,
engine_state=self.__engine_state,
context=self.context)
view.setObjectName('project-view')
await view.setup()
self.__project_view = view
self.__setPage(project.name, view)
self.setProjectView(project.name, view)
async def __debugProject(self, project: project_registry_lib.Project) -> None:
self.showLoadSpinner(project.name, "Loading project \"%s\"..." % project.name)
@ -279,6 +295,7 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget):
await self.__project_view.cleanup()
self.__project_view = None
await project.close()
self.hasProjectView.emit(False)
self.showOpenDialog()
async def closeDebugger(self) -> None:
@ -291,19 +308,12 @@ class ProjectTabPage(ui_base.CommonMixin, QtWidgets.QWidget):
class EditorWindow(ui_base.CommonMixin, QtWidgets.QMainWindow):
# Could not figure out how to define a signal that takes either an instance
# of a specific class or None.
currentProjectChanged = QtCore.pyqtSignal(object)
playingChanged = QtCore.pyqtSignal(bool)
loopEnabledChanged = QtCore.pyqtSignal(bool)
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.__engine_state = engine_state_lib.EngineState(self)
self.__engine_state_listener = None # type: core.Listener[audioproc.EngineStateChange]
self.__current_project_view = None # type: Optional[project_view.ProjectView]
self.setWindowTitle("noisicaƤ")
self.resize(1200, 800)
@ -312,11 +322,6 @@ class EditorWindow(ui_base.CommonMixin, QtWidgets.QMainWindow):
self.createActions()
self.createMenus()
self.createToolBar()
self.createStatusBar()
self.playingChanged.connect(self.onPlayingChanged)
self.loopEnabledChanged.connect(self.onLoopEnabledChanged)
self.__project_tabs = QtWidgets.QTabWidget(self)
self.__project_tabs.setObjectName('project-tabs')
@ -327,7 +332,6 @@ class EditorWindow(ui_base.CommonMixin, QtWidgets.QMainWindow):
self.__project_tabs.setDocumentMode(True)
self.__project_tabs.tabCloseRequested.connect(
lambda idx: self.call_async(self.onCloseProjectTab(idx)))
self.__project_tabs.currentChanged.connect(self.onCurrentProjectTabChanged)
self.__main_layout = QtWidgets.QVBoxLayout()
self.__main_layout.setContentsMargins(0, 0, 0, 0)
@ -369,7 +373,7 @@ class EditorWindow(ui_base.CommonMixin, QtWidgets.QMainWindow):
def audioprocReady(self) -> None:
self.__engine_state_listener = self.audioproc_client.engine_state_changed.add(
self.__engineStateChanged)
self.__engine_state.updateState)
def createSetupProgress(self) -> SetupProgressWidget:
assert self.__setup_progress is None
@ -405,7 +409,10 @@ class EditorWindow(ui_base.CommonMixin, QtWidgets.QMainWindow):
self.__setup_progress_fade_task = None
def addProjectTab(self) -> ProjectTabPage:
page = ProjectTabPage(parent=self.__project_tabs, context=self.context)
page = ProjectTabPage(
parent=self.__project_tabs,
engine_state=self.__engine_state,
context=self.context)
idx = self.__project_tabs.addTab(page, '')
self.__project_tabs.setCurrentIndex(idx)
return page
@ -439,47 +446,6 @@ class EditorWindow(ui_base.CommonMixin, QtWidgets.QMainWindow):
self._set_bpm_action.setStatusTip("Set the project's beats per second")
self._set_bpm_action.triggered.connect(self.onSetBPM)
self._player_move_to_start_action = QtWidgets.QAction("Move to start", self)
self._player_move_to_start_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'media-skip-backward.svg')))
self._player_move_to_start_action.setShortcut(QtGui.QKeySequence('Home'))
self._player_move_to_start_action.setShortcutContext(Qt.ApplicationShortcut)
self._player_move_to_start_action.triggered.connect(lambda: self.onPlayerMoveTo('start'))
self._player_move_to_end_action = QtWidgets.QAction("Move to end", self)
self._player_move_to_end_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'media-skip-forward.svg')))
self._player_move_to_end_action.setShortcut(QtGui.QKeySequence('End'))
self._player_move_to_end_action.setShortcutContext(Qt.ApplicationShortcut)
self._player_move_to_end_action.triggered.connect(lambda: self.onPlayerMoveTo('end'))
self._player_move_to_prev_action = QtWidgets.QAction("Move to previous measure", self)
self._player_move_to_prev_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'media-seek-backward.svg')))
self._player_move_to_prev_action.setShortcut(QtGui.QKeySequence('PgUp'))
self._player_move_to_prev_action.setShortcutContext(Qt.ApplicationShortcut)
self._player_move_to_prev_action.triggered.connect(lambda: self.onPlayerMoveTo('prev'))
self._player_move_to_next_action = QtWidgets.QAction("Move to next measure", self)
self._player_move_to_next_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'media-seek-forward.svg')))
self._player_move_to_next_action.setShortcut(QtGui.QKeySequence('PgDown'))
self._player_move_to_next_action.setShortcutContext(Qt.ApplicationShortcut)
self._player_move_to_next_action.triggered.connect(lambda: self.onPlayerMoveTo('next'))
self._player_toggle_action = QtWidgets.QAction("Play", self)
self._player_toggle_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'media-playback-start.svg')))
self._player_toggle_action.setShortcut(QtGui.QKeySequence('Space'))
self._player_toggle_action.setShortcutContext(Qt.ApplicationShortcut)
self._player_toggle_action.triggered.connect(self.onPlayerToggle)
self._player_loop_action = QtWidgets.QAction("Loop playback", self)
self._player_loop_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'media-playlist-repeat.svg')))
self._player_loop_action.setCheckable(True)
self._player_loop_action.toggled.connect(self.onPlayerLoop)
def createMenus(self) -> None:
menu_bar = self.menuBar()
@ -527,87 +493,16 @@ class EditorWindow(ui_base.CommonMixin, QtWidgets.QMainWindow):
self._help_menu.addAction(self.app.about_action)
self._help_menu.addAction(self.app.aboutqt_action)
def createToolBar(self) -> None:
self.toolbar = QtWidgets.QToolBar()
self.toolbar.setObjectName('toolbar:main')
self.toolbar.addAction(self._player_toggle_action)
self.toolbar.addAction(self._player_loop_action)
self.toolbar.addSeparator()
self.toolbar.addAction(self._player_move_to_start_action)
#self.toolbar.addAction(self._player_move_to_prev_action)
#self.toolbar.addAction(self._player_move_to_next_action)
self.toolbar.addAction(self._player_move_to_end_action)
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
def createStatusBar(self) -> None:
self.statusbar = QtWidgets.QStatusBar()
self.pipeline_load = load_history.LoadHistoryWidget(100, 30)
self.pipeline_load.setToolTip("Load of the playback engine.")
self.statusbar.addPermanentWidget(self.pipeline_load)
self.pipeline_status = QtWidgets.QLabel()
self.statusbar.addPermanentWidget(self.pipeline_status)
self.setStatusBar(self.statusbar)
def storeState(self) -> None:
logger.info("Saving current EditorWindow geometry.")
self.app.settings.setValue('mainwindow/geometry', self.saveGeometry())
self.app.settings.setValue('mainwindow/state', self.saveState())
def __engineStateChanged(self, engine_state: audioproc.EngineStateChange) -> None:
show_status, show_load = False, False
if engine_state.state == audioproc.EngineStateChange.SETUP:
self.pipeline_status.setText("Starting engine...")
show_status = True
elif engine_state.state == audioproc.EngineStateChange.CLEANUP:
self.pipeline_status.setText("Stopping engine...")
show_status = True
elif engine_state.state == audioproc.EngineStateChange.RUNNING:
if engine_state.HasField('load'):
self.pipeline_load.addValue(engine_state.load)
show_load = True
else:
self.pipeline_status.setText("Engine running")
show_status = True
elif engine_state.state == audioproc.EngineStateChange.STOPPED:
self.pipeline_status.setText("Engine stopped")
show_status = True
self.pipeline_status.setVisible(show_status)
self.pipeline_load.setVisible(show_load)
def setInfoMessage(self, msg: str) -> None:
self.statusbar.showMessage(msg)
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
logger.info("CloseEvent received")
event.ignore()
self.call_async(self.app.deleteWindow(self))
def setCurrentProjectView(self, view: Optional[project_view.ProjectView]) -> None:
if view == self.__current_project_view:
return
if self.__current_project_view is not None:
self.__current_project_view.playingChanged.disconnect(self.playingChanged)
self.__current_project_view.loopEnabledChanged.disconnect(self.loopEnabledChanged)
if view is not None:
view.playingChanged.connect(self.playingChanged)
self.playingChanged.emit(view.playing())
view.loopEnabledChanged.connect(self.loopEnabledChanged)
self.loopEnabledChanged.emit(view.loopEnabled())
self.__current_project_view = view
if view is not None:
self.currentProjectChanged.emit(view.project)
else:
self.currentProjectChanged.emit(None)
def onCloseCurrentProject(self) -> None:
idx = self.__project_tabs.currentIndex()
tab = cast(ProjectTabPage, self.__project_tabs.widget(idx))
@ -618,10 +513,6 @@ class EditorWindow(ui_base.CommonMixin, QtWidgets.QMainWindow):
if self.__project_tabs.count() > 1:
self.__project_tabs.removeTab(idx)
def onCurrentProjectTabChanged(self, idx: int) -> None:
tab = cast(ProjectTabPage, self.__project_tabs.widget(idx))
self.setCurrentProjectView(tab.projectView() if tab is not None else None)
async def onCloseProjectTab(self, idx: int) -> None:
tab = cast(ProjectTabPage, self.__project_tabs.widget(idx))
if tab.projectView() is not None:
@ -658,29 +549,3 @@ class EditorWindow(ui_base.CommonMixin, QtWidgets.QMainWindow):
view = self.getCurrentProjectView()
if view is not None:
view.onSetBPM()
def onPlayingChanged(self, playing: bool) -> None:
if playing:
self._player_toggle_action.setIcon(
QtGui.QIcon(os.path.join(constants.DATA_DIR, 'icons', 'media-playback-pause.svg')))
else:
self._player_toggle_action.setIcon(
QtGui.QIcon(os.path.join(constants.DATA_DIR, 'icons', 'media-playback-start.svg')))
def onLoopEnabledChanged(self, loop_enabled: bool) -> None:
self._player_loop_action.setChecked(loop_enabled)
def onPlayerMoveTo(self, where: str) -> None:
view = self.getCurrentProjectView()
if view is not None:
view.onPlayerMoveTo(where)
def onPlayerToggle(self) -> None:
view = self.getCurrentProjectView()
if view is not None:
view.onPlayerToggle()
def onPlayerLoop(self, loop: bool) -> None:
view = self.getCurrentProjectView()
if view is not None:
view.onPlayerLoop(loop)

137
noisicaa/ui/engine_state.py

@ -0,0 +1,137 @@
#!/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
import enum
import logging
import math
import typing
from typing import Any, List
from PyQt5.QtCore import Qt
from PyQt5 import QtCore
from PyQt5 import QtGui
from PyQt5 import QtWidgets
from noisicaa import audioproc
from . import slots
if typing.TYPE_CHECKING:
from noisicaa import core
logger = logging.getLogger(__name__)
class EngineState(slots.SlotContainer, QtCore.QObject):
class State(enum.IntEnum):
Setup = 0
Cleanup = 1
Running = 2
Stopped = 3
state, setState, stateChanged = slots.slot(
State, 'state', default=State.Stopped)
currentLoad, setCurrentLoad, currentLoadChanged = slots.slot(
float, 'currentLoad', default=0.0)
loadHistoryChanged = QtCore.pyqtSignal()
HISTORY_LENGTH = 1000
def __init__(self, parent: QtCore.QObject, **kwargs: Any) -> None:
super().__init__(parent=parent, **kwargs)
self.__history = [None] * self.HISTORY_LENGTH # type: List[float]
self.__latest_values = [] # type: List[float]
self.currentLoadChanged.connect(self.__latest_values.append)
self.__timer = QtCore.QTimer(self)
self.__timer.setInterval(1000 // 25)
self.__timer.timeout.connect(self.__updateHistory)
self.__timer.start()
def updateState(self, msg: audioproc.EngineStateChange) -> None:
self.setState({
audioproc.EngineStateChange.SETUP: self.State.Setup,
audioproc.EngineStateChange.CLEANUP: self.State.Cleanup,
audioproc.EngineStateChange.RUNNING: self.State.Running,
audioproc.EngineStateChange.STOPPED: self.State.Stopped,
}[msg.state])
if msg.state == audioproc.EngineStateChange.RUNNING and msg.HasField('load'):
self.setCurrentLoad(msg.load)
def loadHistory(self, num_ticks: int) -> List[float]:
num_ticks = min(num_ticks, self.HISTORY_LENGTH)
return self.__history[-num_ticks:]
def __updateHistory(self) -> None:
if self.__latest_values:
self.__history.append(max(self.__latest_values))
self.__latest_values.clear()
elif self.state() == self.State.Running:
self.__history.append(self.currentLoad())
else:
self.__history.append(None)
if len(self.__history) > self.HISTORY_LENGTH:
del self.__history[:-self.HISTORY_LENGTH]
self.loadHistoryChanged.emit()
class LoadHistory(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget, engine_state: EngineState) -> None:
super().__init__(parent)
self.__font = QtGui.QFont(self.font())
self.__font.setPixelSize(12)
self.__engine_state = engine_state
self.__engine_state.loadHistoryChanged.connect(self.update)
def paintEvent(self, evt: QtGui.QPaintEvent) -> None:
painter = QtGui.QPainter(self)
painter.fillRect(self.rect(), QtGui.QColor(0, 0, 0))
history = self.__engine_state.loadHistory(max(25, self.width() // 2))
x = self.width() - 2
for value in reversed(history):
if value is not None:
value = max(0.0, min(value, 1.0))
vh = int(self.height() * value)
painter.fillRect(
x, self.height() - vh, 2, vh,
QtGui.QColor(int(255 * value), 255 - int(255 * value), 0))
x -= 2
if self.width() > 50 and self.height() > 16:
last_second = [v for v in history[-25:] if v is not None]
if len(last_second) > 5:
avg = sum(last_second) / len(last_second)
stddev = math.sqrt(sum((v - avg) ** 2 for v in last_second) / len(last_second))
painter.setPen(Qt.white)
painter.setFont(self.__font)
painter.drawText(
4, 1, self.width() - 4, self.height() - 1,
Qt.AlignTop,
"%d\u00b1%d%%" % (100 * avg, 100 * stddev))
return super().paintEvent(evt)

79
noisicaa/ui/load_history.py

@ -1,79 +0,0 @@
#!/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
import math
from typing import List
from PyQt5.QtCore import Qt
from PyQt5 import QtWidgets, QtGui
class LoadHistoryWidget(QtWidgets.QWidget):
def __init__(self, width, height):
super().__init__()
self.__width = width
self.__height = height
self.setFixedSize(self.__width, self.__height)
self.__pixmap = QtGui.QPixmap(self.__width, self.__height)
self.__pixmap.fill(Qt.black)
self.__history = [] # type: List[float]
self.__font = QtGui.QFont("Helvetica")
self.__font.setPixelSize(12)
def paintEvent(self, event):
painter = QtGui.QPainter(self)
painter.drawPixmap(0, 0, self.__pixmap)
if len(self.__history) > 5:
avg = sum(self.__history) / len(self.__history)
stddev = math.sqrt(sum((v - avg) ** 2 for v in self.__history) / len(self.__history))
painter.setPen(Qt.white)
painter.setFont(self.__font)
painter.drawText(
4, 1, self.__width - 4, self.__height - 1,
Qt.AlignTop,
"%d\u00b1%d%%" % (avg, stddev))
return super().paintEvent(event)
def addValue(self, value):
value = max(0, min(value, 1))
vh = int(self.__height * value)
self.__pixmap.scroll(-2, 0, 0, 0, self.__width, self.__height)
painter = QtGui.QPainter(self.__pixmap)
painter.setPen(Qt.NoPen)
painter.setBrush(Qt.black)
painter.drawRect(self.__width - 2, 0, 2, self.__height)
painter.setBrush(QtGui.QColor(int(255 * value), 255 - int(255 * value), 0))
painter.drawRect(self.__width - 2, self.__height - vh, 2, vh)
self.__history.append(100 * value)
if len(self.__history) > 50:
del self.__history[:-50]
self.update()

159
noisicaa/ui/player_state.py

@ -20,12 +20,18 @@
#
# @end:license
import enum
import logging
import os.path
import time as time_lib
from typing import Any
from PyQt5.QtCore import Qt
from PyQt5 import QtCore
from PyQt5 import QtGui
from PyQt5 import QtWidgets
from noisicaa import constants
from noisicaa import audioproc
from noisicaa.audioproc.public import musical_time_pb2
from . import ui_base
@ -33,6 +39,18 @@ from . import ui_base
logger = logging.getLogger(__name__)
class TimeMode(enum.Enum):
Follow = 0
Manual = 1
class MoveTo(enum.Enum):
Start = 0
End = 1
PrevBeat = 2
NextBeat = 3
class PlayerState(ui_base.ProjectMixin, QtCore.QObject):
playingChanged = QtCore.pyqtSignal(bool)
currentTimeChanged = QtCore.pyqtSignal(object)
@ -46,6 +64,8 @@ class PlayerState(ui_base.ProjectMixin, QtCore.QObject):
self.__session_prefix = 'player_state:%s:' % self.project.id
self.__last_current_time_update = None # type: float
self.__time_mode = TimeMode.Follow
self.__playing = False
self.__current_time = self.__get_session_value('current_time', audioproc.MusicalTime())
self.__loop_start_time = self.__get_session_value('loop_start_time', None)
@ -54,12 +74,71 @@ class PlayerState(ui_base.ProjectMixin, QtCore.QObject):
self.__player_id = None # type: str
self.__move_to_start_action = QtWidgets.QAction("Move to start", self)
self.__move_to_start_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'media-skip-backward.svg')))
self.__move_to_start_action.setShortcut(QtGui.QKeySequence('Home'))
self.__move_to_start_action.setShortcutContext(Qt.ApplicationShortcut)
self.__move_to_start_action.triggered.connect(lambda: self.__onMoveTo(MoveTo.Start))
self.__move_to_end_action = QtWidgets.QAction("Move to end", self)
self.__move_to_end_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'media-skip-forward.svg')))
self.__move_to_end_action.setShortcut(QtGui.QKeySequence('End'))
self.__move_to_end_action.setShortcutContext(Qt.ApplicationShortcut)
self.__move_to_end_action.triggered.connect(lambda: self.__onMoveTo(MoveTo.End))
self.__move_to_prev_action = QtWidgets.QAction("Move to previous beat", self)
self.__move_to_prev_action.setIcon(QtGui.QIcon(
os.path.join(constants.DATA_DIR, 'icons', 'media-seek-backward.svg')))
self.__move_to_prev_action.setShortcut(QtGui.QKeySequence('PgUp'))
self.__move_to_prev_action.setShortcutContext(Qt.ApplicationShortcut)
self.__move_to_prev_action.triggered.connect(lambda: self.__onMoveTo(MoveTo.PrevBeat))
self.__move_to_next_action = QtWidgets.QAction("Move to next beat", self)