52 changed files with 2171 additions and 38 deletions
@ -0,0 +1,2 @@
|
||||
from typing import Any |
||||
def __getattr__(arrr: str) -> Any: ... |
@ -0,0 +1,32 @@
|
||||
/* |
||||
* @begin:license |
||||
* |
||||
* Copyright (c) 2015-2019, Benjamin Niemann <pink@odahoda.de> |
||||
* |
||||
* This program is free software; you can redistribute it and/or modify |
||||
* it under the terms of the GNU General Public License as published by |
||||
* the Free Software Foundation; either version 2 of the License, or |
||||
* (at your option) any later version. |
||||
* |
||||
* This program is distributed in the hope that it will be useful, |
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
* GNU General Public License for more details. |
||||
* |
||||
* You should have received a copy of the GNU General Public License along |
||||
* with this program; if not, write to the Free Software Foundation, Inc., |
||||
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. |
||||
* |
||||
* @end:license |
||||
*/ |
||||
|
||||
syntax = "proto2"; |
||||
|
||||
import "noisicaa/audioproc/public/musical_time.proto"; |
||||
|
||||
package noisicaa.pb; |
||||
|
||||
message MidiEvent { |
||||
required MusicalTime time = 1; |
||||
required bytes midi = 2; |
||||
} |
@ -0,0 +1,115 @@
|
||||
#!/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 fractions |
||||
from typing import overload, Any, Union |
||||
|
||||
from google.protobuf import message as protobuf |
||||
|
||||
from noisicaa import value_types |
||||
from . import musical_time_pb2 |
||||
|
||||
|
||||
class PyMusicalDuration(value_types.ProtoValue): |
||||
@overload |
||||
def __init__(self) -> None: ... |
||||
@overload |
||||
def __init__(self, numerator: int, denominator: int) -> None: ... |
||||
@overload |
||||
def __init__(self, duration: PyMusicalDuration) -> None: ... |
||||
@overload |
||||
def __init__(self, duration: fractions.Fraction) -> None: ... |
||||
@overload |
||||
def __init__(self, duration: int) -> None: ... |
||||
def __hash__(self) -> int: ... |
||||
def __str__(self) -> str: ... |
||||
def __repr__(self) -> str: ... |
||||
def __getstate__(self) -> Any: ... |
||||
def __setstate__(self, state: Any) -> None: ... |
||||
@property |
||||
def numerator(self) -> int: ... |
||||
@property |
||||
def denominator(self) -> int: ... |
||||
@property |
||||
def fraction(self) -> fractions.Fraction: ... |
||||
def to_float(self) -> float: ... |
||||
def __bool__(self) -> bool: ... |
||||
def __eq__(self, other: Any) -> bool: ... |
||||
def __ne__(self, other: Any) -> bool: ... |
||||
def __gt__(self, other: PyMusicalDuration) -> bool: ... |
||||
def __ge__(self, other: PyMusicalDuration) -> bool: ... |
||||
def __le__(self, other: PyMusicalDuration) -> bool: ... |
||||
def __lt__(self, other: PyMusicalDuration) -> bool: ... |
||||
def __add__(self, other: PyMusicalDuration) -> PyMusicalDuration: ... |
||||
def __sub__(self, other: PyMusicalDuration) -> PyMusicalDuration: ... |
||||
def __mul__(self, other: Union[PyMusicalDuration, PyMusicalTime, fractions.Fraction, int]) -> PyMusicalDuration: ... |
||||
def __truediv__(self, other: Union[PyMusicalDuration, PyMusicalTime, fractions.Fraction, int]) -> PyMusicalDuration: ... |
||||
def __int__(self) -> int: ... |
||||
def __float__(self) -> float: ... |
||||
@classmethod |
||||
def from_proto(cls, pb: protobuf.Message) -> PyMusicalDuration: ... |
||||
def to_proto(self) -> musical_time_pb2.MusicalDuration: ... |
||||
|
||||
|
||||
class PyMusicalTime(value_types.ProtoValue): |
||||
@overload |
||||
def __init__(self) -> None: ... |
||||
@overload |
||||
def __init__(self, numerator: int, denominator: int) -> None: ... |
||||
@overload |
||||
def __init__(self, duration: PyMusicalTime) -> None: ... |
||||
@overload |
||||
def __init__(self, duration: fractions.Fraction) -> None: ... |
||||
@overload |
||||
def __init__(self, duration: int) -> None: ... |
||||
def __hash__(self) -> int: ... |
||||
def __str__(self) -> str: ... |
||||
def __repr__(self) -> str: ... |
||||
def __getstate__(self) -> Any: ... |
||||
def __setstate__(self, state: Any) -> None: ... |
||||
@property |
||||
def numerator(self) -> int: ... |
||||
@property |
||||
def denominator(self) -> int: ... |
||||
@property |
||||
def fraction(self) -> fractions.Fraction: ... |
||||
def to_float(self) -> float: ... |
||||
def __bool__(self) -> bool: ... |
||||
def __eq__(self, other: Any) -> bool: ... |
||||
def __ne__(self, other: Any) -> bool: ... |
||||
def __gt__(self, other: PyMusicalTime) -> bool: ... |
||||
def __ge__(self, other: PyMusicalTime) -> bool: ... |
||||
def __lt__(self, other: PyMusicalTime) -> bool: ... |
||||
def __le__(self, other: PyMusicalTime) -> bool: ... |
||||
def __add__(self, other: PyMusicalDuration) -> PyMusicalTime: ... |
||||
@overload |
||||
def __sub__(self, other: PyMusicalDuration) -> PyMusicalTime: ... |
||||
@overload |
||||
def __sub__(self, other: PyMusicalTime) -> PyMusicalDuration: ... |
||||
def __mul__(self, other: Union[PyMusicalDuration, PyMusicalTime, fractions.Fraction, int]) -> PyMusicalTime: ... |
||||
def __truediv__(self, other: Union[PyMusicalDuration, PyMusicalTime, fractions.Fraction, int]) -> PyMusicalTime: ... |
||||
def __mod__(self, other: Union[PyMusicalDuration, PyMusicalTime, fractions.Fraction, int]) -> PyMusicalTime: ... |
||||
def __int__(self) -> int: ... |
||||
def __float__(self) -> float: ... |
||||
@classmethod |
||||
def from_proto(cls, pb: protobuf.Message) -> PyMusicalTime: ... |
||||
def to_proto(self) -> musical_time_pb2.MusicalTime: ... |
@ -0,0 +1,41 @@
|
||||
# @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 typing import Iterator |
||||
from .musical_time import PyMusicalTime, PyMusicalDuration |
||||
from noisicaa import music |
||||
|
||||
|
||||
class PyTimeMapper(object): |
||||
bpm = ... # type: int |
||||
duration = ... # type: PyMusicalDuration |
||||
|
||||
def __init__(self, sample_rate: int) -> None: ... |
||||
def setup(self, project: music.BaseProject = None) -> None: ... |
||||
def cleanup(self) -> None: ... |
||||
@property |
||||
def end_time(self) -> PyMusicalTime: ... |
||||
@property |
||||
def num_samples(self) -> int: ... |
||||
def sample_to_musical_time(self, sample_time: int) -> PyMusicalTime: ... |
||||
def musical_to_sample_time(self, musical_time: PyMusicalTime) -> int: ... |
||||
def __iter__(self) -> Iterator[PyMusicalTime]: ... |
||||
def find(self, t: PyMusicalTime) -> Iterator[PyMusicalTime]: ... |
||||
|
@ -0,0 +1,47 @@
|
||||
# @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( |
||||
node_description.py |
||||
model.py |
||||
model_test.py |
||||
node_ui.py |
||||
node_ui_test.py |
||||
processor_test.py |
||||
) |
||||
|
||||
build_model(model.desc.pb _model.py noisicaa/builtin_nodes/model.tmpl.py) |
||||
|
||||
py_proto(model.proto) |
||||
add_dependencies(noisicaa.builtin_nodes.midi_looper.model.proto model-noisicaa.builtin_nodes.midi_looper) |
||||
cpp_proto(model.proto) |
||||
py_proto(processor.proto) |
||||
cpp_proto(processor.proto) |
||||
add_dependencies(noisicaa.builtin_nodes.midi_looper.processor.proto model-noisicaa.builtin_nodes.midi_looper) |
||||
|
||||
add_library(noisicaa-builtin_nodes-midi_looper-processor_messages SHARED processor.pb.cc) |
||||
target_link_libraries(noisicaa-builtin_nodes-midi_looper-processor_messages PRIVATE noisicaa-audioproc-public) |
||||
|
||||
add_library(noisicaa-builtin_nodes-midi_looper-processor SHARED processor.cpp model.pb.cc) |
||||
target_compile_options(noisicaa-builtin_nodes-midi_looper-processor PRIVATE -fPIC -std=c++11 -Wall -Werror -pedantic -DHAVE_PTHREAD_SPIN_LOCK) |
||||
target_link_libraries(noisicaa-builtin_nodes-midi_looper-processor PRIVATE noisicaa-audioproc-public) |
||||
target_link_libraries(noisicaa-builtin_nodes-midi_looper-processor PRIVATE noisicaa-host_system) |
||||
target_link_libraries(noisicaa-builtin_nodes-midi_looper-processor PRIVATE noisicaa-builtin_nodes-processor_message_registry) |
||||
target_link_libraries(noisicaa-builtin_nodes-midi_looper-processor PRIVATE noisicaa-builtin_nodes-midi_looper-processor_messages) |
@ -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 |
@ -0,0 +1,49 @@
|
||||
# @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 |
||||
|
||||
classes { |
||||
name: "MidiLooperPatch" |
||||
super_class: "noisicaa.music.model_base.ProjectChild" |
||||
proto_ext_name: "midi_looper_patch" |
||||
properties { |
||||
name: "events" |
||||
type: WRAPPED_PROTO_LIST |
||||
wrapped_type: "noisicaa.value_types.MidiEvent" |
||||
proto_id: 1 |
||||
} |
||||
} |
||||
|
||||
classes { |
||||
name: "MidiLooper" |
||||
super_class: "noisicaa.music.graph.BaseNode" |
||||
proto_ext_name: "midi_looper" |
||||
properties { |
||||
name: "duration" |
||||
type: WRAPPED_PROTO |
||||
wrapped_type: "noisicaa.audioproc.MusicalDuration" |
||||
proto_id: 1 |
||||
} |
||||
properties { |
||||
name: "patches" |
||||
type: OBJECT_LIST |
||||
obj_type: "MidiLooperPatch" |
||||
proto_id: 2 |
||||
} |
||||
} |
@ -0,0 +1,92 @@
|
||||
#!/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 cast, Any, Iterator, Iterable |
||||
|
||||
from noisicaa import audioproc |
||||
from noisicaa import node_db |
||||
from noisicaa import value_types |
||||
from . import node_description |
||||
from . import processor_pb2 |
||||
from . import _model |
||||
|
||||
logger = logging.getLogger(__name__) |
||||
|
||||
|
||||
class MidiLooperPatch(_model.MidiLooperPatch): |
||||
@property |
||||
def midi_looper(self) -> 'MidiLooper': |
||||
return cast(MidiLooper, self.parent) |
||||
|
||||
def set_events(self, events: Iterable[value_types.MidiEvent]) -> None: |
||||
self.events.clear() |
||||
self.events.extend(events) |
||||
self.midi_looper.update_spec() |
||||
|
||||
|
||||
class MidiLooper(_model.MidiLooper): |
||||
def create(self, **kwargs: Any) -> None: |
||||
super().create(**kwargs) |
||||
|
||||
self.duration = audioproc.MusicalDuration(8, 4) |
||||
self.patches.append(self._pool.create(MidiLooperPatch)) |
||||
|
||||
def setup(self) -> None: |
||||
super().setup() |
||||
|
||||
self.duration_changed.add(lambda _: self.update_spec()) |
||||
|
||||
# TODO: this causes a large number of spec updates when the patch is populated (one for each |
||||
# event added). It would be better to schedule a single update at the end of the mutation. |
||||
self.patches[0].object_changed.add(lambda _: self.update_spec()) |
||||
|
||||
def get_initial_parameter_mutations(self) -> Iterator[audioproc.Mutation]: |
||||
yield from super().get_initial_parameter_mutations() |
||||
yield self.__get_spec_mutation() |
||||
|
||||
def update_spec(self) -> None: |
||||
if self.attached_to_project: |
||||
self.project.handle_pipeline_mutation( |
||||
self.__get_spec_mutation()) |
||||
|
||||
def __get_spec_mutation(self) -> audioproc.Mutation: |
||||
params = audioproc.NodeParameters() |
||||
spec = params.Extensions[processor_pb2.midi_looper_spec] |
||||
spec.duration.CopyFrom(self.duration.to_proto()) |
||||
for event in self.patches[0].events: |
||||
pb_event = spec.events.add() |
||||
pb_event.CopyFrom(event.to_proto()) |
||||
|
||||
return audioproc.Mutation( |
||||
set_node_parameters=audioproc.SetNodeParameters( |
||||
node_id=self.pipeline_node_id, |
||||
parameters=params)) |
||||
|
||||
@property |
||||
def description(self) -> node_db.NodeDescription: |
||||
node_desc = node_db.NodeDescription() |
||||
node_desc.CopyFrom(node_description.MidiLooperDescription) |
||||
return node_desc |
||||
|
||||
def set_duration(self, duration: audioproc.MusicalDuration) -> None: |
||||
self.duration = duration |
@ -0,0 +1,48 @@
|
||||
#!/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 typing import cast |
||||
|
||||
from noisidev import unittest |
||||
from noisidev import unittest_mixins |
||||
from noisicaa import audioproc |
||||
from . import model |
||||
|
||||
|
||||
class MidiLooperTest(unittest_mixins.ProjectMixin, unittest.AsyncTestCase): |
||||
|
||||
async def _add_node(self) -> model.MidiLooper: |
||||
with self.project.apply_mutations('test'): |
||||
return cast( |
||||
model.MidiLooper, |
||||
self.project.create_node('builtin://midi-looper')) |
||||
|
||||
async def test_add_node(self): |
||||
node = await self._add_node() |
||||
self.assertIsInstance(node, model.MidiLooper) |
||||
|
||||
async def test_duration(self): |
||||
node = await self._add_node() |
||||
self.assertEqual(node.duration, audioproc.MusicalDuration(8, 4)) |
||||
with self.project.apply_mutations('test'): |
||||
node.set_duration(audioproc.MusicalDuration(4, 4)) |
||||
self.assertEqual(node.duration, audioproc.MusicalDuration(4, 4)) |
@ -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 |
||||
|
||||
|
||||
MidiLooperDescription = node_db.NodeDescription( |
||||
uri='builtin://midi-looper', |
||||
display_name='MIDI Looper', |
||||
type=node_db.NodeDescription.PROCESSOR, |
||||
node_ui=node_db.NodeUIDescription( |
||||
type='builtin://midi-looper', |
||||
), |
||||
builtin_icon='node-type-builtin', |
||||
processor=node_db.ProcessorDescription( |
||||
type='builtin://midi-looper', |
||||
), |
||||
ports=[ |
||||
node_db.PortDescription( |
||||
name='in', |
||||
direction=node_db.PortDescription.INPUT, |
||||
type=node_db.PortDescription.EVENTS, |
||||
), |
||||
node_db.PortDescription( |
||||
name='out', |
||||
direction=node_db.PortDescription.OUTPUT, |
||||
type=node_db.PortDescription.EVENTS, |
||||
), |
||||
] |
||||
) |
@ -0,0 +1,293 @@
|
||||
#!/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 |
||||
from typing import Any, Dict, List |
||||
|
||||
from PyQt5.QtCore import Qt |
||||
from PyQt5 import QtCore |
||||
from PyQt5 import QtGui |
||||
from PyQt5 import QtWidgets |
||||
|
||||
from noisicaa import core |
||||
from noisicaa import audioproc |
||||
from noisicaa import music |
||||
from noisicaa import value_types |
||||
from noisicaa.ui import ui_base |
||||
from noisicaa.ui import pianoroll |
||||
from noisicaa.ui import slots |
||||
from noisicaa.ui.graph import base_node |
||||
from noisicaa.builtin_nodes import processor_message_registry_pb2 |
||||
from . import model |
||||
|
||||
logger = logging.getLogger(__name__) |
||||
|
||||
|
||||
# Keep this in sync with ProcessorMidiLooper::RecordState in processor.h |
||||
class RecordState(enum.IntEnum): |
||||
UNSET = 0 |
||||
OFF = 1 |
||||
WAITING = 2 |
||||
RECORDING = 3 |
||||
|
||||
|
||||
class RecordButton(slots.SlotContainer, QtWidgets.QPushButton): |
||||
recordState, setRecordState, recordStateChanged = slots.slot(RecordState, 'recordState') |
||||
|
||||
def __init__(self) -> None: |
||||
super().__init__() |
||||
|
||||
self.setText("Record") |
||||
self.setIcon(QtGui.QIcon.fromTheme('media-record')) |
||||
|
||||
self.__default_bg = self.palette().color(QtGui.QPalette.Button) |
||||
|
||||
self.recordStateChanged.connect(self.__recordStateChanged) |
||||
|
||||
self.__timer = QtCore.QTimer() |
||||
self.__timer.setInterval(250) |
||||
self.__timer.timeout.connect(self.__blink) |
||||
self.__blink_state = False |
||||
|
||||
def __recordStateChanged(self, state: RecordState) -> None: |
||||
palette = self.palette() |
||||
if state == RecordState.OFF: |
||||
self.__timer.stop() |
||||
palette.setColor(self.backgroundRole(), self.__default_bg) |
||||
elif state == RecordState.WAITING: |
||||
self.__timer.start() |
||||
self.__blink_state = True |
||||
palette.setColor(self.backgroundRole(), QtGui.QColor(0, 255, 0)) |
||||
elif state == RecordState.RECORDING: |
||||
self.__timer.stop() |
||||
palette.setColor(self.backgroundRole(), QtGui.QColor(255, 0, 0)) |
||||
self.setPalette(palette) |
||||
|
||||
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) |
||||
|
||||
|
||||
class MidiLooperNodeWidget(ui_base.ProjectMixin, core.AutoCleanupMixin, QtWidgets.QWidget): |
||||
def __init__(self, node: model.MidiLooper, session_prefix: str, **kwargs: Any) -> None: |
||||
super().__init__(**kwargs) |
||||
|
||||
self.__node = node |
||||
|
||||
self.__visible = False |
||||
|
||||
self.__slot_connections = slots.SlotConnectionManager( |
||||
session_prefix='midi_looper:%016x:%s' % (self.__node.id, session_prefix), |
||||
context=self.context) |
||||
self.add_cleanup_function(self.__slot_connections.cleanup) |
||||
|
||||
self.__listeners = core.ListenerMap[str]() |
||||
self.add_cleanup_function(self.__listeners.cleanup) |
||||
|
||||
self.__listeners['node-messages'] = self.audioproc_client.node_messages.add( |
||||
'%016x' % self.__node.id, self.__nodeMessage) |
||||
|
||||
self.__event_map = [] # type: List[int] |
||||
self.__recorded_events = [] # type: List[value_types.MidiEvent] |
||||
|
||||
self.__duration = QtWidgets.QSpinBox() |
||||
self.__duration.setObjectName('duration') |
||||
self.__duration.setSuffix(" beats") |
||||
self.__duration.setKeyboardTracking(False) |
||||
self.__duration.setRange(1, 100) |
||||
num_beats = self.__node.duration / audioproc.MusicalDuration(1, 4) |
||||
assert num_beats.denominator == 1 |
||||
self.__duration.setValue(num_beats.numerator) |
||||
self.__duration.valueChanged.connect(self.__durationEdited) |
||||
self.__listeners['duration'] = self.__node.duration_changed.add(self.__durationChanged) |
||||
|
||||
self.__record = RecordButton() |
||||
self.__record.clicked.connect(self.__recordClicked) |
||||
|
||||
self.__pianoroll = pianoroll.PianoRoll() |
||||
self.__pianoroll.setDuration(self.__node.duration) |
||||
|
||||
for event in self.__node.patches[0].events: |
||||
self.__event_map.append(self.__pianoroll.addEvent(event)) |
||||
self.__listeners['events'] = self.__node.patches[0].events_changed.add( |
||||
self.__eventsChanged) |
||||
|
||||
l2 = QtWidgets.QHBoxLayout() |
||||
l2.setContentsMargins(0, 0, 0, 0) |
||||
l2.addWidget(self.__record) |
||||
l2.addWidget(self.__duration) |
||||
l2.addStretch(1) |
||||
|
||||
l1 = QtWidgets.QVBoxLayout() |
||||
l1.setContentsMargins(0, 0, 0, 0) |
||||
l1.addLayout(l2) |
||||
l1.addWidget(self.__pianoroll) |
||||
self.setLayout(l1) |
||||
|
||||
def showEvent(self, evt: QtGui.QShowEvent) -> None: |
||||
if not self.__visible: |
||||
self.__pianoroll.connectSlots(self.__slot_connections, 'pianoroll') |
||||
self.__visible = True |
||||
super().showEvent(evt) |
||||
|
||||
def hideEvent(self, evt: QtGui.QHideEvent) -> None: |
||||
if self.__visible: |
||||
self.__pianoroll.disconnectSlots(self.__slot_connections, 'pianoroll') |
||||
self.__visible = False |
||||
super().hideEvent(evt) # type: ignore |
||||
|
||||
def __eventsChanged(self, change: music.PropertyListChange[value_types.MidiEvent]) -> None: |
||||
if isinstance(change, music.PropertyListInsert): |
||||
event_id = self.__pianoroll.addEvent(change.new_value) |
||||
self.__event_map.insert(change.index, event_id) |
||||
|
||||
elif isinstance(change, music.PropertyListDelete): |
||||
event_id = self.__event_map.pop(change.index) |
||||
self.__pianoroll.removeEvent(event_id) |
||||
|
||||
else: |
||||
raise TypeError(type(change)) |
||||
|
||||
def __durationChanged( |
||||
self, change: music.PropertyValueChange[audioproc.MusicalDuration]) -> None: |
||||
num_beats = change.new_value / audioproc.MusicalDuration(1, 4) |
||||
assert num_beats.denominator == 1 |
||||
self.__duration.setValue(num_beats.numerator) |
||||
self.__pianoroll.setDuration(change.new_value) |
||||
|
||||
def __durationEdited(self, beats: int) -> None: |
||||
duration = audioproc.MusicalDuration(beats, 4) |
||||
if duration == self.__node.duration: |
||||
return |
||||
|
||||
with self.project.apply_mutations('%s: Change duration' % self.__node.name): |
||||
self.__node.set_duration(duration) |
||||
|
||||
def __recordClicked(self) -> None: |
||||
msg = audioproc.ProcessorMessage(node_id=self.__node.pipeline_node_id) |
||||
pb = msg.Extensions[processor_message_registry_pb2.midi_looper_record] |
||||
pb.start = 1 |
||||
self.call_async(self.project_view.sendNodeMessage(msg)) |
||||
|
||||
def __nodeMessage(self, msg: Dict[str, Any]) -> None: |
||||
current_position_urid = ( |
||||
'http://noisicaa.odahoda.de/lv2/processor_midi_looper#current_position') |
||||
if current_position_urid in msg: |
||||
numerator, denominator = msg[current_position_urid] |
||||
current_position = audioproc.MusicalTime(numerator, denominator) |
||||
self.__pianoroll.setPlaybackPosition(current_position) |
||||
|
||||
record_state_urid = 'http://noisicaa.odahoda.de/lv2/processor_midi_looper#record_state' |
||||
if record_state_urid in msg: |
||||
record_state = RecordState(msg[record_state_urid]) |
||||
self.__record.setRecordState(record_state) |
||||
if record_state == RecordState.RECORDING: |
||||
self.__recorded_events.clear() |
||||
self.__pianoroll.clearEvents() |
||||
self.__pianoroll.setUnfinishedNoteMode( |
||||
pianoroll.UnfinishedNoteMode.ToPlaybackPosition) |
||||
else: |
||||
if record_state == RecordState.OFF: |
||||
del self.__listeners['events'] |
||||
|
||||
with self.project.apply_mutations('%s: Record patch' % self.__node.name): |
||||
patch = self.__node.patches[0] |
||||
patch.set_events(self.__recorded_events) |
||||
self.__recorded_events.clear() |
||||
|
||||
self.__pianoroll.clearEvents() |
||||
self.__event_map.clear() |
||||
for event in self.__node.patches[0].events: |
||||
self.__event_map.append(self.__pianoroll.addEvent(event)) |
||||
self.__listeners['events'] = self.__node.patches[0].events_changed.add( |
||||
self.__eventsChanged) |
||||
|
||||
self.__pianoroll.setUnfinishedNoteMode(pianoroll.UnfinishedNoteMode.ToEnd) |
||||
|
||||
|
||||
recorded_event_urid = 'http://noisicaa.odahoda.de/lv2/processor_midi_looper#recorded_event' |
||||
if recorded_event_urid in msg: |
||||
time_numerator, time_denominator, midi, recorded = msg[recorded_event_urid] |
||||
time = audioproc.MusicalTime(time_numerator, time_denominator) |
||||
|
||||
if recorded: |
||||
event = value_types.MidiEvent(time, midi) |
||||
self.__recorded_events.append(event) |
||||
self.__pianoroll.addEvent(event) |
||||
|
||||
if midi[0] & 0xf0 == 0x90: |
||||
self.__pianoroll.noteOn(midi[1]) |
||||
elif midi[0] & 0xf0 == 0x80: |
||||
self.__pianoroll.noteOff(midi[1]) |
||||
|
||||
|
||||
class MidiLooperNode(base_node.Node): |
||||
has_window = True |
||||
|
||||
def __init__(self, *, node: music.BaseNode, **kwargs: Any) -> None: |
||||
assert isinstance(node, model.MidiLooper), type(node).__name__ |
||||
self.__widget = None # type: QtWidgets.QWidget |
||||
self.__node = node # type: model.MidiLooper |
||||
|
||||
super().__init__(node=node, **kwargs) |
||||
|
||||
def createBodyWidget(self) -> QtWidgets.QWidget: |
||||
assert self.__widget is None |
||||
|
||||
body = MidiLooperNodeWidget( |
||||
node=self.__node, |
||||
session_prefix='inline', |
||||
context=self.context) |
||||
self.add_cleanup_function(body.cleanup) |
||||
body.setAutoFillBackground(False) |
||||
body.setAttribute(Qt.WA_NoSystemBackground, True) |
||||
|
||||
self.__widget = QtWidgets.QScrollArea() |
||||
self.__widget.setWidgetResizable(True) |
||||
self.__widget.setFrameShape(QtWidgets.QFrame.NoFrame) |
||||
self.__widget.setWidget(body) |
||||
|
||||
return self.__widget |
||||
|
||||
def createWindow(self, **kwargs: Any) -> QtWidgets.QWidget: |
||||
window = QtWidgets.QDialog(**kwargs) |
||||
window.setAttribute(Qt.WA_DeleteOnClose, False) |
||||
window.setWindowTitle("MIDI Looper") |
||||
|
||||
body = MidiLooperNodeWidget( |
||||
node=self.__node, |
||||
session_prefix='window', |
||||
context=self.context) |
||||
self.add_cleanup_function(body.cleanup) |
||||
|
||||
layout = QtWidgets.QVBoxLayout() |
||||
layout.setContentsMargins(0, 0, 0, 0) |
||||
layout.addWidget(body) |
||||
window.setLayout(layout) |
||||
|
||||
return window |
@ -0,0 +1,67 @@
|
||||
#!/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 PyQt5 import QtWidgets |
||||
|
||||
from noisidev import uitest |
||||
from noisicaa import audioproc |
||||
from . import node_ui |
||||
|
||||
|
||||
class MidiLooperNodeTest(uitest.ProjectMixin, uitest.UITestCase): |
||||
async def setup_testcase(self): |
||||
with self.project.apply_mutations('test'): |
||||
self.node = self.project.create_node('builtin://midi-looper') |
||||
|
||||
async def test_init(self): |
||||
widget = node_ui.MidiLooperNode(node=self.node, context=self.context) |
||||
widget.cleanup() |
||||
|
||||
|
||||
class MidiLooperNodeWidgetTest(uitest.ProjectMixin, uitest.UITestCase): |
||||
async def setup_testcase(self): |
||||
with self.project.apply_mutations('test'): |
||||
self.node = self.project.create_node('builtin://midi-looper') |
||||
|
||||
async def test_init(self): |
||||
widget = node_ui.MidiLooperNodeWidget( |
||||
node=self.node, session_prefix='test', context=self.context) |
||||
widget.cleanup() |
||||
|
||||
async def test_duration(self): |
||||
widget = node_ui.MidiLooperNodeWidget( |
||||
node=self.node, session_prefix='test', context=self.context) |
||||
try: |
||||
duration = widget.findChild(QtWidgets.QSpinBox, 'duration') |
||||
assert duration is not None |
||||
self.assertEqual( |
||||
duration.value(), (self.node.duration / audioproc.MusicalDuration(1, 4)).numerator) |
||||
|
||||
with self.project.apply_mutations('test'): |
||||
self.node.set_duration(audioproc.MusicalDuration(5, 4)) |
||||
self.assertEqual(audioproc.MusicalDuration(duration.value(), 4), self.node.duration) |
||||
|
||||
duration.setValue(7) |
||||
self.assertEqual(self.node.duration, audioproc.MusicalDuration(7, 4)) |
||||
|
||||
finally: |
||||
widget.cleanup() |
@ -0,0 +1,364 @@
|
||||
/*
|
||||
* @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 "noisicaa/audioproc/engine/misc.h" |
||||
#include "noisicaa/audioproc/public/musical_time.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_looper/processor.h" |
||||
#include "noisicaa/builtin_nodes/midi_looper/processor.pb.h" |
||||
#include "noisicaa/builtin_nodes/midi_looper/model.pb.h" |
||||
|
||||
namespace noisicaa { |
||||
|
||||
ProcessorMidiLooper::ProcessorMidiLooper( |
||||
const string& realm_name, const string& node_id, HostSystem *host_system, |
||||
const pb::NodeDescription& desc) |
||||
: Processor( |
||||
realm_name, node_id, "noisicaa.audioproc.engine.processor.midi_looper", host_system, desc), |
||||
_next_spec(nullptr), |
||||
_current_spec(nullptr), |
||||
_old_spec(nullptr) { |
||||
_current_position_urid = _host_system->lv2->map( |
||||
"http://noisicaa.odahoda.de/lv2/processor_midi_looper#current_position"); |
||||
_record_state_urid = _host_system->lv2->map( |
||||
"http://noisicaa.odahoda.de/lv2/processor_midi_looper#record_state"); |
||||
_recorded_event_urid = _host_system->lv2->map( |
||||
"http://noisicaa.odahoda.de/lv2/processor_midi_looper#recorded_event"); |
||||
lv2_atom_forge_init(&_node_msg_forge, &_host_system->lv2->urid_map); |
||||
lv2_atom_forge_init(&_out_forge, &_host_system->lv2->urid_map); |
||||
} |
||||
|
||||
Status ProcessorMidiLooper::setup_internal() { |
||||
RETURN_IF_ERROR(Processor::setup_internal()); |
||||
|
||||
_buffers.resize(_desc.ports_size()); |
||||
_next_record_state.store(UNSET); |
||||
_record_state = OFF; |
||||
_recorded_count = 0; |
||||
_playback_pos = MusicalTime(-1, 1); |
||||
_playback_index = 0; |
||||
_last_seen_spec = nullptr; |
||||
|
||||
return Status::Ok(); |
||||
} |
||||
|
||||
void ProcessorMidiLooper::cleanup_internal() { |
||||
pb::MidiLooperSpec* spec = _next_spec.exchange(nullptr); |
||||
if (spec != nullptr) { |
||||
delete spec; |
||||
} |
||||
spec = _current_spec.exchange(nullptr); |
||||
if (spec != nullptr) { |
||||
delete spec; |
||||
} |
||||
spec = _old_spec.exchange(nullptr); |
||||
if (spec != nullptr) { |
||||
delete spec; |
||||
} |
||||
|
||||
_buffers.clear(); |
||||
|
||||
Processor::cleanup_internal(); |
||||
} |
||||
|
||||
Status ProcessorMidiLooper::handle_message_internal(pb::ProcessorMessage* msg) { |
||||
unique_ptr<pb::ProcessorMessage> msg_ptr(msg); |
||||
if (msg->HasExtension(pb::midi_looper_record)) { |
||||
const pb::MidiLooperRecord& m = msg->GetExtension(pb::midi_looper_record); |
||||
if (m.start()) { |
||||
_next_record_state.store(WAITING); |
||||
} |
||||
|
||||
return Status::Ok(); |
||||
} |
||||
|
||||
return Processor::handle_message_internal(msg_ptr.release()); |
||||
} |
||||
|
||||
Status ProcessorMidiLooper::set_parameters_internal(const pb::NodeParameters& parameters) { |
||||
if (parameters.HasExtension(pb::midi_looper_spec)) { |
||||
const auto& spec = parameters.GetExtension(pb::midi_looper_spec); |
||||
|
||||
Status status = set_spec(spec); |
||||
if (status.is_error()) { |
||||
_logger->warning("Failed to update spec: %s", status.message()); |
||||
} |
||||
} |
||||
|
||||
return Processor::set_parameters_internal(parameters); |
||||
} |
||||
|
||||
Status ProcessorMidiLooper::connect_port_internal( |
||||
BlockContext* ctxt, uint32_t port_idx, BufferPtr buf) { |
||||
if (port_idx >= _buffers.size()) { |
||||
return ERROR_STATUS("Invalid port index %d", port_idx); |
||||