Massive refactoring of the audio layer.

- Run all audio in a single process.
  - Instead of a main audio process connected to the backend and one process per opened project.
  - Audio engine is composed of a tree of 'realms', with the top-level realm hooked up to the
    backend, and each project creates a sub-realm.
- Run plugins in a separate process, isolated from the main audio engine.
  - Turns out I really want to support the LV2 instance access feature, so UIs need to run in the
    same process as the plugin itself.
  - So plugins need to be isolated from each other, so they can use different UI toolkits without
    linker symbol clashes. I.e. they can't all run in the main audio process.
- Separate audio processing from the project process.
  - Instead of generating MIDI events from the project and route them to the audio pipeline, have
    processors in the pipeline, which capture the full state of the project and emit events during
    playback. Changes to the project update those processors to keep them in sync.
- Use protocol buffers for a lot of internal messages in non-realtime contexts.
- Very basic support for LV2 plugin UIs.
  - Trying to get that working triggered all those changes above...
- And many, many more changes...
looper
Ben Niemann 5 years ago
parent 66fb6afb0d
commit 45be492aed

@ -28,10 +28,15 @@ import subprocess
import sys
import time
import tarfile
import zipfile
VERSION = '0.24.0'
FILENAME = 'lilv-%s.tar.bz2' % VERSION
DOWNLOAD_URL = 'http://git.drobilla.net/cgit.cgi/lilv.git/snapshot/%s' % FILENAME
# VERSION = '0.24.0'
# FILENAME = 'lilv-%s.tar.bz2' % VERSION
# DOWNLOAD_URL = 'http://git.drobilla.net/cgit.cgi/lilv.git/snapshot/%s' % FILENAME
VERSION = '0.24.3-git'
FILENAME = 'master.zip'
DOWNLOAD_URL = 'https://github.com/odahoda/lilv/archive/%s' % FILENAME
assert os.getenv('VIRTUAL_ENV'), "Not running in a virtualenv."
@ -95,6 +100,30 @@ class BuildLilv(LilvMixin, core.Command):
os.rename(archive_path + '.partial', archive_path)
print('Downloaded %s: %d bytes' % (DOWNLOAD_URL, total_bytes))
# def _unpack_archive(self, archive_path, src_dir):
# if os.path.isdir(src_dir):
# return
# print("Extracting...")
# base_dir = None
# with tarfile.open(archive_path, 'r:bz2') as fp:
# for path in fp.getnames():
# while path:
# path, b = os.path.split(path)
# if not path:
# if base_dir is None:
# base_dir = b
# elif b != base_dir:
# raise RuntimeError(
# "No common base dir (%s)" % b)
# fp.extractall(self.build_base)
# os.rename(os.path.join(self.build_base, base_dir), src_dir)
# print("Extracted to %s" % src_dir)
# return src_dir
def _unpack_archive(self, archive_path, src_dir):
if os.path.isdir(src_dir):
return
@ -102,8 +131,8 @@ class BuildLilv(LilvMixin, core.Command):
print("Extracting...")
base_dir = None
with tarfile.open(archive_path, 'r:bz2') as fp:
for path in fp.getnames():
with zipfile.ZipFile(archive_path, 'r') as fp:
for path in fp.namelist():
while path:
path, b = os.path.split(path)
if not path:
@ -115,6 +144,8 @@ class BuildLilv(LilvMixin, core.Command):
fp.extractall(self.build_base)
os.chmod(os.path.join(self.build_base, base_dir, 'waf'), 0o755)
os.rename(os.path.join(self.build_base, base_dir), src_dir)
print("Extracted to %s" % src_dir)
return src_dir

@ -0,0 +1,184 @@
# @begin:license
#
# Copyright (c) 2015-2018, 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 distutils import core
from distutils.command.build import build
from distutils.command.install import install
import urllib.request
import os
import os.path
import subprocess
import sys
import time
import tarfile
VERSION = '0.10.0'
FILENAME = 'suil-%s.tar.bz2' % VERSION
DOWNLOAD_URL = 'http://git.drobilla.net/cgit.cgi/suil.git/snapshot/%s' % FILENAME
assert os.getenv('VIRTUAL_ENV'), "Not running in a virtualenv."
class SuilMixin(object):
user_options = [
('build-base=', 'b',
"base directory for build library"),
]
def initialize_options(self):
self.build_base = os.path.join(os.getenv('VIRTUAL_ENV'), 'build', 'suil')
def finalize_options(self):
pass
@property
def archive_path(self):
return os.path.join(self.build_base, FILENAME)
@property
def src_dir(self):
return os.path.join(self.build_base, 'src')
class BuildSuil(SuilMixin, core.Command):
def run(self):
if not os.path.isdir(self.build_base):
os.makedirs(self.build_base)
self._download_archive(self.archive_path)
self._unpack_archive(self.archive_path, self.src_dir)
self._configure(self.src_dir)
self._build(self.src_dir)
def _download_archive(self, archive_path):
if os.path.exists(archive_path):
return
total_bytes = 0
with urllib.request.urlopen(DOWNLOAD_URL) as fp_in:
with open(archive_path + '.partial', 'wb') as fp_out:
last_report = time.time()
try:
while True:
dat = fp_in.read(10240)
if not dat:
break
fp_out.write(dat)
total_bytes += len(dat)
if time.time() - last_report > 1:
sys.stderr.write(
'Downloading %s: %d bytes\r'
% (DOWNLOAD_URL, total_bytes))
sys.stderr.flush()
last_report = time.time()
finally:
sys.stderr.write('\033[K')
sys.stderr.flush()
os.rename(archive_path + '.partial', archive_path)
print('Downloaded %s: %d bytes' % (DOWNLOAD_URL, total_bytes))
def _unpack_archive(self, archive_path, src_dir):
if os.path.isdir(src_dir):
return
print("Extracting...")
base_dir = None
with tarfile.open(archive_path, 'r:bz2') as fp:
for path in fp.getnames():
while path:
path, b = os.path.split(path)
if not path:
if base_dir is None:
base_dir = b
elif b != base_dir:
raise RuntimeError(
"No common base dir (%s)" % b)
fp.extractall(self.build_base)
os.rename(os.path.join(self.build_base, base_dir), src_dir)
print("Extracted to %s" % src_dir)
return src_dir
def _configure(self, src_dir):
if os.path.exists(os.path.join(src_dir, '.configure.complete')):
return
print("Running waf configure...")
subprocess.run(
['./waf',
'configure',
'--prefix=%s' % os.getenv('VIRTUAL_ENV'),
],
cwd=src_dir,
env=dict(
os.environ,
PKG_CONFIG_PATH=os.path.join(os.getenv('VIRTUAL_ENV'), 'lib', 'pkgconfig')),
check=True)
open(os.path.join(src_dir, '.configure.complete'), 'w').close()
def _build(self, src_dir):
if os.path.exists(os.path.join(src_dir, '.build.complete')):
return
print("Running waf build...")
subprocess.run(
['./waf', 'build'],
cwd=src_dir,
check=True)
open(os.path.join(src_dir, '.build.complete'), 'w').close()
class InstallSuil(SuilMixin, core.Command):
@property
def sentinel_path(self):
return os.path.join(
os.getenv('VIRTUAL_ENV'), '.suil-%s-installed' % VERSION)
def run(self):
if os.path.exists(self.sentinel_path):
return
print("Running waf install...")
subprocess.run(
['./waf', 'install'],
cwd=self.src_dir,
check=True)
open(self.sentinel_path, 'w').close()
def get_outputs(self):
return [self.sentinel_path]
build.sub_commands.append(('build_suil', None))
install.sub_commands.insert(0, ('install_suil', None))
core.setup(
name = 'suil',
version = VERSION,
cmdclass = {
'build_suil': BuildSuil,
'install_suil': InstallSuil,
},
requires=['lv2'],
)

