Browse Source

MidiCCtoCV: add MIDI learn mode.

builtin-nodes
Ben Niemann 3 years ago
parent
commit
0913aef84e
  1. 3
      3rdparty/typeshed/PyQt5/QtCore.pyi
  2. 1
      noisicaa/builtin_nodes/CMakeLists.txt
  3. 7
      noisicaa/builtin_nodes/midi_cc_to_cv/CMakeLists.txt
  4. 141
      noisicaa/builtin_nodes/midi_cc_to_cv/node_ui.py
  5. 15
      noisicaa/builtin_nodes/midi_cc_to_cv/node_ui_test.py
  6. 51
      noisicaa/builtin_nodes/midi_cc_to_cv/processor.cpp
  7. 4
      noisicaa/builtin_nodes/midi_cc_to_cv/processor.h
  8. 29
      noisicaa/builtin_nodes/midi_cc_to_cv/processor_messages.proto
  9. 34
      noisicaa/builtin_nodes/midi_cc_to_cv/processor_messages.py
  10. 3
      noisicaa/builtin_nodes/midi_source/processor.h
  11. 4
      noisicaa/builtin_nodes/processor_message_registry.proto

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

@ -7620,6 +7620,7 @@ class QTimeLine(QObject):
class QTimer(QObject):
timeout = ... # type: PYQT_SIGNAL
def __init__(self, parent: typing.Optional[QObject] = ...) -> None: ...
@ -7627,7 +7628,7 @@ class QTimer(QObject):
def timerType(self) -> Qt.TimerType: ...
def setTimerType(self, atype: Qt.TimerType) -> None: ...
def timerEvent(self, a0: QTimerEvent) -> None: ...
def timeout(self) -> None: ...
#def timeout(self) -> None: ...
def stop(self) -> None: ...
@typing.overload
def start(self, msec: int) -> None: ...

1
noisicaa/builtin_nodes/CMakeLists.txt