@ -36,11 +36,17 @@ set(ENV{PKG_CONFIG_PATH} $ENV{VIRTUAL_ENV}/lib/pkgconfig)
find_package(PkgConfig)
pkg_check_modules(LIBSRATOM REQUIRED sratom-0)
pkg_check_modules(LIBLILV REQUIRED lilv-0)
pkg_check_modules(LIBSUIL REQUIRED suil-0>=0.10.0)
pkg_check_modules(LIBSNDFILE REQUIRED sndfile)
pkg_check_modules(LIBFLUIDSYNTH REQUIRED fluidsynth)
pkg_check_modules(LIBFLUIDSYNTH REQUIRED fluidsynth>=1.1.6)
pkg_check_modules(LIBAVUTIL REQUIRED libavutil)
pkg_check_modules(LIBSWRESAMPLE REQUIRED libswresample)
pkg_check_modules(LIBPROTOBUF REQUIRED protobuf)
pkg_check_modules(LIBPROTOBUF REQUIRED protobuf>=3.0)
pkg_check_modules(LIBPORTAUDIO REQUIRED portaudio-2.0>=19)
pkg_check_modules(LIBUNWIND REQUIRED libunwind-generic>=1.1)
find_package(Qt4 4.8 REQUIRED QtGui)
find_package(GTK2 2.24 REQUIRED gtk)
set(LIBCSOUND_INCLUDE_DIRS)
set(LIBCSOUND_LIBRARIES csound64)
@ -143,6 +149,14 @@ macro(static_file src)
)
endmacro(static_file)
macro(render_csound src dest)
add_custom_command(
OUTPUT ${dest}
COMMAND LD_LIBRARY_PATH=$ENV{VIRTUAL_ENV}/lib csound -o${dest} ${CMAKE_CURRENT_LIST_DIR}/${src}
DEPENDS ${CMAKE_CURRENT_LIST_DIR}/${src}
)
endmacro(render_csound)
add_subdirectory(noisicaa)
add_subdirectory(noisidev)
add_subdirectory(data)

@ -1,30 +1,121 @@
# -*- org-tags-column: -98 -*-
* Gracefully handle Processor::setup() failures :FR:
* Expected SIGPIPE causes gdb to kick in :BUG:TESTING:
- can I just install a sig handler to ignore SIGPIPE?
* Convert backend_*_test.pyx to pure python :CLEANUP:TESTING:
- or at least make more use of Py* wrappers.
* Instrument library is broken :BUG:
INFO :29194:7f7f990a8700:noisicaa.core.ipc: PIPELINE_MUTATION(355c400b4dcb49f980691af733da0531, <AddNode name="Events" type=NodeDescription id='d64cc989c73f44b0a14b6d6cfd474619' track_id='instrument_library'>)
INFO :29194:7f7f990a8700:noisicaa.audioproc.audioproc_process: AddNode():
type: OTHER
display_name: "Events"
ports {
direction: OUTPUT
type: EVENTS
name: "out"
}
ERROR :29165:7f52460e7700:quamash._QEventLoop: Task exception was never retrieved
future: <Task finished coro=<InstrumentLibraryDialog.__instrumentLoader() done, defined at /home/pink/noisicaa/build/noisicaa/ui/instrument_library.py:607> exception=RemoteException('From server /tmp/noisicaa-20180316-051900-28513-athmzfpn/audioproc<main>.62c53d407584442f861ca52721658582.sock:\nTraceback (most recent call last):\n File "/home/pink/noisicaa/build/noisicaa/core/ipc.py", line 252, in handle_command\n result = await handler(*args, **kwargs)\n File "/home/pink/noisicaa/build/noisicaa/audioproc/audioproc_process.py", line 210, in handle_pipeline_mutation\n **mutation.args)\n File "/home/pink/noisicaa/build/noisicaa/audioproc/nodes.py", line 65, in create\n raise ValueError("Unsupported node type %d" % description.type)\nValueError: Unsupported node type 1\n',)>
ERROR :29165:7f52460e7700:root: Task exception was never retrieved:
NoneType
* Use a single protobuf for audioproc status updates :CLEANUP:
- PipelineNotification
- pipeline, player, node status updates and perf data
- and a single IPC call to post it to clients
- clients must subscribe to the updates that they want to receive
* Make Slot thread-safe and lock-free :FR:
- emit() might be called from any thread, incl. the audio thread
- either way there needs to be a lock-free queue that transfers state changes from the audio thread
into the non-realtime world (so it can then be pushed into the event loop).
- alternatively:
1) make Slot thread-safe, but not lock-free
- put lock-free queue into Processor that calls emit() from a non-realtime thread.
2) require strict phases:
- setup: only connect() can be called
- runtime: only emit() can be called
- cleanup: only disconnect() can be called
Then emit() does not need to acquire a lock
- OTOH processor state changes in the audiothread are probably catastrophic events anyway, so
taking a lock does cause any more damage either.
* Demangle function names in stacktraces :FR:
* Denoise build output :CLEANUP:
- get rid of all compiler warnings
- only dump csound output if it failed.
* Slot::Listener should disconnect on destruction :CLEANUP:
So I don't have to manually disconnect when descructing the owner.
Also foo_listener.disconnect() looks nicer than foo_slot.disconnect(foo_listener).
* redesign plugin handling :FR:
- Plugin UI slows down after turning the wheels a lot
- implement instance-access and data-access features on UIs.
- gracefully handle crashes of plugin host processes
- reuse BackendManager?
- notify UI on processor state changes (careful, when change happens in audio thread).
- needs async processor states?
- schedule async CLEAN when processor crashes
- use BufferManager in all tests that use buffers
- have an PrivateArena that doesn't use shared memory (pure python with bytearray).
- block_size changes must be done via Program changes
- how to synchronize this between processes?
- subprocess just rejects blocks with non-matching block_size, until its block_size has
been changes as well?
- a lot of plugin LV2 UIs require instance-access, would be sad not so support those
- so have to run the plugin UI in the same process as the plugin
- disadvantages:
- performance? context switch for every plugin call
- hypothetical plans of running multiple UIs against a single project will be difficult
- could do remote UI via X forwarding
- run plugins in a separate process from audioproc process
- single plugin per process?
- or multiple instances of same plugin per process?
- or multiple plugins with same UI type per process?
* Switch back to vanilla lilv :CLEANUP:
Implement UI feature query with the generic RDF API:
https://github.com/drobilla/lilv/pull/5#issuecomment-365869585
* Replace ipc.ConnectionClosed by core.ConnectionClosed :CLEANUP:
* Subprocesses should commit suicide then the parent process dies :BUG:
When process manager dies hard and doesn't cleanup properly
https://stackoverflow.com/questions/284325/how-to-make-child-process-die-after-parent-exits
* Improve core.Thread :FR:
- Add the boilerplate for
- telling the thread to quit
- waiting until the thread is ready
- simple way to re-raise an exception in the thread in the main event_loop.
- StatefulThread?
* Use core.Thread instead of threading.Thread, where it makes sense :CLEANUP:
* properly prepare atom output buffers :BUG:
- apparently an atom output buffer prefilled with a blank atom denoting the size of the buffer.
- where is that documented?
- size with or without atom header?
- any specific atom type?
* Native LV2 UI support :FR:
- helm works as a plugin, good candidate for a test
- helm UI has instance-access as required feature :(
- ref implementation:
- http://dev.drobilla.net/browser/suil
- http://dev.drobilla.net/browser/jalv
- spawn UI specific process for each plugin
- needs shared urid mapper for all processes
- pass port value changes from audioproc process to UI
- for every block cycle or rate limited to Xfps?
* LV2 support :FR:
** support zynaddsubfx
* support zynaddsubfx
- required features:
- http://lv2plug.in/ns/ext/worker#schedule
- http://lv2plug.in/ns/ext/options#options
- atom input port
- how to load instrument w/o UI?
** features
* Native UI support
- implement portNotification property
- look into extension data provides by UIs
- pass port value changes from audioproc process to UI
- for every block cycle or rate limited to Xfps?
* LV2 features
- plugins with unsupported features:
- include in NodeDB, but mark as non-functional, with reason text?
- provide features
@ -36,19 +127,63 @@
- http://lv2plug.in/ns/ext/buf-size/buf-size.html
- http://lv2plug.in/ns/ext/buf-size#fixedBlockLength
- http://lv2plug.in/ns/ext/buf-size#boundedBlockLength
** event/atom ports
* Use protobuf for BackendSettings :CLEANUP:
* Subprocesses should always shutdown cleanly :CLEANUP:
- notify manager before entering cleanup method
- set SubprocessHandle.state = STOPPING
- manager doesn't try to kill it, while in STOPPING, until some timeout passes
* Disentangle audioproc code :CLEANUP:
Convert as much as possible from noisicaa.audioproc.vm.engine to pure Python
- Make more use of PyFoo wrappers, instead of directly using C++ objects.
Clarify responsibilities of
- AudioProcServer
- PipelineVM
- VM
* Use ProcessManager in unittests :CLEANUP:
- single CREATE_PROCESS(cls, ...) command
- ProcessManager.add_process_class(cls, run_inline:bool, singleton:bool, ...)
* NodeDB should use separate subprocess to analyze plugins :FR:
- at least LADSPA requires dlopen'ing an .so file, which is dangerous
- if subprocess crashes, mark the plugin as broken
- reuse the same subprocesses, until done or it crashes (and the spawn a new one)
* Allow project specific block_size/sample_rate :FR:
ProcessorIPC does resampling and buffering to translate it to main engine.
* runtests crashes on some module if DISPLAY is not set :BUG:TESTING:
- noisicaa.ui.pipeline_graph_view_test
- noisicaa.ui.plugin_ui_process_test
Probably related to unittest.UITestCase
* runtests: disable gdb, if stdout is not a tty :FR:TESTING:
* Make the audio thread real-time safe :FR:
- no more python code in the main loop
- lock-free queue for log messages
* clean up pylint issues in pylint-unclean files :CLEANUP:
* clean up pylint issues in pylint-unclean files :CLEANUP:TESTING:
- grep -r pylint-unclean noisicaa/
- pick some file and clean it up.
- until grep finds no more files.
* Exlore pytest as a better unittest framework :RESEARCH:
* Exlore pytest as a better unittest framework :RESEARCH:TESTING:
- https://docs.pytest.org/en/latest/
- supports parallel test execution with pytest-xdist
* Add UI tests :RESEARCH:TESTING:
- any framework to use for testing Qt apps?
* Tag tests with a 'test class', select which tests to run :TESTING:
- decorator @unittest.XXX_TEST
- runtests --classes=xxx,yyy,...
- classes:
- unittest
- integration
- performance
- ui
- lint
* Revisit source directory structure :RESEARCH:
- move all sources into src/
- can't accidentally import modules from source
@ -95,12 +230,15 @@
that type.
- 'Open with...' drop down?
- 'Open directory in file manager'
- placeholders in file name
- $(project_name), $(sample_rate), ...
* Speed up project setup :FR:
- takes quite some time until a project is up and running.
- figure out what the bottlenecks are.
- a lot of messages are passes around. Anything that can be batched.
- how much time is the logging taking up?
- Batch set_control_value when initializing a node
- some nodes have a lot of control values...
* No cleanup in destructors :BUG:
- Was a bad idea: http://www.artima.com/cppsource/nevercall.html
@ -133,7 +271,7 @@ ERROR : 8298:7fbd9ef37700:ui.editor_app: Exception in callback: Traceback for
return stat
AssertionError
* Move various test helpers to noisidev.unittest :CLEANUP:
* Move various test helpers to noisidev.unittest :CLEANUP:TESTING:
- noisicaa.ui.uitest_utils
* Explore https://github.com/census-instrumentation for stats tracking :RESEARCH:
@ -208,7 +346,7 @@ to the backend.
E.g. use unlikely() when checking for error conditions.
Is there some cross-compiler/-platform header to provide this functionality?
* track syscalls in audio thread :FR:
* track syscalls in audio thread :FR:TESTING:
- seems non-trivial:
- ptrace can trace just a specific thread, but it must be in a subprocess of the tracer.
- calling ptrace() with the gettid() of the thread fails with EPERM
@ -299,11 +437,11 @@ Is there some cross-compiler/-platform header to provide this functionality?
- Use those for demo projects
- Also remove dependency on mda-lv2 and swh-plugins packages from demo projects
* Don't use system files in tests :CLEANUP:
* Don't use system files in tests :CLEANUP:TESTING:
- grep for '/usr/'
- build test ladspa plugins from source in testdata
* Full app run in vmtest :FR:
* Full app run in vmtest :FR:TESTING:
- bin/noisicaä --play-and-exit --demo="demo name"
* Track properties should directly modify mixer control values :CLEANUP:
@ -350,10 +488,6 @@ Is there some cross-compiler/-platform header to provide this functionality?
- bypass needs conditionals
- lv2 features
- make atom buffer size a param of hostsystem
- some test plugins to validate features
- point LV2_PATH to just those, so test don't see all plugins on a host
- move feature handling to noisicaa.lv2
- the node_db has access to the list of supported features, w/o duplicating code.
- ProcessorFluidSynth
- capture fluidsynth logs
- cache soundfonts in master instrument again
@ -365,7 +499,6 @@ Is there some cross-compiler/-platform header to provide this functionality?
- are there any other places, where I care about zero-copy deserialization?
- clean use of NodeDescription types
- which types are actually needed?
- always call cleanup() in destructor
- engine_perftest should focus on other opcodes than CALL
- use this pattern for C-only classes
https://github.com/cython/cython/wiki/FAQ#can-cython-create-objects-or-apply-operators-to-locally-created-objects-as-pure-c-code
@ -402,11 +535,22 @@ Is there some cross-compiler/-platform header to provide this functionality?
cython_module(foo) -> foo.so
etc. for pb, capnp, ...
* Built-in testcases :CLEANUP:
* Built-in testcases :CLEANUP:TESTING:
- for each file generate a built-in TestCase
- run some C++ linter and iwyu on *.cpp/*.h files.
- run mypy when using type declarations.
* Get pycheck working :RESEARCH:TESTING:
- seems much faster than mypy
- problems:
- no documentation
- requires python2.7, so can't be installed in the venv
- not installable via apt either
- doesn't find typeshed on its own, need to set TYPESHED_HOME
- needs --python_version=3.5 --python_exe=/usr/bin/python3.5
- crashes if it uses the python exe from the venv
- complains about super()
* Reduce duplication in noisicaa/music/*_test.py :CLEANUP:
- create TestProject class
- has dummy node_db (with builtin stuff and selected other stuff)
@ -414,7 +558,7 @@ Is there some cross-compiler/-platform header to provide this functionality?
* Capture per-node logs :FR:
- csound, lv2 log extensions, ...
- logs tab in node IU
* Improve noisicaa.core.stats_test :CLEANUP:
* Improve noisicaa.core.stats_test :CLEANUP:TESTING:
The module's code changed a lot, but the unittest wasn't updated.
* Loop start/end move around when BPM is changed :BUG:
@ -468,9 +612,6 @@ http://pyxdg.readthedocs.io/en/latest/recentfiles.html
** UI prefers showing port groups instead of individual ports, option to ungroup ports
** Implicit coercing of mono->stereo ports
* Use flatbuffers for RPC serialization :FR:
- https://github.com/google/flatbuffers
* Message router :FR:
- Send messages to ports, which might live in another process.
- Ports have a unique ID within its process.
@ -654,7 +795,6 @@ Blacklist crashing nodes
- no way to remove trailing measures from sheet
* Unify instrument handling in ScoreTrack and BeatTrack :CLEANUP:
* Move BackendManager to noisicaa.core :CLEANUP:
* Review licenses of all used modules :FR:
All compatible with GPL?
* SampleInstrument: tuning :FR:
@ -969,7 +1109,6 @@ otherwise set volume on outgoing samples.
* separate client, server and common code in music :CLEANUP:
* proper classes for mutations emitted from state.py :CLEANUP:
* move tests from state_test.py to model_base_test.py :CLEANUP:
* find a proper test sample for audio settings dialog :CLEANUP:
* move initial project mutations to BaseProject :CLEANUP:
* node_db imports all nodes and populates itself :CLEANUP:
* use registry instance instead of class attributes to track classes :CLEANUP:

@ -29,7 +29,7 @@ unsafe-load-any-extension=no
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code
extension-pkg-whitelist=PyQt5,lxml,noisicaa,posix_ipc
extension-pkg-whitelist=PyQt5,lxml,noisicaa,noisidev,posix_ipc
[REPORTS]
@ -189,7 +189,11 @@ ignored-modules=*_pb2,noisicaa.bindings.lilv
# List of classes names for which member attributes should not be checked
# (useful for classes with attributes dynamically set).
ignored-classes=SQLObject
# TODO: host_system is odd.. This is the HostSystemMixin.host_system *attribute*,
# but somehow pylint thinks it's a class. And then complaints about missing
# members, when uses self.host_system in tests.
# Probably caused because that mixin is implemented in Cython...
ignored-classes=SQLObject,host_system
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E0201 when accessed. Python regular
@ -339,7 +343,7 @@ int-import-graph=
[CLASSES]
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,__new__,setUp
defining-attr-methods=__init__,__new__,setUp,setup_testcase
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls

@ -18,7 +18,12 @@
#
# @end:license
install_files(
"data.sounds"
metronome.wav
static_file(metronome.wav)
render_csound(test_sound.csnd test_sound.wav)
add_custom_target(
"data.sounds" ALL
DEPENDS
metronome.wav
test_sound.wav
)

@ -0,0 +1,93 @@
<CsoundSynthesizer>
<CsLicense>
@begin:license
Copyright (c) 2015-2018, 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
Based on http://www.adp-gmbh.ch/csound/instruments/gong.html
</CsLicense>
<CsOptions>
-d
</CsOptions>
<CsInstruments>
sr = 44100
kr = 441
ksmps= 100
nchnls = 2
instr 1; *****************************************************************
ilen = p3
ifrq = cpspch(p4)
iamp = p5
ipan = p6
ifrq1 = 1.0000 * ifrq
ifrq2 = 1.1541 * ifrq
ifrq3 = 1.6041 * ifrq
ifrq4 = 1.5208 * ifrq
ifrq5 = 1.4166 * ifrq
ifrq6 = 2.7916 * ifrq
ifrq7 = 3.3833 * ifrq
iamp1 = 1.0000 * iamp
iamp2 = 0.8333 * iamp
iamp3 = 0.6667 * iamp
iamp4 = 1.0000 * iamp
iamp5 = 0.3333 * iamp
iamp6 = 0.3333 * iamp
iamp7 = 0.3333 * iamp
aenv1 oscili iamp1, 1/ilen, 2
aenv2 oscili iamp2, 1/ilen, 2
aenv3 oscili iamp3, 1/ilen, 2
aenv4 oscili iamp4, 1/ilen, 2
aenv5 oscili iamp5, 1/ilen, 2
aenv6 oscili iamp6, 1/ilen, 2
aenv7 oscili iamp7, 1/ilen, 2
asig1 oscili aenv1, ifrq1, 1
asig2 oscili aenv2, ifrq2, 1
asig3 oscili aenv3, ifrq3, 1
asig4 oscili aenv4, ifrq4, 1
asig5 oscili aenv5, ifrq5, 1
asig6 oscili aenv6, ifrq6, 1
asig7 oscili aenv7, ifrq7, 1
asig = asig1 + asig2 + asig3 + asig4 + asig5 + asig6 + asig7
i_sqrt2 = 1.414213562373095
i_theta = 3.141592653589793 * 45 * (1 - ipan) / 180
asig_l = i_sqrt2 * cos(i_theta) * asig
asig_r = i_sqrt2 * sin(i_theta) * asig
out asig_l, asig_r
endin
</CsInstruments>
<CsScore>
f1 0 512 9 1 1 0 ; basic waveform
f2 0 513 5 128 512 1 ; envelopes
i1 0 2 7.02 5000 -1
i1 0 3 6.02 5000 0
i1 0.3 2 7.06 4000 1
i1 0.3 3 6.06 4000 0
i1 0.6 2 7.04 3500 0
i1 0.6 3 6.04 3500 0
</CsScore>
</CsoundSynthesizer>

@ -48,9 +48,12 @@ PIP_DEPS = {
'runtime': [
PKG('./3rdparty/csound/', 'ubuntu', '<17.10'),
PKG('./3rdparty/lv2/', 'ubuntu', '<17.10'),
PKG('./3rdparty/lilv/', 'ubuntu', '<17.10'),
PKG('./3rdparty/lilv/'),
PKG('./3rdparty/suil/'),
PKG('PyGObject'),
PKG('PyQt5'),
PKG('eventfd'),
PKG('gbulb'),
PKG('numpy'),
PKG('portalocker'),
PKG('posix_ipc'),
@ -70,11 +73,13 @@ PIP_DEPS = {
],
'dev': [
PKG('asynctest'),
PKG('async_generator'),
PKG('coverage'),
PKG('mox3'),
PKG('py-cpuinfo'),
PKG('pyfakefs'),
PKG('pylint'),
PKG('mypy'),
],
'vmtests': [
PKG('paramiko'),
@ -94,12 +99,18 @@ SYS_DEPS = {
PKG('ffmpeg'),
],
'build': [
# qt4
PKG('libqt4-dev'),
# gtk2
PKG('libgtk2.0-dev'),
PKG('libgirepository1.0-dev'),
# lv2/lilv
PKG('libserd-dev'),
PKG('libsord-dev'),
PKG('libsratom-dev'),
PKG('lv2-dev', 'ubuntu', '>=17.10'),
PKG('liblilv-dev', 'ubuntu', '>=17.10'),
# ladspa
PKG('ladspa-sdk'),
@ -145,11 +156,13 @@ SYS_DEPS = {
PKG('libfluidsynth-dev'),
PKG('inkscape'),
PKG('zlib1g-dev'),
PKG('libunwind-dev'),
],
'dev': [
PKG('mda-lv2'),
PKG('swh-plugins'),
PKG('gdb'),
PKG('xvfb'),
],
'vmtests': [
PKG('virtualbox'),

@ -33,6 +33,7 @@ add_subdirectory(audioproc)
add_subdirectory(bindings)
add_subdirectory(core)
add_subdirectory(devices)
add_subdirectory(host_system)
add_subdirectory(importers)
add_subdirectory(instr)
add_subdirectory(instrument_db)

@ -24,11 +24,7 @@ add_python_package(
audioproc_process.py
exceptions.py
mutations.py
node.py
node_db.py
ports.py
)
add_subdirectory(nodes)
add_subdirectory(vm)
add_subdirectory(engine)
add_subdirectory(public)

@ -25,7 +25,6 @@ from .mutations import (
ConnectPorts,
DisconnectPorts,
SetPortProperty,
SetNodeParameter,
SetControlValue,
)
from .public import (

@ -37,6 +37,10 @@ class AudioProcClientMixin(object):
self._session_id = None
self.listeners = core.CallbackRegistry()
@property
def address(self):
return self._stub.server_address
async def setup(self):
await super().setup()
self.server.add_command_handler(
@ -57,8 +61,8 @@ class AudioProcClientMixin(object):
assert self._stub is None
self._stub = ipc.Stub(self.event_loop, address)
await self._stub.connect()
self._session_id = await self._stub.call(
'START_SESSION', self.server.address, flags)
self._session_id = await self._stub.call('START_SESSION', self.server.address, flags)
logger.info("Started session %s", self._session_id)
async def disconnect(self, shutdown=False):
if self._session_id is not None:
@ -78,41 +82,56 @@ class AudioProcClientMixin(object):
async def shutdown(self):
await self._stub.call('SHUTDOWN')
async def add_node(self, *, description, **args):
return await self.pipeline_mutation(
mutations.AddNode(description=description, **args))
async def ping(self):
await self._stub.ping()
async def create_realm(self, *, name, parent=None, enable_player=False):
await self._stub.call('CREATE_REALM', self._session_id, name, parent, enable_player)
async def delete_realm(self, name):
await self._stub.call('DELETE_REALM', self._session_id, name)
async def remove_node(self, node_id):
async def add_node(self, realm, *, description, **args):
return await self.pipeline_mutation(
mutations.RemoveNode(node_id))
realm, mutations.AddNode(description=description, **args))
async def connect_ports(self, node1_id, port1_name, node2_id, port2_name):
async def remove_node(self, realm, node_id):
return await self.pipeline_mutation(
mutations.ConnectPorts(node1_id, port1_name, node2_id, port2_name))
realm, mutations.RemoveNode(node_id))
async def disconnect_ports(self, node1_id, port1_name, node2_id, port2_name):
async def connect_ports(self, realm, node1_id, port1_name, node2_id, port2_name):
return await self.pipeline_mutation(
mutations.DisconnectPorts(node1_id, port1_name, node2_id, port2_name))
realm, mutations.ConnectPorts(node1_id, port1_name, node2_id, port2_name))
async def set_port_property(self, node_id, port_name, **kwargs):
async def disconnect_ports(self, realm, node1_id, port1_name, node2_id, port2_name):
return await self.pipeline_mutation(
mutations.SetPortProperty(node_id, port_name, **kwargs))
realm, mutations.DisconnectPorts(node1_id, port1_name, node2_id, port2_name))
async def set_node_parameter(self, node_id, **kwargs):
async def set_port_property(self, realm, node_id, port_name, **kwargs):
return await self.pipeline_mutation(
mutations.SetNodeParameter(node_id, **kwargs))
realm, mutations.SetPortProperty(node_id, port_name, **kwargs))
async def set_control_value(self, name, value):
async def set_control_value(self, realm, name, value):
return await self.pipeline_mutation(
mutations.SetControlValue(name, value))
realm, mutations.SetControlValue(name, value))
async def pipeline_mutation(self, realm, mutation):
return await self._stub.call(
'PIPELINE_MUTATION', self._session_id, realm, mutation)
async def create_plugin_ui(self, realm, node_id):
return await self._stub.call('CREATE_PLUGIN_UI', self._session_id, realm, node_id)
async def delete_plugin_ui(self, realm, node_id):
return await self._stub.call('DELETE_PLUGIN_UI', self._session_id, realm, node_id)
async def pipeline_mutation(self, mutation):
async def send_node_messages(self, realm, messages):
return await self._stub.call(
'PIPELINE_MUTATION', self._session_id, mutation)
'SEND_NODE_MESSAGES', self._session_id, realm, messages)
async def send_node_messages(self, messages):
async def set_host_parameters(self, **parameters):
return await self._stub.call(
'SEND_NODE_MESSAGES', self._session_id, messages)
'SET_HOST_PARAMETERS', self._session_id, parameters)
async def set_backend(self, name, **parameters):
return await self._stub.call(
@ -122,9 +141,9 @@ class AudioProcClientMixin(object):
return await self._stub.call(
'SET_BACKEND_PARAMETERS', self._session_id, parameters)
async def update_player_state(self, state):
async def update_player_state(self, realm, state):
return await self._stub.call(
'UPDATE_PLAYER_STATE', self._session_id, state)
'UPDATE_PLAYER_STATE', self._session_id, realm, state)
async def send_message(self, msg):
return await self._stub.call('SEND_MESSAGE', self._session_id, msg.to_bytes())
@ -136,9 +155,9 @@ class AudioProcClientMixin(object):
async def dump(self):
return await self._stub.call('DUMP', self._session_id)
async def update_project_properties(self, **kwargs):
async def update_project_properties(self, realm, **kwargs):
return await self._stub.call(
'UPDATE_PROJECT_PROPERTIES', self._session_id, kwargs)
'UPDATE_PROJECT_PROPERTIES', self._session_id, realm, kwargs)
def handle_pipeline_mutation(self, mutation):
logger.info("Mutation received: %s", mutation)
@ -146,5 +165,5 @@ class AudioProcClientMixin(object):
def handle_pipeline_status(self, status):
self.listeners.call('pipeline_status', status)
def handle_player_state(self, state):
self.listeners.call('player_state', state)
def handle_player_state(self, realm, state):
self.listeners.call('player_state', realm, state)

@ -21,15 +21,20 @@
# @end:license
import asyncio
import logging
import async_generator
from noisidev import unittest
from noisidev import unittest_mixins
from noisicaa import node_db
from noisicaa.constants import TEST_OPTS
from noisicaa.core import ipc
from . import audioproc_process
from . import audioproc_client
logger = logging.getLogger(__name__)
class TestClientImpl(object):
def __init__(self, event_loop):
@ -48,61 +53,130 @@ class TestClient(audioproc_client.AudioProcClientMixin, TestClientImpl):
pass
class ProxyTest(unittest.AsyncTestCase):
class AudioProcClientTest(
unittest_mixins.NodeDBMixin,
unittest_mixins.ProcessManagerMixin,
unittest.AsyncTestCase):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.client = None
self.audioproc_task = None
self.audioproc_process = None
async def setup_testcase(self):
self.passthru_description = node_db.ProcessorDescription(
processor_name='null',
self.passthru_description = node_db.NodeDescription(
type=node_db.NodeDescription.PROCESSOR,
ports=[
node_db.AudioPortDescription(
node_db.PortDescription(
name='in:left',
direction=node_db.PortDirection.Input),
node_db.AudioPortDescription(
direction=node_db.PortDescription.INPUT,
type=node_db.PortDescription.AUDIO,
),
node_db.PortDescription(
name='in:right',
direction=node_db.PortDirection.Input),
node_db.AudioPortDescription(
direction=node_db.PortDescription.INPUT,
type=node_db.PortDescription.AUDIO,
),
node_db.PortDescription(
name='out:left',
direction=node_db.PortDirection.Output),
node_db.AudioPortDescription(
direction=node_db.PortDescription.OUTPUT,
type=node_db.PortDescription.AUDIO,
),
node_db.PortDescription(
name='out:right',
direction=node_db.PortDirection.Output),
])
self.audioproc_process = audioproc_process.AudioProcProcess(
name='audioproc', event_loop=self.loop, manager=None, tmp_dir=TEST_OPTS.TMP_DIR)
await self.audioproc_process.setup()
self.audioproc_task = self.loop.create_task(self.audioproc_process.run())
self.client = TestClient(self.loop)
await self.client.setup()
await self.client.connect(self.audioproc_process.server.address)
async def cleanup_testcase(self):
if self.client is not None:
await self.client.disconnect(shutdown=True)
await self.client.cleanup()
if self.audioproc_process is not None:
if self.audioproc_task is not None:
await self.audioproc_process.shutdown()
await asyncio.wait_for(self.audioproc_task, None)
await self.audioproc_process.cleanup()
direction=node_db.PortDescription.OUTPUT,
type=node_db.PortDescription.AUDIO,
),
],
processor=node_db.ProcessorDescription(
type=node_db.ProcessorDescription.NULLPROC,
),
)
@async_generator.asynccontextmanager
@async_generator.async_generator
async def create_process(self, *, inline_plugin_host=True, inline_audioproc=True):
self.setup_urid_mapper_process(inline=True)
self.setup_plugin_host_process(inline=inline_plugin_host)
if inline_audioproc:
proc = await self.process_manager.start_inline_process(
name='audioproc',
entry='noisicaa.audioproc.audioproc_process.AudioProcProcess')
else:
proc = await self.process_manager.start_subprocess(
name='audioproc',
entry='noisicaa.audioproc.audioproc_process.AudioProcSubprocess')
client = TestClient(self.loop)
await client.setup()
await client.connect(proc.address)
try:
await client.create_realm(name='root')
await async_generator.yield_(client)
finally:
await client.disconnect(shutdown=True)
await client.cleanup()
await proc.wait()
async def test_realms(self):
async with self.create_process(inline_plugin_host=False) as client:
await client.create_realm(name='test', parent='root')
await client.add_node(
'root',
id='child', child_realm='test', description=node_db.Builtins.ChildRealmDescription)
await client.connect_ports('root', 'child', 'out:left', 'sink', 'in:left')
await client.connect_ports('root', 'child', 'out:right', 'sink', 'in:right')
await client.disconnect_ports('root', 'child', 'out:left', 'sink', 'in:left')
await client.disconnect_ports('root', 'child', 'out:right', 'sink', 'in:right')
await client.remove_node('root', 'child')
await client.delete_realm('test')
async def test_add_remove_node(self):
await self.client.add_node(id='test', description=self.passthru_description)
await self.client.remove_node('test')
async with self.create_process() as client:
await client.add_node('root', id='test', description=self.passthru_description)
await client.remove_node('root', 'test')
async def test_connect_ports(self):
await self.client.add_node(id='node1', description=self.passthru_description)
await self.client.add_node(id='node2', description=self.passthru_description)
await self.client.connect_ports('node1', 'out:left', 'node2', 'in:left')
async with self.create_process() as client:
await client.add_node('root', id='node1', description=self.passthru_description)
await client.add_node('root', id='node2', description=self.passthru_description)
await client.connect_ports('root', 'node1', 'out:left', 'node2', 'in:left')
async def test_disconnect_ports(self):
await self.client.add_node(id='node1', description=self.passthru_description)
await self.client.add_node(id='node2', description=self.passthru_description)
await self.client.connect_ports('node1', 'out:left', 'node2', 'in:left')
await self.client.disconnect_ports('node1', 'out:left', 'node2', 'in:left')
async with self.create_process() as client:
await client.add_node('root', id='node1', description=self.passthru_description)
await client.add_node('root', id='node2', description=self.passthru_description)
await client.connect_ports('root', 'node1', 'out:left', 'node2', 'in:left')
await client.disconnect_ports('root', 'node1', 'out:left', 'node2', 'in:left')
async def test_plugin_node(self):
async with self.create_process() as client:
plugin_uri = 'http://noisicaa.odahoda.de/plugins/test-passthru'
node_description = self.node_db[plugin_uri]
await client.add_node('root', id='test', description=node_description)
await client.remove_node('root', 'test')
async def test_plugin_host_process_crashes(self):
async with self.create_process(inline_plugin_host=False) as client:
is_broken = asyncio.Event(loop=self.loop)
def pipeline_status(status):
logger.info("pipeline_status(%s)", status)
if 'node_state' in status:
realm, node_id, node_state = status['node_state']
if realm == 'root' and node_id == 'test' and node_state == 'BROKEN':
is_broken.set()
client.listeners.add('pipeline_status', pipeline_status)
await client.set_backend('null')
plugin_uri = 'ladspa://crasher.so/crasher'
node_description = self.node_db[plugin_uri]
await client.add_node('root', id='test', description=node_description)
await asyncio.wait_for(is_broken.wait(), 10, loop=self.loop)
# TODO: this should not crash, when plugin host process is dead.
#await client.remove_node('test')

@ -26,268 +26,219 @@ import functools
import logging
import sys
import uuid
import io
import posix_ipc
from noisicaa import core
from noisicaa import node_db
from noisicaa.core import ipc
from noisicaa import lv2
from noisicaa import host_system
from . import vm
from . import engine
from . import mutations
from . import node_db as nodecls_db
from . import node
from . import nodes
logger = logging.getLogger(__name__)
class InvalidSessionError(Exception): pass
class Session(core.CallbackSessionMixin, core.SessionBase):
def __init__(self, client_address, flags, **kwargs):
super().__init__(callback_address=client_address, **kwargs)
self.__flags = flags or set()
self.__pending_mutations = []
self.owned_realms = set()
class Session(object):
def __init__(self, event_loop, callback_stub, flags):
self.event_loop = event_loop
self.callback_stub = callback_stub
self.flags = flags or set()
self.id = uuid.uuid4().hex
self.pending_mutations = []
# async def setup(self):
# await super().setup()
async def cleanup(self):
if self.callback_stub is not None:
await self.callback_stub.close()
self.callback_stub = None
# Send initial mutations to build up the current pipeline
# state.
# TODO: reanimate
# for node in self.__engine.nodes:
# mutation = mutations.AddNode(node)
# session.publish_mutation(mutation)
# for node in self.__engine.nodes:
# for port in node.inputs.values():
# for upstream_port in port.inputs:
# mutation = mutations.ConnectPorts(
# upstream_port, port)
# session.publish_mutation(mutation)
def callback_connected(self):
while self.__pending_mutations:
self.publish_mutation(self.__pending_mutations.pop(0))
def publish_mutation(self, mutation):
if not self.callback_stub.connected:
self.pending_mutations.append(mutation)
if not self.callback_alive:
self.__pending_mutations.append(mutation)
return
callback_task = self.event_loop.create_task(
self.callback_stub.call('PIPELINE_MUTATION', mutation))
callback_task.add_done_callback(self.publish_mutation_done)
self.async_callback('PIPELINE_MUTATION', mutation)
def publish_mutation_done(self, callback_task):
assert callback_task.done()
exc = callback_task.exception()
if exc is not None:
logger.error(
"PUBLISH_MUTATION failed with exception: %s", exc)
def publish_player_state(self, state):
if not self.callback_stub.connected:
def publish_player_state(self, realm, state):
if realm not in self.owned_realms:
return
callback_task = self.event_loop.create_task(
self.callback_stub.call('PLAYER_STATE', state))
callback_task.add_done_callback(self.publish_player_state_done)
if not self.callback_alive:
return
def publish_player_state_done(self, callback_task):
assert callback_task.done()
exc = callback_task.exception()
if exc is not None:
logger.error(
"PLAYER_STATE failed with exception: %s", exc)
self.async_callback('PLAYER_STATE', realm, state)
def publish_status(self, status):
if not self.callback_alive:
return
status = dict(status)
if 'perf_data' not in self.flags and 'perf_data' in status:
if 'perf_data' not in self.__flags and 'perf_data' in status:
del status['perf_data']
if status:
callback_task = self.event_loop.create_task(
self.callback_stub.call('PIPELINE_STATUS', status))
callback_task.add_done_callback(self.publish_status_done)
self.async_callback('PIPELINE_STATUS', status)
def publish_status_done(self, callback_task):
assert callback_task.done()
exc = callback_task.exception()
if exc is not None:
buf = io.StringIO()
callback_task.print_stack(file=buf)
logger.error("PUBLISH_STATUS failed with exception: %s\n%s", exc, buf.getvalue())
def callback_stub_connected(self):
assert self.callback_stub.connected
while self.pending_mutations:
self.publish_mutation(self.pending_mutations.pop(0))
class AudioProcProcess(core.SessionHandlerMixin, core.ProcessBase):
session_cls = Session
class AudioProcProcess(core.ProcessBase):
def __init__(
self, *,
shm=None, profile_path=None, enable_player=False,
shm=None, profile_path=None, block_size=None, sample_rate=None,
**kwargs):
super().__init__(**kwargs)
self.shm_name = shm
self.profile_path = profile_path
self.shm = None
self.__enable_player = enable_player
self.__host_data = None
self.__vm = None
self.sessions = {}
self.nodecls_db = None
self.__urid_mapper = None
self.__block_size = block_size
self.__sample_rate = sample_rate
self.__host_system = None
self.__engine = None
async def setup(self):
await super().setup()
self.server.add_command_handler(
'START_SESSION', self.handle_start_session)
self.server.add_command_handler(
'END_SESSION', self.handle_end_session)
self.server.add_command_handler('SHUTDOWN', self.shutdown)
self.server.add_command_handler(
'SET_BACKEND', self.handle_set_backend)
self.server.add_command_handler(
'SET_BACKEND_PARAMETERS', self.handle_set_backend_parameters)
self.server.add_command_handler(
'SEND_MESSAGE', self.handle_send_message)
self.server.add_command_handler(
'PLAY_FILE', self.handle_play_file)
self.server.add_command_handler(
'PIPELINE_MUTATION', self.handle_pipeline_mutation)
self.server.add_command_handler(
'SEND_NODE_MESSAGES', self.handle_send_node_messages)
self.server.add_command_handler(
'UPDATE_PLAYER_STATE', self.handle_update_player_state)
self.server.add_command_handler('CREATE_REALM', self.__handle_create_realm)
self.server.add_command_handler('DELETE_REALM', self