@ -37,6 +37,7 @@ target_link_libraries(noisicaa-builtin_nodes-processor_message_registry PRIVATE
target_link_libraries(noisicaa-builtin_nodes-processor_message_registry PRIVATE noisicaa-builtin_nodes-instrument-processor_messages)
target_link_libraries(noisicaa-builtin_nodes-processor_message_registry PRIVATE noisicaa-builtin_nodes-pianoroll-processor_messages)
target_link_libraries(noisicaa-builtin_nodes-processor_message_registry PRIVATE noisicaa-builtin_nodes-midi_source-processor_messages)
target_link_libraries(noisicaa-builtin_nodes-processor_message_registry PRIVATE noisicaa-builtin_nodes-midi_cc_to_cv-processor_messages)
add_library(noisicaa-builtin_nodes-processors SHARED processor_registry.cpp)
target_link_libraries(noisicaa-builtin_nodes-processors PRIVATE noisicaa-builtin_nodes-control_track-processor)

7
noisicaa/builtin_nodes/midi_cc_to_cv/CMakeLists.txt

@ -29,6 +29,7 @@ add_python_package(
node_ui.py
node_ui_test.py
processor_test.py
processor_messages.py
)
py_proto(model.proto)
@ -36,9 +37,15 @@ cpp_proto(model.proto)
py_proto(commands.proto)
py_proto(processor.proto)
cpp_proto(processor.proto)
py_proto(processor_messages.proto)
cpp_proto(processor_messages.proto)
add_library(noisicaa-builtin_nodes-midi_cc_to_cv-processor_messages SHARED processor_messages.pb.cc)
target_link_libraries(noisicaa-builtin_nodes-midi_cc_to_cv-processor_messages PRIVATE noisicaa-audioproc-public)
add_library(noisicaa-builtin_nodes-midi_cc_to_cv-processor SHARED processor.cpp processor.pb.cc)
target_compile_options(noisicaa-builtin_nodes-midi_cc_to_cv-processor PRIVATE -fPIC -std=c++11 -Wall -Werror -pedantic -DHAVE_PTHREAD_SPIN_LOCK)
target_link_libraries(noisicaa-builtin_nodes-midi_cc_to_cv-processor PRIVATE noisicaa-audioproc-public)
target_link_libraries(noisicaa-builtin_nodes-midi_cc_to_cv-processor PRIVATE noisicaa-host_system)
target_link_libraries(noisicaa-builtin_nodes-midi_cc_to_cv-processor PRIVATE noisicaa-builtin_nodes-processor_message_registry)
target_link_libraries(noisicaa-builtin_nodes-midi_cc_to_cv-processor PRIVATE noisicaa-builtin_nodes-midi_cc_to_cv-processor_messages)

141
noisicaa/builtin_nodes/midi_cc_to_cv/node_ui.py

@ -22,7 +22,7 @@
import logging
import math
from typing import Any, Dict, List
from typing import cast, Any, Dict, List
from PyQt5.QtCore import Qt
from PyQt5 import QtCore
@ -36,6 +36,7 @@ from noisicaa.ui import ui_base
from noisicaa.ui.graph import base_node
from . import client_impl
from . import commands
from . import processor_messages
logger = logging.getLogger(__name__)
@ -131,12 +132,50 @@ controller_names = {
}
class LearnButton(QtWidgets.QPushButton):
def __init__(self) -> None:
super().__init__()
self.setText("L")
self.setCheckable(True)
self.__default_bg = self.palette().color(QtGui.QPalette.Button)
self.__timer = QtCore.QTimer()
self.__timer.setInterval(250)
self.__timer.timeout.connect(self.__blink)
self.__blink_state = False
self.toggled.connect(self.__toggledChanged)
def __blink(self) -> None:
self.__blink_state = not self.__blink_state
palette = self.palette()
if self.__blink_state:
palette.setColor(self.backgroundRole(), QtGui.QColor(0, 255, 0))
else:
palette.setColor(self.backgroundRole(), self.__default_bg)
self.setPalette(palette)
def __toggledChanged(self, toggled: bool) -> None:
if toggled:
self.__timer.start()
else:
self.__timer.stop()
palette = self.palette()
palette.setColor(self.backgroundRole(), self.__default_bg)
self.setPalette(palette)
class ChannelUI(ui_base.ProjectMixin, QtCore.QObject):
def __init__(self, channel: client_impl.MidiCCtoCVChannel, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.__channel = channel
self.__listeners = [] # type: List[core.Listener]
self.__node = cast(client_impl.MidiCCtoCV, channel.parent)
self.__listeners = {} # type: Dict[str, core.Listener]
self.__learning = False
self.__midi_channel = QtWidgets.QComboBox()
self.__midi_channel.setObjectName('channel[%016x]:midi_channel' % channel.id)
@ -145,7 +184,8 @@ class ChannelUI(ui_base.ProjectMixin, QtCore.QObject):
if ch == self.__channel.midi_channel:
self.__midi_channel.setCurrentIndex(self.__midi_channel.count() - 1)
self.__midi_channel.currentIndexChanged.connect(self.__midiChannelEdited)
self.__listeners.append(self.__channel.midi_channel_changed.add(self.__midiChannelChanged))
self.__listeners['midi_channel'] = self.__channel.midi_channel_changed.add(
self.__midiChannelChanged)
self.__midi_controller = QtWidgets.QComboBox()
self.__midi_controller.setObjectName('channel[%016x]:midi_controller' % channel.id)
@ -158,8 +198,17 @@ class ChannelUI(ui_base.ProjectMixin, QtCore.QObject):
if ch == self.__channel.midi_controller:
self.__midi_controller.setCurrentIndex(self.__midi_controller.count() - 1)
self.__midi_controller.currentIndexChanged.connect(self.__midiControllerEdited)
self.__listeners.append(
self.__channel.midi_controller_changed.add(self.__midiControllerChanged))
self.__listeners['midi_controller'] = self.__channel.midi_controller_changed.add(
self.__midiControllerChanged)
self.__learn_timeout = QtCore.QTimer()
self.__learn_timeout.setInterval(5000)
self.__learn_timeout.setSingleShot(True)
self.__learn_timeout.timeout.connect(self.__learnStop)
self.__learn = LearnButton()
self.__learn.setObjectName('channel[%016x]:learn' % channel.id)
self.__learn.toggled.connect(self.__learnClicked)
self.__min_value = QtWidgets.QLineEdit()
self.__min_value.setObjectName('channel[%016x]:min_value' % channel.id)
@ -168,7 +217,7 @@ class ChannelUI(ui_base.ProjectMixin, QtCore.QObject):
self.__min_value.setValidator(min_value_validator)
self.__min_value.setText(fmt_value(self.__channel.min_value))
self.__min_value.editingFinished.connect(self.__minValueEdited)
self.__listeners.append(self.__channel.min_value_changed.add(self.__minValueChanged))
self.__listeners['min_value'] = self.__channel.min_value_changed.add(self.__minValueChanged)
self.__max_value = QtWidgets.QLineEdit()
self.__max_value.setObjectName('channel[%016x]:max_value' % channel.id)
@ -177,28 +226,84 @@ class ChannelUI(ui_base.ProjectMixin, QtCore.QObject):
self.__max_value.setValidator(max_value_validator)
self.__max_value.setText(fmt_value(self.__channel.max_value))
self.__max_value.editingFinished.connect(self.__maxValueEdited)
self.__listeners.append(self.__channel.max_value_changed.add(self.__maxValueChanged))
self.__listeners['max_value'] = self.__channel.max_value_changed.add(self.__maxValueChanged)
self.__log_scale = QtWidgets.QCheckBox()
self.__log_scale.setObjectName('channel[%016x]:log_scale' % channel.id)
self.__log_scale.setChecked(self.__channel.log_scale)
self.__log_scale.stateChanged.connect(self.__logScaleEdited)
self.__listeners.append(channel.log_scale_changed.add(self.__logScaleChanged))
self.__listeners['log_scale'] = channel.log_scale_changed.add(self.__logScaleChanged)
def addToLayout(self, layout: QtWidgets.QGridLayout, row: int) -> None:
layout.addWidget(self.__midi_channel, row, 0)
layout.addWidget(self.__midi_controller, row, 1)
layout.addWidget(self.__min_value, row, 2)
layout.addWidget(self.__max_value, row, 3)
layout.addWidget(self.__log_scale, row, 4)
layout.addWidget(self.__learn, row, 2)
layout.addWidget(self.__min_value, row, 3)
layout.addWidget(self.__max_value, row, 4)
layout.addWidget(self.__log_scale, row, 5)
def cleanup(self) -> None:
pass
self.__learnStop()
for listener in self.__listeners.values():
listener.remove()
self.__listeners.clear()
def __nodeMessage(self, msg: Dict[str, Any]) -> None:
learn_urid = 'http://noisicaa.odahoda.de/lv2/processor_cc_to_cv#learn'
if learn_urid in msg and self.__learning:
midi_channel, midi_controller = msg[learn_urid]
idx = self.__midi_channel.findData(midi_channel)
if idx >= 0:
self.__midi_channel.setCurrentIndex(idx)
else:
logger.error("MIDI channel %r not found.", midi_channel)
idx = self.__midi_controller.findData(midi_controller)
if idx >= 0:
self.__midi_controller.setCurrentIndex(idx)
else:
logger.error("MIDI controller %r not found.", midi_controller)
self.__learn_timeout.start()
def __learnStart(self) -> None:
if self.__learning:
return
self.__learning = True
self.__listeners['node-messages'] = self.app.node_messages.add(
'%016x' % self.__node.id, self.__nodeMessage)
self.call_async(self.project_view.sendNodeMessage(
processor_messages.learn(self.__node, True)))
self.__learn.setChecked(True)
self.__learn_timeout.start()
def __learnStop(self) -> None:
if not self.__learning:
return
self.__learning = False
self.__learn.setChecked(False)
self.__learn_timeout.stop()
self.call_async(self.project_view.sendNodeMessage(
processor_messages.learn(self.__node, False)))
self.__listeners.pop('node-messages').remove()
def __learnClicked(self, checked: bool) -> None:
if checked:
self.__learnStart()
else:
self.__learnStop()
def __midiChannelChanged(self, change: model.PropertyValueChange[int]) -> None:
idx = self.__midi_channel.findData(change.new_value)
if idx > 0:
if idx >= 0:
self.__midi_channel.setCurrentIndex(idx)
else:
logger.error("MIDI channel %r not found.", change.new_value)
def __midiChannelEdited(self) -> None:
value = self.__midi_channel.currentData()
@ -208,8 +313,10 @@ class ChannelUI(ui_base.ProjectMixin, QtCore.QObject):
def __midiControllerChanged(self, change: model.PropertyValueChange[int]) -> None:
idx = self.__midi_controller.findData(change.new_value)
if idx > 0:
if idx >= 0:
self.__midi_controller.setCurrentIndex(idx)
else:
logger.error("MIDI controller %r not found.", change.new_value)
def __midiControllerEdited(self) -> None:
value = self.__midi_controller.currentData()
@ -263,9 +370,6 @@ class MidiCCtoCVNodeWidget(ui_base.ProjectMixin, QtWidgets.QScrollArea):
self.__listeners['channels'] = self.__node.channels_changed.add(
self.__channelsChanged)
self.__listeners['node-messages'] = self.app.node_messages.add(
'%016x' % self.__node.id, self.__nodeMessage)
body = QtWidgets.QWidget(self)
body.setAutoFillBackground(False)
body.setAttribute(Qt.WA_NoSystemBackground, True)
@ -302,9 +406,6 @@ class MidiCCtoCVNodeWidget(ui_base.ProjectMixin, QtWidgets.QScrollArea):
channel.cleanup()
self.__channels.clear()
def __nodeMessage(self, msg: Dict[str, Any]) -> None:
pass
def __updateChannels(self) -> None:
clearLayout(self.__channel_layout)

15
noisicaa/builtin_nodes/midi_cc_to_cv/node_ui_test.py

@ -83,37 +83,42 @@ class MidiCCtoCVNodeWidgetTest(uitest.ProjectMixin, uitest.UITestCase):
assert editor is not None, editor_name
return editor
async def test_channel_set_midi_channel(self):
async def test_channel_midi_channel_changed(self):
await self.project_client.send_command(commands.update_channel(
self.node.channels[0], set_midi_channel=12))
editor = self._getEditor(QtWidgets.QComboBox, self.node.channels[0], 'midi_channel')
self.assertEqual(editor.currentData(), 12)
async def test_channel_set_midi_controller(self):
async def test_channel_midi_controller_changed(self):
await self.project_client.send_command(commands.update_channel(
self.node.channels[0], set_midi_controller=63))
editor = self._getEditor(QtWidgets.QComboBox, self.node.channels[0], 'midi_controller')
self.assertEqual(editor.currentData(), 63)
async def test_channel_set_min_value(self):
async def test_channel_min_value_changed(self):
await self.project_client.send_command(commands.update_channel(
self.node.channels[0], set_min_value=440.0))
editor = self._getEditor(QtWidgets.QLineEdit, self.node.channels[0], 'min_value')
self.assertEqual(editor.text(), '440.0')
async def test_channel_set_max_value(self):
async def test_channel_max_value_changed(self):
await self.project_client.send_command(commands.update_channel(
self.node.channels[0], set_max_value=880.0))
editor = self._getEditor(QtWidgets.QLineEdit, self.node.channels[0], 'max_value')
self.assertEqual(editor.text(), '880.0')
async def test_channel_set_log_scale(self):
async def test_channel_log_scale_changed(self):
await self.project_client.send_command(commands.update_channel(
self.node.channels[0], set_log_scale=True))
editor = self._getEditor(QtWidgets.QCheckBox, self.node.channels[0], 'log_scale')
self.assertTrue(editor.isChecked())
# crashes, because test doesn't have a ProjectView
# async def test_channel_learn_clicked(self):
# button = self._getEditor(QtWidgets.QAbstractButton, self.node.channels[0], 'learn')
# button.click()

51
noisicaa/builtin_nodes/midi_cc_to_cv/processor.cpp

@ -22,12 +22,17 @@
#include <math.h>
#include "lv2/lv2plug.in/ns/ext/atom/forge.h"
#include "noisicaa/audioproc/engine/misc.h"
#include "noisicaa/audioproc/public/engine_notification.pb.h"
#include "noisicaa/audioproc/public/processor_message.pb.h"
#include "noisicaa/audioproc/engine/message_queue.h"
#include "noisicaa/host_system/host_system.h"
#include "noisicaa/builtin_nodes/processor_message_registry.pb.h"
#include "noisicaa/builtin_nodes/midi_cc_to_cv/processor.h"
#include "noisicaa/builtin_nodes/midi_cc_to_cv/processor.pb.h"
#include "noisicaa/builtin_nodes/midi_cc_to_cv/processor_messages.pb.h"
namespace noisicaa {
@ -39,6 +44,8 @@ ProcessorMidiCCtoCV::ProcessorMidiCCtoCV(
_next_spec(nullptr),
_current_spec(nullptr),
_old_spec(nullptr) {
_learn_urid = _host_system->lv2->map(
"http://noisicaa.odahoda.de/lv2/processor_cc_to_cv#learn");
}
Status ProcessorMidiCCtoCV::setup_internal() {
@ -48,6 +55,7 @@ Status ProcessorMidiCCtoCV::setup_internal() {
for (int idx = 0; idx < 128 ; ++idx) {
_current_value[idx] = 0.0;
}
_learn = 0;
return Status::Ok();
}
@ -71,6 +79,26 @@ void ProcessorMidiCCtoCV::cleanup_internal() {
Processor::cleanup_internal();
}
Status ProcessorMidiCCtoCV::handle_message_internal(pb::ProcessorMessage* msg) {
unique_ptr<pb::ProcessorMessage> msg_ptr(msg);
if (msg->HasExtension(pb::midi_cc_to_cv_learn)) {
const pb::MidiCCtoCVLearn& m = msg->GetExtension(pb::midi_cc_to_cv_learn);
if (m.enable()) {
++_learn;
} else {
if (_learn > 0) {
--_learn;
} else {
_logger->error("Unbalanced MidiCCtoCVLearn messages.");
}
}
return Status::Ok();
}
return Processor::handle_message_internal(msg_ptr.release());
}
Status ProcessorMidiCCtoCV::set_parameters_internal(const pb::NodeParameters& parameters) {
if (parameters.HasExtension(pb::midi_cc_to_cv_spec)) {
const auto& spec = parameters.GetExtension(pb::midi_cc_to_cv_spec);
@ -120,6 +148,8 @@ Status ProcessorMidiCCtoCV::process_block_internal(BlockContext* ctxt, TimeMappe
return Status::Ok();
}
bool learn = _learn > 0;
LV2_Atom_Sequence* seq = (LV2_Atom_Sequence*)_buffers[0];
if (seq->atom.type != _host_system->lv2->urid.atom_sequence) {
return ERROR_STATUS(
@ -141,6 +171,27 @@ Status ProcessorMidiCCtoCV::process_block_internal(BlockContext* ctxt, TimeMappe
&& midi[1] == channel_spec.midi_controller()) {
_current_value[channel_idx] = midi[2] / 127.0;
}
if (learn) {
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, _learn_urid);
LV2_Atom_Forge_Frame tframe;
lv2_atom_forge_tuple(&forge, &tframe);
lv2_atom_forge_int(&forge, midi[0] & 0x0f);
lv2_atom_forge_int(&forge, midi[1]);
lv2_atom_forge_pop(&forge, &tframe);
lv2_atom_forge_pop(&forge, &oframe);
NodeMessage::push(ctxt->out_messages, _node_id, (LV2_Atom*)atom);
}
}
} else {
_logger->warning("Ignoring event %d in sequence.", atom.type);

4
noisicaa/builtin_nodes/midi_cc_to_cv/processor.h

@ -54,6 +54,7 @@ public:
protected:
Status setup_internal() override;
void cleanup_internal() override;
Status handle_message_internal(pb::ProcessorMessage* msg) override;
Status set_parameters_internal(const pb::NodeParameters& parameters);
Status connect_port_internal(BlockContext* ctxt, uint32_t port_idx, BufferPtr buf) override;
Status process_block_internal(BlockContext* ctxt, TimeMapper* time_mapper) override;
@ -61,8 +62,11 @@ protected:
private:
Status set_spec(const pb::MidiCCtoCVSpec& spec);
LV2_URID _learn_urid;
vector<BufferPtr> _buffers;
float _current_value[128];
atomic<uint32_t> _learn;
atomic<pb::MidiCCtoCVSpec*> _next_spec;
atomic<pb::MidiCCtoCVSpec*> _current_spec;

29
noisicaa/builtin_nodes/midi_cc_to_cv/processor_messages.proto

@ -0,0 +1,29 @@
/*
* @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";
package noisicaa.pb;
message MidiCCtoCVLearn {
required bool enable = 1;
}

34
noisicaa/builtin_nodes/midi_cc_to_cv/processor_messages.py

@ -0,0 +1,34 @@
#!/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 audioproc
from noisicaa.builtin_nodes import processor_message_registry_pb2
from . import model
def learn(
node: model.MidiCCtoCV, enable: bool
) -> audioproc.ProcessorMessage:
msg = audioproc.ProcessorMessage(node_id=node.pipeline_node_id)
pb = msg.Extensions[processor_message_registry_pb2.midi_cc_to_cv_learn]
pb.enable = enable
return msg

3
noisicaa/builtin_nodes/midi_source/processor.h

@ -37,9 +37,6 @@ namespace noisicaa {
using namespace std;
class HostSystem;
namespace pb {
class MidiSourceUpdate;
}
class ProcessorMidiSource : public Processor {
public:

4
noisicaa/builtin_nodes/processor_message_registry.proto

@ -28,6 +28,7 @@ import "noisicaa/builtin_nodes/sample_track/processor_messages.proto";
import "noisicaa/builtin_nodes/instrument/processor_messages.proto";
import "noisicaa/builtin_nodes/pianoroll/processor_messages.proto";
import "noisicaa/builtin_nodes/midi_source/processor_messages.proto";
import "noisicaa/builtin_nodes/midi_cc_to_cv/processor_messages.proto";
package noisicaa.pb;
@ -50,4 +51,7 @@ extend ProcessorMessage {
// Midi Source (408xxx)
optional MidiSourceUpdate midi_source_update = 408000;
optional MidiSourceEvent midi_source_event = 408001;
// Midi CC to CV (410xxx)
optional MidiCCtoCVLearn midi_cc_to_cv_learn = 410000;
}

Loading…
Cancel
Save