diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index dbf2d8d485..fb0b447234 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -28,6 +28,10 @@ if(APPLE OR WIN32 OR TARGET X11::X11) if(TARGET score_plugin_clap) add_subdirectory(clappuppet) endif() + + if(TARGET score_plugin_lv2) + add_subdirectory(lv2puppet) + endif() endif() if(LINUX) diff --git a/src/lv2puppet/CMakeLists.txt b/src/lv2puppet/CMakeLists.txt new file mode 100644 index 0000000000..9b1606c6ee --- /dev/null +++ b/src/lv2puppet/CMakeLists.txt @@ -0,0 +1,76 @@ +project(lv2puppet CXX) + +find_path(LV2_PATH lv2/lv2plug.in/ns/ext/atom/atom.h + HINTS "${OSSIA_SDK}/lv2/include") +if(NOT TARGET Lilv) + find_package(Lilv) +endif() +if(NOT TARGET Lilv OR NOT LV2_PATH) + message(STATUS "lv2puppet: Lilv or LV2_PATH not found, skipping") + return() +endif() + +add_executable(ossia-score-lv2puppet WIN32 + lv2puppet.cpp + ../vstpuppet/window.cpp +) +target_compile_definitions(ossia-score-lv2puppet PRIVATE HAS_LV2) +target_link_libraries( + ossia-score-lv2puppet + PRIVATE + $ + $ + Lilv + Threads::Threads + ${CMAKE_DL_LIBS}) + +if(APPLE) + find_library(Foundation_FK Foundation) + find_library(Cocoa_FK Cocoa) + target_link_libraries(ossia-score-lv2puppet PRIVATE + ${Foundation_FK} + ${Cocoa_FK} + ) +elseif(WIN32) + target_link_libraries(ossia-score-lv2puppet PRIVATE user32 gdi32 ws2_32) +else() + target_include_directories(ossia-score-lv2puppet PRIVATE "${X11_X11_INCLUDE_PATH}") +endif() + +target_include_directories( + ossia-score-lv2puppet + PRIVATE + "${SCORE_SRC}/lib" + "${LV2_PATH}" +) + +setup_score_common_exe_features(ossia-score-lv2puppet) + +set_target_properties( + ossia-score-lv2puppet + PROPERTIES + DISABLE_PRECOMPILE_HEADERS TRUE +) + +if(APPLE AND SCORE_DEPLOYMENT_BUILD) + set_target_properties( + ossia-score-lv2puppet + PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/Info.plist.in" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/ossia score.app/Contents/MacOS") + install( + TARGETS ossia-score-lv2puppet + BUNDLE DESTINATION "ossia score.app/Contents/MacOS" + COMPONENT OssiaScore) +elseif(WIN32) + install( + TARGETS ossia-score-lv2puppet + RUNTIME DESTINATION "${SCORE_BIN_INSTALL_DIR}" + COMPONENT OssiaScore) +else() + install( + TARGETS ossia-score-lv2puppet + RUNTIME DESTINATION bin + COMPONENT OssiaScore) +endif() diff --git a/src/lv2puppet/Info.plist.in b/src/lv2puppet/Info.plist.in new file mode 100644 index 0000000000..cd5d7bf621 --- /dev/null +++ b/src/lv2puppet/Info.plist.in @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + ossia-score-lv2puppet + CFBundleGetInfoString + ossia-score-lv2puppet + CFBundleIconFile + + CFBundleIdentifier + io.ossia.lv2puppet + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLongVersionString + @SCORE_VERSION@ + CFBundleName + ossia-score-lv2puppet + CFBundlePackageType + APPL + CFBundleShortVersionString + @SCORE_VERSION@ + CFBundleSignature + ???? + CFBundleVersion + @SCORE_VERSION@ + CSResourcesFileMapped + + LSRequiresCarbon + + NSHumanReadableCopyright + + NSHighResolutionCapable + + + diff --git a/src/lv2puppet/entitlements.plist b/src/lv2puppet/entitlements.plist new file mode 100644 index 0000000000..4f4a998976 --- /dev/null +++ b/src/lv2puppet/entitlements.plist @@ -0,0 +1,38 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.app-sandbox + + com.apple.security.cs.disable-library-validation + + com.apple.security.assets.music.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.device.firewire + + com.apple.security.device.microphone + + com.apple.security.device.usb + + com.apple.security.device.audio-input + + com.apple.security.device.camera + + com.apple.security.device.bluetooth + + com.apple.security.network.server + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.bookmarks.app-scope + + + diff --git a/src/lv2puppet/lv2puppet.cpp b/src/lv2puppet/lv2puppet.cpp new file mode 100644 index 0000000000..9bb367b074 --- /dev/null +++ b/src/lv2puppet/lv2puppet.cpp @@ -0,0 +1,553 @@ +#include +#include + +#include +#include +#include +#include + +#if defined(_MSC_VER) +#include +#endif + +// https://svn.boost.org/trac10/ticket/3605 +#if defined(_MSC_VER) +#include +#pragma warning(push) +#pragma warning(disable : 4073) +#pragma init_seg(lib) +boost::asio::detail::winsock_init<2, 2>::manual manual_winsock_init; +#pragma warning(pop) +#elif defined(_WIN32) +#include +#endif + +#if !defined(__cpp_exceptions) +#include +namespace boost +{ +void throw_exception(std::exception const& e) +{ + std::terminate(); +} +void throw_exception(std::exception const& e, boost::source_location const& loc) +{ + std::terminate(); +} +} +#endif + +#include + +#include +#include +#include +#include +#include + +namespace +{ +std::string json_escape(const char* s) +{ + if(!s) + return {}; + std::string out; + out.reserve(std::strlen(s) + 8); + for(const char* p = s; *p; ++p) + { + unsigned char c = static_cast(*p); + switch(c) + { + case '"': + out += "\\\""; + break; + case '\\': + out += "\\\\"; + break; + case '\b': + out += "\\b"; + break; + case '\f': + out += "\\f"; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + if(c < 0x20) + out += fmt::format("\\u{:04x}", (int)c); + else + out += (char)c; + break; + } + } + return out; +} + +std::string node_string(const LilvNode* n) +{ + if(!n) + return {}; + if(const char* s = lilv_node_as_string(n)) + return json_escape(s); + return {}; +} + +std::string node_uri(const LilvNode* n) +{ + if(!n) + return {}; + if(lilv_node_is_uri(n)) + { + if(const char* s = lilv_node_as_uri(n)) + return json_escape(s); + } + if(const char* s = lilv_node_as_string(n)) + return json_escape(s); + return {}; +} + +struct PortCounts +{ + int audio_in = 0; + int audio_out = 0; + int midi_in = 0; + int midi_out = 0; + int atom_in = 0; + int atom_out = 0; + int control_in = 0; + int control_out = 0; + int cv_in = 0; +}; + +PortCounts count_ports(LilvWorld* world, const LilvPlugin* p) +{ + PortCounts c; + LilvNode* lv2_InputPort = lilv_new_uri(world, LILV_URI_INPUT_PORT); + LilvNode* lv2_OutputPort = lilv_new_uri(world, LILV_URI_OUTPUT_PORT); + LilvNode* lv2_AudioPort = lilv_new_uri(world, LILV_URI_AUDIO_PORT); + LilvNode* lv2_ControlPort = lilv_new_uri(world, LILV_URI_CONTROL_PORT); + LilvNode* lv2_AtomPort = lilv_new_uri(world, LV2_ATOM__AtomPort); + LilvNode* lv2_CVPort = lilv_new_uri(world, LV2_CORE__CVPort); + LilvNode* lv2_MidiEvent = lilv_new_uri(world, LILV_URI_MIDI_EVENT); + // atom:supports avoids the deprecated ev:supportsEvent path + LilvNode* atom_supports = lilv_new_uri(world, LV2_ATOM__supports); + + const uint32_t n = lilv_plugin_get_num_ports(p); + for(uint32_t i = 0; i < n; ++i) + { + const LilvPort* port = lilv_plugin_get_port_by_index(p, i); + if(!port) + continue; + const bool in = lilv_port_is_a(p, port, lv2_InputPort); + const bool out = lilv_port_is_a(p, port, lv2_OutputPort); + + if(lilv_port_is_a(p, port, lv2_AudioPort)) + { + if(in) + ++c.audio_in; + else if(out) + ++c.audio_out; + } + else if(lilv_port_is_a(p, port, lv2_AtomPort)) + { + LilvNodes* supports = lilv_port_get_value(p, port, atom_supports); + const bool is_midi + = supports && lilv_nodes_contains(supports, lv2_MidiEvent); + lilv_nodes_free(supports); + if(is_midi) + { + if(in) + ++c.midi_in; + else if(out) + ++c.midi_out; + } + else + { + if(in) + ++c.atom_in; + else if(out) + ++c.atom_out; + } + } + else if(lilv_port_is_a(p, port, lv2_CVPort)) + { + if(in) + ++c.cv_in; + } + else if(lilv_port_is_a(p, port, lv2_ControlPort)) + { + if(in) + ++c.control_in; + else if(out) + ++c.control_out; + } + } + + lilv_node_free(atom_supports); + lilv_node_free(lv2_InputPort); + lilv_node_free(lv2_OutputPort); + lilv_node_free(lv2_AudioPort); + lilv_node_free(lv2_ControlPort); + lilv_node_free(lv2_AtomPort); + lilv_node_free(lv2_CVPort); + lilv_node_free(lv2_MidiEvent); + return c; +} + +std::string make_bundle_uri(std::string path) +{ + for(char& c : path) + if(c == '\\') + c = '/'; + while(!path.empty() && path.back() == '/') + path.pop_back(); + // Windows drive paths: file:// + C:/foo -> file:///C:/foo + if(!path.empty() && path[0] != '/') + return std::string{"file:///"} + path + "/"; + return std::string{"file://"} + path + "/"; +} + +std::string scan_bundle(const std::string& bundle_path, int id) +{ + try + { + if(!std::filesystem::exists(bundle_path)) + { + std::cerr << "Invalid bundle path: " << bundle_path << std::endl; + return fmt::format( + R"_({{"Bundle":"{}","Request":{},"Error":"Bundle not found","Plugins":[]}})_", + json_escape(bundle_path.c_str()), id); + } + + LilvWorld* world = lilv_world_new(); + if(!world) + { + return fmt::format( + R"_({{"Bundle":"{}","Request":{},"Error":"lilv_world_new failed","Plugins":[]}})_", + json_escape(bundle_path.c_str()), id); + } + + const std::string bundle_uri_str = make_bundle_uri(bundle_path); + LilvNode* bundle_uri = lilv_new_uri(world, bundle_uri_str.c_str()); + if(!bundle_uri) + { + lilv_world_free(world); + return fmt::format( + R"_({{"Bundle":"{}","Request":{},"Error":"Invalid bundle URI","Plugins":[]}})_", + json_escape(bundle_path.c_str()), id); + } + + // Avoid lilv_world_load_all: one malformed manifest would crash this scan + if(const char* specs_env = std::getenv("LV2PUPPET_SPECS")) + { +#if defined(_WIN32) + const char sep = ';'; +#else + const char sep = ':'; +#endif + std::string_view specs{specs_env}; + while(!specs.empty()) + { + auto pos = specs.find(sep); + std::string spec_path + = std::string{specs.substr(0, pos == std::string_view::npos ? specs.size() : pos)}; + specs.remove_prefix(pos == std::string_view::npos ? specs.size() : pos + 1); + if(spec_path.empty()) + continue; + const std::string spec_uri = make_bundle_uri(spec_path); + if(LilvNode* spec_node = lilv_new_uri(world, spec_uri.c_str())) + { + lilv_world_load_bundle(world, spec_node); + lilv_node_free(spec_node); + } + } + } + lilv_world_load_bundle(world, bundle_uri); + + lilv_world_load_specifications(world); + lilv_world_load_plugin_classes(world); + + const LilvPlugins* plugins = lilv_world_get_all_plugins(world); + + std::string plugins_json; + bool first = true; + + LilvNode* pset_node = lilv_new_uri(world, LV2_PRESETS__Preset); + LilvNode* rdfs_seeAlso + = lilv_new_uri(world, "http://www.w3.org/2000/01/rdf-schema#seeAlso"); + + LILV_FOREACH(plugins, it, plugins) + { + const LilvPlugin* p = lilv_plugins_get(plugins, it); + if(!p) + continue; + + // Emit only plug-ins from the requested bundle + const LilvNode* p_bundle = lilv_plugin_get_bundle_uri(p); + const char* p_bundle_str = p_bundle ? lilv_node_as_string(p_bundle) : nullptr; + if(!p_bundle_str || bundle_uri_str != p_bundle_str) + continue; + + const LilvNode* uri_node = lilv_plugin_get_uri(p); + const std::string uri = node_uri(uri_node); + + LilvNode* name_node = lilv_plugin_get_name(p); + const std::string name = node_string(name_node); + if(name_node) + lilv_node_free(name_node); + + std::string class_label; + if(const LilvPluginClass* cls = lilv_plugin_get_class(p)) + { + const LilvNode* lab = lilv_plugin_class_get_label(cls); + if(lab) + class_label = node_string(lab); + } + + LilvNode* author_node = lilv_plugin_get_author_name(p); + const std::string author = node_string(author_node); + if(author_node) + lilv_node_free(author_node); + + LilvNode* homepage_node = lilv_plugin_get_author_homepage(p); + const std::string doc_link = node_string(homepage_node); + if(homepage_node) + lilv_node_free(homepage_node); + + const PortCounts pc = count_ports(world, p); + + bool has_ui = false; + std::string ui_uris_json = "["; + bool first_ui = true; + if(LilvUIs* uis = lilv_plugin_get_uis(p)) + { + LILV_FOREACH(uis, ui_it, uis) + { + const LilvUI* ui = lilv_uis_get(uis, ui_it); + if(!ui) + continue; + has_ui = true; + const LilvNodes* types = lilv_ui_get_classes(ui); + if(types) + { + LILV_FOREACH(nodes, t_it, types) + { + const LilvNode* t = lilv_nodes_get(types, t_it); + if(!t) + continue; + if(!first_ui) + ui_uris_json += ","; + ui_uris_json += "\"" + node_uri(t) + "\""; + first_ui = false; + } + } + } + lilv_uis_free(uis); + } + ui_uris_json += "]"; + + // rdfs:seeAlso points to the bundle TTL; parent dir is the preset bundle + std::string preset_uris_json = "["; + std::string preset_bundles_json = "["; + bool first_pb = true; + if(LilvNodes* presets = lilv_plugin_get_related(p, pset_node)) + { + LILV_FOREACH(nodes, pit, presets) + { + const LilvNode* preset = lilv_nodes_get(presets, pit); + if(!preset) + continue; + + std::string bundle_uri; + if(LilvNodes* seeAlso + = lilv_world_find_nodes(world, preset, rdfs_seeAlso, nullptr)) + { + if(const LilvNode* sa = lilv_nodes_get_first(seeAlso)) + { + if(const char* sa_str = lilv_node_as_uri(sa)) + { + std::string s{sa_str}; + auto slash = s.find_last_of('/'); + if(slash != std::string::npos) + bundle_uri = s.substr(0, slash + 1); + } + } + lilv_nodes_free(seeAlso); + } + if(bundle_uri.empty()) + { + // Fall back to preset URI's parent; raw URI (json_escape runs below) + const char* raw = nullptr; + if(lilv_node_is_uri(preset)) + raw = lilv_node_as_uri(preset); + else if(lilv_node_as_string(preset)) + raw = lilv_node_as_string(preset); + if(raw) + { + std::string s{raw}; + auto slash = s.find_last_of('/'); + if(slash != std::string::npos) + bundle_uri = s.substr(0, slash + 1); + } + } + + if(!first_pb) + { + preset_uris_json += ","; + preset_bundles_json += ","; + } + preset_uris_json += "\"" + node_uri(preset) + "\""; + preset_bundles_json += "\"" + json_escape(bundle_uri.c_str()) + "\""; + first_pb = false; + } + lilv_nodes_free(presets); + } + preset_uris_json += "]"; + preset_bundles_json += "]"; + + auto plugin_json = fmt::format( + R"_({{"uri":"{}","name":"{}","class":"{}","author":"{}","documentationLink":"{}","audio_in":{},"audio_out":{},"midi_in":{},"midi_out":{},"control_in":{},"control_out":{},"atom_in":{},"atom_out":{},"cv_in":{},"has_ui":{},"ui_uris":{},"preset_uris":{},"preset_bundles":{}}})_", + uri, name, class_label, author, doc_link, + pc.audio_in, pc.audio_out, pc.midi_in, pc.midi_out, + pc.control_in, pc.control_out, pc.atom_in, pc.atom_out, pc.cv_in, + has_ui ? "true" : "false", ui_uris_json, preset_uris_json, + preset_bundles_json); + + if(!first) + plugins_json += ",\n"; + plugins_json += plugin_json; + first = false; + } + + lilv_node_free(rdfs_seeAlso); + lilv_node_free(pset_node); + lilv_node_free(bundle_uri); + lilv_world_free(world); + + return fmt::format( + R"_({{"Bundle":"{}","Request":{},"Plugins":[{}]}})_", + json_escape(bundle_path.c_str()), id, plugins_json); + } + catch(const std::exception& e) + { + std::cerr << "Exception: " << e.what() << std::endl; + return fmt::format( + R"_({{"Bundle":"{}","Request":{},"Error":"Exception: {}","Plugins":[]}})_", + json_escape(bundle_path.c_str()), id, json_escape(e.what())); + } + catch(...) + { + std::cerr << "Unknown exception" << std::endl; + return fmt::format( + R"_({{"Bundle":"{}","Request":{},"Error":"Unknown exception","Plugins":[]}})_", + json_escape(bundle_path.c_str()), id); + } +} + +struct app +{ + boost::asio::io_context ctx; + ossia::net::websocket_simple_client socket{ + {.url = "ws://127.0.0.1:37590"}, ctx}; + + bool socket_ready{}, lv2_ready{}; + std::string json_ret; + + app() + { + socket.on_open.connect<&app::on_open>(*this); + socket.on_fail.connect<&app::on_error>(*this); + socket.on_close.connect<&app::on_error>(*this); + + socket.websocket_client::connect("ws://127.0.0.1:37590"); + } + + std::string bundle_for_log; + + // _Exit: websocketpp/asio destructors throw from internals past our handlers + void finish_success() { std::_Exit(json_ret.empty() ? 1 : 0); } + + void on_ready() + { + if(socket_ready && lv2_ready) + { + try + { + socket.send_message(json_ret); + } + catch(...) + { + on_error(); + return; + } + + // Flush delay: send_message is async; large payloads (~MB) need time + auto delay = std::make_shared(ctx); + delay->expires_after(std::chrono::milliseconds(500)); + delay->async_wait([this, delay](auto) { finish_success(); }); + } + } + + void on_error() + { + auto line = fmt::format("[lv2puppet {}] socket error\n", bundle_for_log); + std::fwrite(line.data(), 1, line.size(), stderr); + std::fflush(stderr); + std::_Exit(1); + } + + void load(const std::string& bundle, int id) + { + auto pos = bundle.find_last_of('/'); + bundle_for_log = (pos == std::string::npos) ? bundle : bundle.substr(pos + 1); + json_ret = scan_bundle(bundle, id); + // No stdout echo: parent pipe is never drained; large JSON would block + lv2_ready = true; + on_ready(); + } + + void on_open() + { + socket_ready = true; + on_ready(); + } +}; +} + +void init_invisible_window(); +int main(int argc, char** argv) +{ + if(argc <= 1) + return 1; + + init_invisible_window(); + + int id = 0; + if(argc > 2) + id = std::atoi(argv[2]); + + app a; + + boost::asio::post(a.ctx, [&] { a.load(argv[1], id); }); + + boost::asio::steady_timer tm{a.ctx}; + tm.expires_after(std::chrono::seconds(10)); + tm.async_wait([](auto ec) { + std::cerr << "Timeout\n"; + exit(1); + }); + + a.ctx.run(); + a.ctx.restart(); + a.ctx.run(); + return a.json_ret.empty() ? 1 : 0; +} + +#include diff --git a/src/plugins/score-plugin-lv2/LV2/ApplicationPlugin.cpp b/src/plugins/score-plugin-lv2/LV2/ApplicationPlugin.cpp index c0d0404863..91e44bec6e 100644 --- a/src/plugins/score-plugin-lv2/LV2/ApplicationPlugin.cpp +++ b/src/plugins/score-plugin-lv2/LV2/ApplicationPlugin.cpp @@ -10,19 +10,53 @@ #include #include +#include #include #include #include +#include +#include +#include +#include +#include #include +#include #include #include #include +#include +#include +#include +#include +#include #include W_OBJECT_IMPL(LV2::ApplicationPlugin) +SCORE_SERALIZE_DATASTREAM_DEFINE(LV2::PluginInfo) +SCORE_SERALIZE_DATASTREAM_DEFINE(std::vector) + +template <> +void DataStreamReader::read(const LV2::PluginInfo& p) +{ + m_stream << p.bundle << p.uri << p.name << p.class_label << p.author + << p.documentationLink << p.mtime << p.size << p.audio_in << p.audio_out + << p.midi_in << p.midi_out << p.control_in << p.control_out << p.atom_in + << p.atom_out << p.cv_in << p.has_ui << p.ui_uris << p.preset_uris + << p.preset_bundles << p.valid; +} +template <> +void DataStreamWriter::write(LV2::PluginInfo& p) +{ + m_stream >> p.bundle >> p.uri >> p.name >> p.class_label >> p.author + >> p.documentationLink >> p.mtime >> p.size >> p.audio_in >> p.audio_out + >> p.midi_in >> p.midi_out >> p.control_in >> p.control_out >> p.atom_in + >> p.atom_out >> p.cv_in >> p.has_ui >> p.ui_uris >> p.preset_uris + >> p.preset_bundles >> p.valid; +} + namespace LV2 { void on_uiMessage( @@ -72,7 +106,11 @@ ApplicationPlugin::ApplicationPlugin(const score::ApplicationContext& app) : score::ApplicationPlugin{app} , lv2_context{std::make_unique(64, lv2_host_context)} , lv2_host_context{lv2_context.get(), nullptr, lv2_context->features(), lilv} + , m_wsServer("lv2-notification-server", QWebSocketServer::NonSecureMode) { + qRegisterMetaType(); + qRegisterMetaType>(); + if(!qEnvironmentVariableIsEmpty("SCORE_DISABLE_AUDIOPLUGINS")) return; @@ -84,7 +122,6 @@ ApplicationPlugin::ApplicationPlugin(const score::ApplicationContext& app) lv2_context->ui_host = suil.host_new(LV2::on_uiMessage, LV2::port_index, nullptr, nullptr); - // initialize thread-pool & some initial data buffers score::TaskPool::instance(); for(int i = 0; i < 100; i++) { @@ -92,19 +129,592 @@ ApplicationPlugin::ApplicationPlugin(const score::ApplicationContext& app) v.reserve(65535); lv2_host_context.release_worker_data(std::move(v)); } + + // rescan rebuilds the lilv index, invalidating cached LilvPlugin* in find_lv2_plugin + QObject::connect( + this, &ApplicationPlugin::descriptorsChanged, this, + [] { LV2::clearPluginCache(); }); + + // Coalesce disk writes + tree rebuilds: one per batch, not one per puppet + m_persistTimer = new QTimer{this}; + m_persistTimer->setSingleShot(true); + m_persistTimer->setInterval(500); + QObject::connect(m_persistTimer, &QTimer::timeout, this, [this] { + persistCache(); + descriptorsChanged(); + }); + + if(!m_wsServer.listen(QHostAddress::LocalHost, 37590)) + { + qWarning() << "LV2: failed to start scanner WebSocket on 127.0.0.1:37590 -" + << m_wsServer.errorString() + << "- bundle scanning will be unavailable"; + } + con(m_wsServer, &QWebSocketServer::newConnection, this, [this] { + QWebSocket* ws = m_wsServer.nextPendingConnection(); + if(!ws) + return; + + // Default Qt limits reject the multi-MB payloads from large bundles (lsp-plugins, ...) + ws->setMaxAllowedIncomingFrameSize(64 * 1024 * 1024); + ws->setMaxAllowedIncomingMessageSize(64 * 1024 * 1024); + + // Defer deleteLater so the puppet closes its side first; sync FIN -> spurious exit 1 + connect(ws, &QWebSocket::textMessageReceived, this, [this, ws](const QString& txt) { + QObject::disconnect(ws, &QWebSocket::textMessageReceived, nullptr, nullptr); + processIncomingMessage(txt); + QTimer::singleShot(1000, ws, [ws] { ws->deleteLater(); }); + }); + }); } void ApplicationPlugin::initialize() { lv2_context->sampleRate = context.settings().getRate(); + + if(!qEnvironmentVariableIsEmpty("SCORE_DISABLE_AUDIOPLUGINS")) + return; + if(!qEnvironmentVariableIsEmpty("SCORE_DISABLE_LV2")) + return; + + { + QSettings s; + auto val = s.value("Effect/KnownLV2"); + if(val.canConvert>()) + m_plugins = val.value>(); + } + + descriptorsChanged(); + + rescanBundles(); } ApplicationPlugin::~ApplicationPlugin() { - abort_library_scan = true; - std::lock_guard lck{library_lock}; + for(auto& proc : m_processes) + { + if(proc.second) + { + proc.second->terminate(); + if(!proc.second->waitForFinished(100)) + proc.second->kill(); + } + } suil.host_free(lv2_context->ui_host); } +const PluginInfo* +ApplicationPlugin::findDescriptor(const QString& uri_or_bundle) const noexcept +{ + for(const auto& p : m_plugins) + { + if(p.valid && p.uri == uri_or_bundle) + return &p; + } + QString path = uri_or_bundle; + if(path.startsWith("file://")) + path = QUrl(path).toLocalFile(); + while(path.size() > 1 && path.endsWith('/')) + path.chop(1); + + for(const auto& p : m_plugins) + { + if(!p.valid) + continue; + QString bp = p.bundle; + while(bp.size() > 1 && bp.endsWith('/')) + bp.chop(1); + if(bp == path) + return &p; + } + return nullptr; +} + +void ApplicationPlugin::ensureBundleLoaded(const QString& uri_or_bundle) +{ + QString bundle; + if(auto* info = findDescriptor(uri_or_bundle)) + { + bundle = info->bundle; + } + else if(uri_or_bundle.startsWith("file://")) + { + bundle = uri_or_bundle; + } + else + { + return; + } + + if(bundle.isEmpty()) + return; + + // QUrl::fromLocalFile handles RFC 8089 cross-platform (Windows drive path third slash) + QString bundle_uri; + if(bundle.startsWith("file://")) + { + bundle_uri = bundle; + } + else + { + QString path = bundle; + while(path.size() > 1 && path.endsWith('/')) + path.chop(1); + bundle_uri = QUrl::fromLocalFile(path).toString(); + } + if(!bundle_uri.endsWith('/')) + bundle_uri.append('/'); + + if(m_loaded_bundles.contains(bundle_uri)) + return; + + LilvNode* node = lilv_new_uri(lilv.me, bundle_uri.toUtf8().constData()); + if(!node) + return; + + lilv_world_load_bundle(lilv.me, node); + lilv_node_free(node); + + // Re-run specs + classes so the new bundle's class hierarchy resolves (idempotent) + lilv_world_load_specifications(lilv.me); + lilv_world_load_plugin_classes(lilv.me); + + m_loaded_bundles.insert(bundle_uri); +} + +QStringList ApplicationPlugin::discoverLv2Search() +{ + // Linux multi-arch paths come from Context.cpp's ldconfig heuristic + QStringList paths; + +#if defined(__APPLE__) + paths << "/Library/Audio/Plug-Ins/LV2" + << (QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + + "/Library/Audio/Plug-Ins/LV2") + << "/usr/local/lib/lv2" // lilv default, Homebrew Intel + << "/opt/homebrew/lib/lv2" // Homebrew Apple Silicon + << "/usr/lib/lv2"; // lilv default +#elif defined(_WIN32) + paths << "C:/Program Files/Common Files/LV2" + // Roaming %APPDATA%\LV2 (lilv/Carla default) + << QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/LV2" + // Non-roaming %LOCALAPPDATA%\LV2 (legacy score path) + << QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) + + "/LV2"; +#else + paths << QStringLiteral("/usr/lib/lv2") << QStringLiteral("/usr/local/lib/lv2") + << QStringLiteral("/usr/lib64/lv2") << QStringLiteral("/usr/local/lib64/lv2"); +#endif + + // ~/.lv2/: Linux convention; Ardour also writes user presets here cross-platform + paths << QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/.lv2"; + + if(qEnvironmentVariableIsSet("LV2_PATH")) + { + auto extra = QString::fromUtf8(qgetenv("LV2_PATH")) + .split(QDir::listSeparator(), Qt::SkipEmptyParts); + paths += extra; + } + + for(auto it = paths.begin(); it != paths.end();) + { + auto fi = QFileInfo{*it}; + if(!fi.exists() || !fi.isDir()) + { + it = paths.erase(it); + } + else + { + *it = fi.canonicalFilePath(); + ++it; + } + } + paths.removeDuplicates(); + return paths; +} + +// Cache key over all *.ttl in the bundle: manifest-only mtime missed sibling TTL edits +static std::pair bundleCacheKey(const QString& bundlePath) +{ + qint64 max_mtime = 0; + qint64 total_size = 0; + QDirIterator it( + bundlePath, QStringList{QStringLiteral("*.ttl")}, QDir::Files, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + while(it.hasNext()) + { + it.next(); + const QFileInfo fi = it.fileInfo(); + const qint64 m = fi.lastModified().toMSecsSinceEpoch(); + if(m > max_mtime) + max_mtime = m; + total_size += fi.size(); + } + if(max_mtime == 0) + { + QFileInfo manifest{bundlePath + QStringLiteral("/manifest.ttl")}; + max_mtime = manifest.lastModified().toMSecsSinceEpoch(); + total_size = manifest.size(); + } + return {max_mtime, total_size}; +} + +QStringList ApplicationPlugin::discoverBundles(const QStringList& search) +{ + QStringList bundles; + for(const QString& dir : search) + { + if(!QDir{dir}.isReadable()) + continue; + QDirIterator it( + dir, QStringList{"*.lv2"}, QDir::Dirs | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + while(it.hasNext()) + { + QString bundlePath = it.next(); + if(!QFile::exists(bundlePath + "/manifest.ttl")) + continue; + bundles.push_back(QFileInfo{bundlePath}.canonicalFilePath()); + } + } + bundles.removeDuplicates(); + return bundles; +} + +QStringList ApplicationPlugin::discoverSpecBundles(const QStringList& bundles) +{ + QStringList specs; + for(const QString& b : bundles) + { + QFile manifest(b + QStringLiteral("/manifest.ttl")); + if(!manifest.open(QIODevice::ReadOnly)) + continue; + // Substring match: spec manifests are tiny, no need for a TTL parser here + const QByteArray content = manifest.readAll(); + if(content.contains("lv2:Specification") + || content.contains("")) + { + specs.push_back(b); + } + } + return specs; +} + +void ApplicationPlugin::rescanBundles() +{ + m_bundleQueue.clear(); + m_processes.clear(); + m_processCount = 0; + m_scanned_ok.clear(); + + const auto search = discoverLv2Search(); + const auto bundles = discoverBundles(search); + + // Pass specs to every puppet so a malformed third-party bundle can't crash a scan + m_spec_bundles = discoverSpecBundles(bundles); + + ossia::hash_map> cache_index; + cache_index.reserve(m_plugins.size()); + for(const auto& p : m_plugins) + { + if(p.valid) + cache_index[p.bundle] = {p.mtime, p.size}; + } + + QStringList toScan; + for(const QString& bundle : bundles) + { + const auto [mtime, size] = bundleCacheKey(bundle); + auto it = cache_index.find(bundle); + if(it == cache_index.end()) + { + toScan.push_back(bundle); + } + else if(it->second.first != mtime || it->second.second != size) + { + for(int i = m_plugins.size() - 1; i >= 0; --i) + { + if(m_plugins[i].bundle == bundle) + m_plugins.erase(m_plugins.begin() + i); + } + toScan.push_back(bundle); + } + } + + { + ossia::hash_set on_disk; + on_disk.reserve(bundles.size()); + for(const auto& b : bundles) + on_disk.insert(b); + for(int i = m_plugins.size() - 1; i >= 0; --i) + { + if(!on_disk.contains(m_plugins[i].bundle)) + m_plugins.erase(m_plugins.begin() + i); + } + } + + if(!toScan.isEmpty()) + { + descriptorsChanged(); // stale cache entries may have been removed + } + + for(const auto& b : toScan) + m_bundleQueue.push_back(b); + + if(m_bundleQueue.empty()) + { + persistCache(); + descriptorsChanged(); + return; + } + + scanNextBatch(); +} + +static const QString& lv2puppetPath() +{ + static const QString path = []() -> QString { + auto app = QCoreApplication::instance()->applicationDirPath(); +#if defined(__APPLE__) + auto bundle_path + = "/ossia-score-lv2puppet.app/Contents/MacOS/" + "ossia-score-lv2puppet"; + QString bundle_puppet = app + bundle_path; + if(QFile::exists(bundle_puppet)) + return bundle_puppet; + else if(QFile::exists(app + "/ossia-score-lv2puppet")) + return QString(app + "/ossia-score-lv2puppet"); + else if(QFile::exists(app + "/../../ossia-score-lv2puppet")) + return QString(app + "/../../ossia-score-lv2puppet"); + else if(QFile::exists(app + "/../../" + bundle_path)) + return QString(app + "/../../" + bundle_path); + else + return QStringLiteral("ossia-score-lv2puppet"); +#else + return app + "/ossia-score-lv2puppet"; +#endif + }(); + return path; +} + +void ApplicationPlugin::scanNextBatch() +{ + if(m_bundleQueue.empty() && m_processes.empty()) + { + persistCache(); + descriptorsChanged(); + return; + } + + while(!m_bundleQueue.empty() && (int)m_processes.size() < max_in_flight) + { + QString bundlePath = m_bundleQueue.back(); + m_bundleQueue.pop_back(); + + int id = m_processCount++; + QPointer proc = new QProcess; + m_processes[id] = proc; + + connect( + proc, &QProcess::errorOccurred, this, + [this, id, bundlePath, proc](QProcess::ProcessError err) { + qDebug() << "LV2 scan error for:" << bundlePath << "error:" << err; + m_processes.erase(id); + if(proc) + proc->deleteLater(); + // websocketpp throws on close-after-server-closed even after the JSON was delivered + if(!m_scanned_ok.contains(bundlePath)) + markBundleInvalid(bundlePath); + scanNextBatch(); + }); + + auto timer = new QTimer; + timer->setSingleShot(true); + // 60s: lsp-plugins (~197 plug-ins, ASan) can push past 10s + timer->setInterval(60000); + connect(timer, &QTimer::timeout, proc, [this, id, proc, timer, bundlePath] { + if(m_processes.find(id) != m_processes.end()) + { + qDebug() << "LV2 scan timeout for:" << bundlePath; + if(proc) + { + proc->terminate(); + if(!proc->waitForFinished(100)) + proc->kill(); + } + } + timer->deleteLater(); + }); + + connect( + proc, &QProcess::finished, this, + [this, id, bundlePath, proc, timer](int exitCode, QProcess::ExitStatus) { + m_processes.erase(id); + if(proc) + proc->deleteLater(); + + // Non-zero after a close-handshake race / timeout SIGTERM does not invalidate + if(exitCode != 0 && !m_scanned_ok.contains(bundlePath)) + { + qDebug() << "LV2 scan failed for:" << bundlePath; + markBundleInvalid(bundlePath); + } + + scanNextBatch(); + timer->deleteLater(); + }); + timer->start(); + + // Disable LSan in the child: lilv leaks on shutdown; we'd see QProcess::Crashed + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert( + "ASAN_OPTIONS", + env.value("ASAN_OPTIONS", "") + ":detect_leaks=0:exitcode=0"); + env.insert("LSAN_OPTIONS", env.value("LSAN_OPTIONS", "") + ":exitcode=0"); + if(!m_spec_bundles.isEmpty()) + env.insert("LV2PUPPET_SPECS", m_spec_bundles.join(QDir::listSeparator())); + proc->setProcessEnvironment(env); + proc->setProcessChannelMode(QProcess::ForwardedErrorChannel); + proc->start(lv2puppetPath(), {bundlePath, QString::number(id)}); + } +} + +void ApplicationPlugin::processIncomingMessage(const QString& txt) +{ + try + { + QJsonParseError err; + auto json = QJsonDocument::fromJson(txt.toUtf8(), &err); + if(!json.isObject()) + { + qWarning() << "LV2: malformed scan result (" << txt.size() + << "bytes):" << err.errorString(); + return; + } + + auto obj = json.object(); + [[maybe_unused]] const int req = obj["Request"].toInt(); + auto bundle = obj["Bundle"].toString(); + + if(bundle.isEmpty()) + return; + + if(obj.contains("Error")) + { + qDebug() << "LV2 puppet error for" << bundle << ":" + << obj["Error"].toString(); + markBundleInvalid(bundle); + return; + } + + if(obj.contains("Plugins") && obj["Plugins"].isArray()) + { + addPluginsFromJson(bundle, obj["Plugins"].toArray()); + m_scanned_ok.insert(bundle); + } + } + catch(...) + { + qDebug() << "Failed to parse LV2 scan result"; + } +} + +void ApplicationPlugin::addPluginsFromJson( + const QString& bundlePath, const QJsonArray& arr) +{ + const auto [mtime, size] = bundleCacheKey(bundlePath); + + for(int i = m_plugins.size() - 1; i >= 0; --i) + { + if(m_plugins[i].bundle == bundlePath) + m_plugins.erase(m_plugins.begin() + i); + } + + for(const auto& v : arr) + { + if(!v.isObject()) + continue; + const auto o = v.toObject(); + PluginInfo info; + info.bundle = bundlePath; + info.uri = o["uri"].toString(); + info.name = o["name"].toString(); + info.class_label = o["class"].toString(); + info.author = o["author"].toString(); + info.documentationLink = o["documentationLink"].toString(); + info.mtime = mtime; + info.size = size; + info.audio_in = o["audio_in"].toInt(); + info.audio_out = o["audio_out"].toInt(); + info.midi_in = o["midi_in"].toInt(); + info.midi_out = o["midi_out"].toInt(); + info.control_in = o["control_in"].toInt(); + info.control_out = o["control_out"].toInt(); + info.atom_in = o["atom_in"].toInt(); + info.atom_out = o["atom_out"].toInt(); + info.cv_in = o["cv_in"].toInt(); + info.has_ui = o["has_ui"].toBool(); + if(o["ui_uris"].isArray()) + { + for(const auto& u : o["ui_uris"].toArray()) + info.ui_uris.push_back(u.toString()); + } + if(o["preset_uris"].isArray()) + { + for(const auto& u : o["preset_uris"].toArray()) + info.preset_uris.push_back(u.toString()); + } + if(o["preset_bundles"].isArray()) + { + for(const auto& u : o["preset_bundles"].toArray()) + info.preset_bundles.push_back(u.toString()); + } + info.valid = !info.uri.isEmpty(); + m_plugins.push_back(std::move(info)); + } + + // Empty-bundle marker: matches mtime+size so the next rescan is a cache hit + if(arr.isEmpty()) + { + PluginInfo marker; + marker.bundle = bundlePath; + marker.name = ""; + marker.mtime = mtime; + marker.size = size; + marker.valid = false; + m_plugins.push_back(std::move(marker)); + } + + if(m_persistTimer) + m_persistTimer->start(); +} + +void ApplicationPlugin::markBundleInvalid(const QString& bundlePath) +{ + // Marker so we don't re-scan the broken bundle every run; mtime/size for fix detection + const auto [mtime, size] = bundleCacheKey(bundlePath); + PluginInfo info; + info.bundle = bundlePath; + info.name = ""; + info.mtime = mtime; + info.size = size; + info.valid = false; + + for(int i = m_plugins.size() - 1; i >= 0; --i) + { + if(m_plugins[i].bundle == bundlePath) + m_plugins.erase(m_plugins.begin() + i); + } + m_plugins.push_back(std::move(info)); + + if(m_persistTimer) + m_persistTimer->start(); +} + +void ApplicationPlugin::persistCache() +{ + QSettings{}.setValue("Effect/KnownLV2", QVariant::fromValue(m_plugins)); +} + } diff --git a/src/plugins/score-plugin-lv2/LV2/ApplicationPlugin.hpp b/src/plugins/score-plugin-lv2/LV2/ApplicationPlugin.hpp index d8088ba66b..cb561dacb9 100644 --- a/src/plugins/score-plugin-lv2/LV2/ApplicationPlugin.hpp +++ b/src/plugins/score-plugin-lv2/LV2/ApplicationPlugin.hpp @@ -6,13 +6,17 @@ #include +#include #include +#include +#include +#include +#include #include -#include -#include -#include +#include +#include #include namespace LV2 @@ -20,6 +24,39 @@ namespace LV2 struct HostContext; struct GlobalContext; +// Cached descriptor from a lv2puppet scan; persisted to QSettings("Effect/KnownLV2") +struct PluginInfo +{ + QString bundle; // file system path to .lv2 bundle dir + QString uri; // canonical plug-in URI + QString name; + QString class_label; // lv2:Class label (Reverb, Delay, ...) + QString author; + QString documentationLink; // author homepage URL + + // Cache invalidation key — re-scan when the manifest changes. + qint64 mtime{}; + qint64 size{}; + + int audio_in{}; + int audio_out{}; + int midi_in{}; + int midi_out{}; + int control_in{}; + int control_out{}; + int atom_in{}; + int atom_out{}; + int cv_in{}; + + bool has_ui{}; + QStringList ui_uris; + QStringList preset_uris; + // Parallel to preset_uris: bundle that must be loaded before lilv_plugin_get_related + QStringList preset_bundles; + + bool valid{}; +}; + class ApplicationPlugin : public QObject , public score::ApplicationPlugin @@ -30,8 +67,18 @@ class ApplicationPlugin void initialize() override; ~ApplicationPlugin() override; - std::atomic_bool abort_library_scan{}; - std::mutex library_lock; + const std::vector& cachedDescriptors() const noexcept + { + return m_plugins; + } + + const PluginInfo* findDescriptor(const QString& uri_or_bundle) const noexcept; + + // Idempotent; main thread only. + void ensureBundleLoaded(const QString& uri_or_bundle); + +public: + void descriptorsChanged() W_SIGNAL(descriptorsChanged); public: Lilv::World lilv; @@ -39,6 +86,40 @@ class ApplicationPlugin LV2::HostContext lv2_host_context; const libsuil& suil = libsuil::instance(); + +private: + void rescanBundles(); + void scanNextBatch(); + void processIncomingMessage(const QString& txt); + void addPluginsFromJson(const QString& bundlePath, const class QJsonArray& arr); + void markBundleInvalid(const QString& bundlePath); + void persistCache(); + + static QStringList discoverLv2Search(); + static QStringList discoverBundles(const QStringList& search); + // Pick out LV2 spec bundles (atom, urid, ui, ...) so puppets can skip load_all + static QStringList discoverSpecBundles(const QStringList& bundles); + + QWebSocketServer m_wsServer; + std::map> m_processes; + std::vector m_bundleQueue; + int m_processCount{0}; + static constexpr int max_in_flight = 8; + + // Suppresses markBundleInvalid on post-report puppet exit (close-handshake race) + ossia::hash_set m_scanned_ok; + + std::vector m_plugins; + + ossia::hash_set m_loaded_bundles; + + QStringList m_spec_bundles; + + // Coalesces persistCache + descriptorsChanged into one fire per burst + class QTimer* m_persistTimer{}; }; } + +Q_DECLARE_METATYPE(LV2::PluginInfo) +Q_DECLARE_METATYPE(std::vector) diff --git a/src/plugins/score-plugin-lv2/LV2/Context.cpp b/src/plugins/score-plugin-lv2/LV2/Context.cpp index 9cfe1dc629..705f58f883 100644 --- a/src/plugins/score-plugin-lv2/LV2/Context.cpp +++ b/src/plugins/score-plugin-lv2/LV2/Context.cpp @@ -101,13 +101,16 @@ struct worker { LV2::HostContext* host; LV2::EffectContext* fx; + LilvInstance* instance; + const LV2_Worker_Interface* worker_iface; + ossia::mpmc_queue>* response_queue; std::vector dat; void operator()() { // 3. Process the work - fx->worker->work( - fx->instance->lv2_handle, &worker::work_done, this, dat.size(), dat.data()); + worker_iface->work( + instance->lv2_handle, &worker::work_done, this, dat.size(), dat.data()); // Give back the worker buffer host->release_worker_data(std::move(dat)); @@ -120,7 +123,7 @@ struct worker auto& self = *(worker*)sub_h; auto response_data = self.host->acquire_worker_data((const char*)sub_d, sub_s); - self.fx->worker_datas.enqueue(std::move(response_data)); + self.response_queue->enqueue(std::move(response_data)); return LV2_WORKER_SUCCESS; } @@ -137,12 +140,22 @@ do_worker(LV2_Worker_Schedule_Handle ptr, uint32_t s, const void* data) auto& w = *cur->worker; if(w.work) { + // Per-voice queue if the audio node set one; shared effectContext otherwise + auto* response_queue + = c.host.current_worker_datas ? c.host.current_worker_datas : &cur->worker_datas; + // 1. Acquire a buffer to copy the data in audio thread std::vector cp = c.host.acquire_worker_data((const char*)data, s); // 2. Move that buffer to the thread pool auto& tq = score::TaskPool::instance(); - tq.post(worker{.host = &c.host, .fx = cur, .dat = std::move(cp)}); + tq.post(worker{ + .host = &c.host, + .fx = cur, + .instance = cur->instance, + .worker_iface = cur->worker, + .response_queue = response_queue, + .dat = std::move(cp)}); return LV2_WORKER_SUCCESS; } @@ -307,7 +320,6 @@ void LV2::GlobalContext::loadPlugins() qputenv("LV2_PATH", paths.toUtf8()); } #endif - host.world.load_all(); // Atom stuff host.null_id = map.map(map.handle, ""); @@ -331,6 +343,12 @@ void LV2::GlobalContext::loadPlugins() host.time_framesPerSecond_id = map.map(map.handle, LV2_TIME__framesPerSecond); host.time_speed_id = map.map(map.handle, LV2_TIME__speed); + // For state:port-restore decoding; see lv2_set_port_value in EffectModel.cpp + host.atom_Float_id = map.map(map.handle, LV2_ATOM__Float); + host.atom_Double_id = map.map(map.handle, LV2_ATOM__Double); + host.atom_Int_id = map.map(map.handle, LV2_ATOM__Int); + host.atom_Long_id = map.map(map.handle, LV2_ATOM__Long); + lv2_atom_forge_init(&host.forge, &map); } diff --git a/src/plugins/score-plugin-lv2/LV2/Context.hpp b/src/plugins/score-plugin-lv2/LV2/Context.hpp index 0c65d7e166..d2630c9e01 100644 --- a/src/plugins/score-plugin-lv2/LV2/Context.hpp +++ b/src/plugins/score-plugin-lv2/LV2/Context.hpp @@ -46,6 +46,7 @@ #include #include #include +#include #include #include @@ -100,6 +101,15 @@ struct HostContext Lilv::Node fixed_size{make_node(LV2_UI__fixedSize)}; Lilv::Node no_user_resize{make_node(LV2_UI__noUserResize)}; + Lilv::Node state_loadDefaultState_node{make_node(LV2_STATE__loadDefaultState)}; + + Lilv::Node integer_property{make_node("http://lv2plug.in/ns/lv2core#integer")}; + Lilv::Node toggled_property{make_node("http://lv2plug.in/ns/lv2core#toggled")}; + Lilv::Node enumeration_property{ + make_node("http://lv2plug.in/ns/lv2core#enumeration")}; + Lilv::Node logarithmic_property{make_node(LV2_PORT_PROPS__logarithmic)}; + Lilv::Node notOnGUI_property{make_node(LV2_PORT_PROPS__notOnGUI)}; + Lilv::Node host_ui_type{make_node("http://lv2plug.in/ns/extensions/ui#Qt6UI")}; int32_t midi_buffer_size = 2048; @@ -110,6 +120,12 @@ struct HostContext LV2_URID null_id{}; LV2_URID atom_eventTransfer{}; + // state:port-restore payload types; see Ardour set_port_value (lv2_plugin.cc:313) + LV2_URID atom_Float_id{}; + LV2_URID atom_Double_id{}; + LV2_URID atom_Int_id{}; + LV2_URID atom_Long_id{}; + LV2_URID time_Time_id{}; LV2_URID time_Position_id{}; LV2_URID time_rate_id{}; @@ -138,12 +154,44 @@ struct HostContext } ossia::mpmc_queue> worker_datas_pool{}; + + // Set by the audio node per-voice so work_schedule routes to the right queue + ossia::mpmc_queue>* current_worker_datas{}; +}; + +// shared_ptr: refcounted LilvInstance. Last drop MUST be on the main thread. +struct InstanceHandle +{ + LilvInstance* instance{}; + bool activated{false}; + + InstanceHandle() = default; + explicit InstanceHandle(LilvInstance* i) noexcept + : instance{i} + { + } + InstanceHandle(const InstanceHandle&) = delete; + InstanceHandle& operator=(const InstanceHandle&) = delete; + InstanceHandle(InstanceHandle&&) = delete; + InstanceHandle& operator=(InstanceHandle&&) = delete; + ~InstanceHandle() noexcept + { + if(!instance) + return; + if(activated) + lilv_instance_deactivate(instance); + lilv_instance_free(instance); + } }; +using SharedInstance = std::shared_ptr; + struct EffectContext { Lilv::Plugin plugin{nullptr}; + // alias for instance_holder->instance LilvInstance* instance{}; + SharedInstance instance_holder{}; const LV2_Worker_Interface* worker{}; LV2_Extension_Data_Feature data{}; const LilvUI* ui{}; diff --git a/src/plugins/score-plugin-lv2/LV2/EffectModel.cpp b/src/plugins/score-plugin-lv2/LV2/EffectModel.cpp index de0af43286..537d5a9a0f 100644 --- a/src/plugins/score-plugin-lv2/LV2/EffectModel.cpp +++ b/src/plugins/score-plugin-lv2/LV2/EffectModel.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include #include @@ -36,6 +37,51 @@ W_OBJECT_IMPL(LV2::Model) namespace LV2 { +// state:port-restore callback; canonical type dispatch in Ardour lv2_plugin.cc:313 +static void lv2_set_port_value( + const char* port_symbol, void* user_data, const void* value, + uint32_t /*size*/, uint32_t type) +{ + auto* self = static_cast(user_data); + if(!self || !self->plugin || !self->hostContext || !port_symbol || !value) + return; + + LilvWorld* world = self->hostContext->world.me; + LilvNode* sym_node = lilv_new_uri(world, port_symbol); + if(!sym_node) + return; + + const LilvPort* port + = lilv_plugin_get_port_by_symbol(self->plugin, sym_node); + lilv_node_free(sym_node); + if(!port) + return; + + const uint32_t idx = lilv_port_get_index(self->plugin, port); + auto it = self->control_map.find(idx); + if(it == self->control_map.end() || !it->second.first) + return; + + const auto& h = *self->hostContext; + float fvalue = 0.f; + if(type == h.atom_Float_id) + fvalue = *static_cast(value); + else if(type == h.atom_Double_id) + fvalue = static_cast(*static_cast(value)); + else if(type == h.atom_Int_id) + fvalue = static_cast(*static_cast(value)); + else if(type == h.atom_Long_id) + fvalue = static_cast(*static_cast(value)); + else + { + return; + } + + // suil port-event guard: on_uiMessage drops UI writes when we're writing + it->second.second = true; + it->second.first->setValue(ossia::value{fvalue}); + it->second.second = false; +} QString get_lv2_plugin_name(const Lilv::Plugin& node) { QString ret; @@ -47,9 +93,24 @@ QString get_lv2_plugin_name(const Lilv::Plugin& node) } return ret; }; +namespace +{ +// Cleared on descriptorsChanged: rescan invalidates the LilvPlugin* values +ossia::hash_map& plug_map_instance() +{ + static ossia::hash_map m; + return m; +} +} + +void clearPluginCache() +{ + plug_map_instance().clear(); +} + std::optional find_lv2_plugin(Lilv::World& world, QString path) { - static ossia::hash_map plug_map; + auto& plug_map = plug_map_instance(); if(auto it = plug_map.find(path); it != plug_map.end()) { if(it->second) @@ -59,6 +120,15 @@ std::optional find_lv2_plugin(Lilv::World& world, QString path) } auto old_str = path; + + auto& app_plug = score::AppComponents().applicationPlugin(); + const LV2::PluginInfo* info = app_plug.findDescriptor(path); + + if(info) + app_plug.ensureBundleLoaded(info->bundle); + else + app_plug.ensureBundleLoaded(path); // path might be a bundle URI / path + const bool isFile = QFile(QUrl(path).toString(QUrl::PreferLocalFile)).exists(); if(isFile) { @@ -72,13 +142,14 @@ std::optional find_lv2_plugin(Lilv::World& world, QString path) { auto plug = plugs.get(it); const auto plugin_name = get_lv2_plugin_name(plug); - if((isFile && QString(plug.get_bundle_uri().as_string()) == path) - || (!isFile && plugin_name == path)) + const QString plugin_uri = QString::fromUtf8(plug.get_uri().as_string()); + if(info && plugin_uri == info->uri) { plug_map[old_str] = plug; return plug; } - else if(!isFile && plugin_name == path) + if((isFile && QString(plug.get_bundle_uri().as_string()) == path) + || (!isFile && plugin_name == path) || (!isFile && plugin_uri == old_str)) { plug_map[old_str] = plug; return plug; @@ -95,7 +166,7 @@ std::optional find_lv2_plugin(Lilv::World& world, QString path) struct LV2PluginChooserDialog : public QDialog { - LV2PluginChooserDialog(Lilv::World& world, QWidget* parent) + LV2PluginChooserDialog(LV2::ApplicationPlugin& app, QWidget* parent) : QDialog{parent} { this->setLayout(&m_lay); @@ -108,40 +179,28 @@ struct LV2PluginChooserDialog : public QDialog m_lay.addWidget(&m_categories); m_lay.addWidget(&m_plugins); m_lay.addWidget(&m_buttons); - auto plugs = world.get_all_plugins(); - QStringList items; - - auto it = plugs.begin(); - while(!plugs.is_end(it)) + for(const auto& info : app.cachedDescriptors()) { - auto plug = plugs.get(it); - const auto class_name = plug.get_class().get_label().as_string(); - const auto plug_name = get_lv2_plugin_name(plug); - auto sub_it = m_categories_map.find(class_name); - if(sub_it == m_categories_map.end()) - { - m_categories_map.insert({class_name, {plug_name}}); - } - else - { - sub_it->second.append(plug_name); - } - it = plugs.next(it); + if(!info.valid) + continue; + QString category + = info.class_label.isEmpty() ? QStringLiteral("Other") : info.class_label; + m_categories_map[category].push_back({info.name, info.uri}); } for(auto& category : m_categories_map) - { m_categories.addItem(category.first); - } con(m_categories, &QListWidget::currentTextChanged, this, &LV2PluginChooserDialog::updateProcesses); - auto accept_item = [&](auto item) { + auto accept_item = [&](QListWidgetItem* item) { if(item) { - m_accepted = item->text(); + m_accepted = item->data(Qt::UserRole).toString(); + if(m_accepted.isEmpty()) + m_accepted = item->text(); QDialog::close(); } }; @@ -154,9 +213,11 @@ struct LV2PluginChooserDialog : public QDialog void updateProcesses(const QString& str) { m_plugins.clear(); - for(const auto& plug : m_categories_map[str]) + for(const auto& [name, uri] : m_categories_map[str]) { - m_plugins.addItem(plug); + auto* item = new QListWidgetItem(name); + item->setData(Qt::UserRole, uri); + m_plugins.addItem(item); } } @@ -166,7 +227,7 @@ struct LV2PluginChooserDialog : public QDialog QListWidget m_plugins; QDialogButtonBox m_buttons; - ossia::flat_map> m_categories_map; + ossia::flat_map>> m_categories_map; }; } namespace Process @@ -175,9 +236,9 @@ namespace Process template <> QString EffectProcessFactory_T::customConstructionData() const noexcept { - auto& world = score::AppComponents().applicationPlugin().lilv; + auto& app_plug = score::AppComponents().applicationPlugin(); - LV2::LV2PluginChooserDialog dial{world, nullptr}; + LV2::LV2PluginChooserDialog dial{app_plug, nullptr}; dial.exec(); return dial.m_accepted; } @@ -270,15 +331,12 @@ Process::Descriptor EffectProcessFactory_T::descriptor( const Process::ProcessModel& d) const noexcept { auto& p = safe_cast(d); + auto& app_plug = score::AppComponents().applicationPlugin(); Process::Descriptor desc; if(auto plug = p.plugin) desc = make_descriptor(Lilv::Plugin{plug}); - else - { - auto& app_plug = score::AppComponents().applicationPlugin(); - if(auto plug = LV2::find_lv2_plugin(app_plug.lilv, d.effect())) - desc = make_descriptor(*plug); - } + else if(auto plug = LV2::find_lv2_plugin(app_plug.lilv, d.effect())) + desc = make_descriptor(*plug); return desc; } @@ -313,12 +371,150 @@ Model::~Model() auto w = reinterpret_cast(externalUI); delete w; } + // Voices may still hold refs; SharedInstance ensures last drop runs ~InstanceHandle + effectContext.instance = nullptr; + effectContext.instance_holder.reset(); } QString Model::prettyName() const noexcept { return metadata().getLabel(); } +std::vector Model::builtinPresets() const noexcept +{ + std::vector result; + if(!plugin) + return result; + + auto& app_plug + = score::AppComponents().applicationPlugin(); + auto& host_ctx = app_plug.lv2_host_context; + LilvWorld* world = host_ctx.world.me; + + // Pre-load preset bundles so lilv_plugin_get_related sees them + if(auto* info = app_plug.findDescriptor(m_effectPath)) + { + for(const auto& bundle : info->preset_bundles) + { + if(!bundle.isEmpty()) + app_plug.ensureBundleLoaded(bundle); + } + // Fall back to old "strip last URI component" heuristic for pre-preset_bundles caches + if(info->preset_bundles.isEmpty()) + { + for(const auto& preset_uri : info->preset_uris) + { + QString u = preset_uri; + int slash = u.lastIndexOf('/'); + if(slash > 0) + app_plug.ensureBundleLoaded(u.left(slash + 1)); + } + } + } + + LilvNode* pset_node = lilv_new_uri(world, LV2_PRESETS__Preset); + LilvNode* rdfs_label_node + = lilv_new_uri(world, "http://www.w3.org/2000/01/rdf-schema#label"); + + if(LilvNodes* presets = lilv_plugin_get_related(plugin, pset_node)) + { + result.reserve(lilv_nodes_size(presets)); + LILV_FOREACH(nodes, it, presets) + { + const LilvNode* preset_node = lilv_nodes_get(presets, it); + // rdfs:seeAlso file (with the rdfs:label) is loaded via lilv_world_load_resource + lilv_world_load_resource(world, preset_node); + + QString label; + if(LilvNodes* labels = lilv_world_find_nodes( + world, preset_node, rdfs_label_node, nullptr)) + { + if(lilv_nodes_size(labels) > 0) + { + const LilvNode* l = lilv_nodes_get_first(labels); + if(l) + label = QString::fromUtf8(lilv_node_as_string(l)); + } + lilv_nodes_free(labels); + } + + Process::Preset p; + p.name = label.isEmpty() ? QString::fromUtf8(lilv_node_as_uri(preset_node)) + : label; + p.key.key = this->concreteKey(); + p.key.effect = m_effectPath; + // Store the URI; loadPreset resolves via lilv_state_new_from_world + p.data = QByteArray(lilv_node_as_uri(preset_node)); + result.push_back(std::move(p)); + } + lilv_nodes_free(presets); + } + + lilv_node_free(rdfs_label_node); + lilv_node_free(pset_node); + return result; +} + +void Model::loadPreset(const Process::Preset& preset) +{ + if(!plugin || !effectContext.instance || preset.data.isEmpty()) + return; + if(!hostContext || !hostContext->global) + return; + + auto& app_plug + = score::AppComponents().applicationPlugin(); + LilvWorld* world = hostContext->world.me; + auto& map = hostContext->global->map; + + // Built-in path: data is a URI; saved projects may reference unenumerated presets + LilvState* state = nullptr; + if(LilvNode* uri = lilv_new_uri(world, preset.data.constData())) + { + lilv_world_load_resource(world, uri); + state = lilv_state_new_from_world(world, &map, uri); + lilv_node_free(uri); + } + if(!state) + state = lilv_state_new_from_string(world, &map, preset.data.constData()); + + if(state) + { + lilv_state_restore( + state, effectContext.instance, lv2_set_port_value, this, + LV2_STATE_IS_PORTABLE, hostContext->features); + lilv_state_free(state); + } +} + +Process::Preset Model::savePreset() const noexcept +{ + Process::Preset p; + p.name = this->metadata().getName(); + p.key.key = this->concreteKey(); + p.key.effect = m_effectPath; + if(!plugin || !effectContext.instance || !hostContext || !hostContext->global) + return p; + + auto& global = *hostContext->global; + LilvState* state = lilv_state_new_from_instance( + plugin, effectContext.instance, &global.map, nullptr, nullptr, nullptr, + nullptr, nullptr, nullptr, LV2_STATE_IS_PORTABLE, nullptr); + if(!state) + return p; + + char* str = lilv_state_to_string( + hostContext->world.me, &global.map, &global.unmap, state, + "urn:io.ossia:lv2preset", nullptr); + if(str) + { + p.data = QByteArray(str); + free(str); + } + lilv_state_free(state); + return p; +} + bool Model::hasExternalUI() const noexcept { if(!plugin) @@ -440,39 +636,89 @@ void Model::readPlugin() m_controlInStart = in_id; // CONTROL - // FIXME if(data.control_in_ports.size() < 10) + for(int port_id : data.control_in_ports) { - for(int port_id : data.control_in_ports) + SCORE_ASSERT(port_id >= 0); + Lilv::Port p = data.effect.plugin.get_port_by_index(port_id); + Lilv::Node n = p.get_name(); + QString port_name = QString::fromUtf8(n.as_string()); + Id id{in_id++}; + const float pmin = fParamMin[port_id]; + const float pmax = fParamMax[port_id]; + const float pinit + = std::isnan(fParamInit[port_id]) ? pmin : fParamInit[port_id]; + + // Widget by portProperty: toggled, enumeration+scalePoint, integer, logarithmic, default + Process::ControlInlet* port = nullptr; + LilvNodes* props = lilv_port_get_properties(data.effect.plugin.me, p.me); + const bool is_toggled = props && lilv_nodes_contains(props, h.toggled_property.me); + const bool is_enum + = props && lilv_nodes_contains(props, h.enumeration_property.me); + const bool is_integer + = props && lilv_nodes_contains(props, h.integer_property.me); + const bool is_log + = props && lilv_nodes_contains(props, h.logarithmic_property.me); + if(props) + lilv_nodes_free(props); + + if(is_toggled) + { + port = new Process::Toggle{pinit >= 0.5f, port_name, id, this}; + } + else if(is_enum) { - SCORE_ASSERT(port_id >= 0); - Lilv::Port p = data.effect.plugin.get_port_by_index(port_id); - Lilv::Node n = p.get_name(); - - SCORE_SOFT_ASSERT(!std::isnan(fParamInit[port_id])); - auto port = new Process::FloatSlider{ - fParamMin[port_id], fParamMax[port_id], - fParamInit[port_id], QString::fromUtf8(n.as_string()), - Id{in_id++}, this}; - - control_map.insert({(uint32_t)port_id, {port, false}}); - connect( - port, &Process::ControlInlet::valueChanged, this, - [this, port_id](const ossia::value& v) { - if(effectContext.ui_instance) + std::vector> values; + if(LilvScalePoints* sps + = lilv_port_get_scale_points(data.effect.plugin.me, p.me)) + { + LILV_FOREACH(scale_points, it, sps) { - auto& writing = control_map[port_id].second; - writing = true; - float f = ossia::convert(v); - - static auto& suil = libsuil::instance(); - suil.instance_port_event( - effectContext.ui_instance, port_id, sizeof(float), 0, &f); - writing = false; + const LilvScalePoint* sp = lilv_scale_points_get(sps, it); + const LilvNode* lbl = lilv_scale_point_get_label(sp); + const LilvNode* val = lilv_scale_point_get_value(sp); + if(lbl && val) + values.emplace_back( + QString::fromUtf8(lilv_node_as_string(lbl)), + ossia::value{lilv_node_as_float(val)}); } - }); - - m_inlets.push_back(port); + lilv_scale_points_free(sps); + } + if(!values.empty()) + port = new Process::ComboBox{ + std::move(values), ossia::value{pinit}, port_name, id, this}; + } + if(!port && is_integer) + { + port = new Process::IntSlider{ + int(pmin), int(pmax), int(pinit), port_name, id, this}; } + if(!port && is_log && pmin > 0.f) + { + port = new Process::LogFloatSlider{pmin, pmax, pinit, port_name, id, this}; + } + if(!port) + { + port = new Process::FloatSlider{pmin, pmax, pinit, port_name, id, this}; + } + + control_map.insert({(uint32_t)port_id, {port, false}}); + connect( + port, &Process::ControlInlet::valueChanged, this, + [this, port_id](const ossia::value& v) { + if(effectContext.ui_instance) + { + auto& writing = control_map[port_id].second; + writing = true; + float f = ossia::convert(v); + + static auto& suil = libsuil::instance(); + suil.instance_port_event( + effectContext.ui_instance, port_id, sizeof(float), 0, &f); + writing = false; + } + }); + + m_inlets.push_back(port); } m_controlOutStart = in_id; @@ -498,15 +744,37 @@ void Model::readPlugin() } auto sr = app_plug.context.settings().getRate(); - effectContext.instance = lilv_plugin_instantiate( - effectContext.plugin.me, sr, app_plug.lv2_host_context.features); - - if(!effectContext.instance) + if(auto* inst = lilv_plugin_instantiate( + effectContext.plugin.me, sr, app_plug.lv2_host_context.features)) + { + effectContext.instance_holder = std::make_shared(inst); + effectContext.instance = inst; + } + else { qDebug() << "Could not load LV2 plug-in"; return; } + // state:loadDefaultState (LV2 spec): apply ttl state:state triple between instantiate and run + if(auto* required = lilv_plugin_get_required_features(effectContext.plugin.me)) + { + if(lilv_nodes_contains(required, h.state_loadDefaultState_node.me)) + { + LilvState* default_state = lilv_state_new_from_world( + h.world.me, &app_plug.lv2_host_context.global->map, + lilv_plugin_get_uri(effectContext.plugin.me)); + if(default_state) + { + lilv_state_restore( + default_state, effectContext.instance, lv2_set_port_value, this, + LV2_STATE_IS_PORTABLE, app_plug.lv2_host_context.features); + lilv_state_free(default_state); + } + } + lilv_nodes_free(required); + } + effectContext.data.data_access = lilv_instance_get_descriptor(effectContext.instance)->extension_data; @@ -527,6 +795,10 @@ void Model::readPlugin() void Model::reload() { + // MAIN THREAD ONLY: mutates the shared LilvWorld via ensureBundleLoaded + effectContext.instance = nullptr; + effectContext.instance_holder.reset(); + plugin = nullptr; auto path = m_effectPath; @@ -562,33 +834,45 @@ static LilvState* state_from_instance(const LV2::Model& ctx) static QByteArray readLV2State(const LV2::Model& eff) { - if(!eff.plugin) + if(!eff.plugin || !eff.effectContext.instance || !eff.hostContext + || !eff.hostContext->global) return {}; auto& global = *eff.hostContext->global; const auto state = state_from_instance(eff); + if(!state) + return {}; + auto str = lilv_state_to_string( eff.hostContext->world.me, &global.map, &global.unmap, state, "urn:io.ossia:lv2state", nullptr); - QByteArray b(str); + QByteArray b; + if(str) + { + b = QByteArray(str); + free(str); + } lilv_state_free(state); - free(str); return b; } static void restoreLV2State(LV2::Model& eff, const QByteArray& str) { - if(!eff.plugin) + if(!eff.plugin || str.isEmpty()) return; auto& global = *eff.hostContext->global; const auto state = lilv_state_new_from_string( eff.hostContext->world.me, &global.map, str.constData()); + // lilv_state_restore is not NULL-safe + if(!state) + return; + lilv_state_restore( - state, eff.effectContext.instance, nullptr, nullptr, LV2_STATE_IS_PORTABLE, - nullptr); + state, eff.effectContext.instance, LV2::lv2_set_port_value, &eff, + LV2_STATE_IS_PORTABLE, nullptr); lilv_state_free(state); } @@ -710,11 +994,13 @@ void on_start::operator()() LV2::Message msg; while(proc.try_dequeue(msg)) { - for(std::size_t k = 0; k < node.m_midi_atom_ins.size(); ++k) + // Broadcast to every voice: per_channel needs the same MIDI on all instances + for(std::size_t k = 0; k < node.data.midi_in_ports.size(); ++k) { if(node.data.midi_in_ports[k] == int32_t(msg.index)) { - node.m_message_for_midi_atom_ins[k].push_back(std::move(msg)); + for(auto& v : node.voices) + v->message_for_midi_atom_ins[k].push_back(msg); break; } } @@ -738,11 +1024,14 @@ void on_finish::operator()() if(!nn) return; auto& node = *static_cast(nn.get()); + if(node.voices.empty()) + return; + auto& v0 = *node.voices[0]; for(std::size_t k = 0; k < node.data.control_out_ports.size(); k++) { auto port = (uint32_t)node.data.control_out_ports[k]; - float val = node.fOutControls[k]; + float val = v0.fOutControls[k]; auto cport = static_cast(p->process()).control_out_map[port]; SCORE_ASSERT(cport); @@ -754,11 +1043,14 @@ void on_finish::operator()() if(!nn) return; auto& node = *static_cast(nn.get()); + if(node.voices.empty()) + return; + auto& v0 = *node.voices[0]; for(std::size_t k = 0; k < node.data.midi_out_ports.size(); ++k) { int port_index = node.data.midi_out_ports[k]; - auto& buf = node.m_midi_atom_outs[k]; + auto& buf = v0.midi_atom_outs[k]; LV2_ATOM_SEQUENCE_FOREACH(&buf.buf->atoms, ev) { @@ -788,16 +1080,23 @@ void LV2EffectComponent::lazy_init() of.self = os.self; of.q = ctx.weakEditionQueue(); + LV2::LV2Data lv2data{host.lv2_host_context, proc.effectContext}; + auto strategy = LV2::choose_voice_strategy(lv2data); + auto node = ossia::make_node( - *ctx.execState, LV2::LV2Data{host.lv2_host_context, proc.effectContext}, - ctx.execState->sampleRate, os, of); + *ctx.execState, lv2data, ctx.execState->sampleRate, strategy, os, of); for(std::size_t i = proc.m_controlInStart; i < proc.inlets().size(); i++) { - auto inlet = static_cast(proc.inlets()[i]); - node->fInControls[i - proc.m_controlInStart] = ossia::convert(inlet->value()); - auto inl = node->root_inputs()[i]; + auto* inlet = static_cast(proc.inlets()[i]); + const auto ctrl_idx = i - proc.m_controlInStart; + const float init_val = ossia::convert(inlet->value()); + for(auto& v : node->voices) + if(ctrl_idx < v->fInControls.size()) + v->fInControls[ctrl_idx] = init_val; + + auto inl = node->root_inputs()[i]; auto& vp = *inl->target(); vp.type = inlet->value().get_type(); vp.domain = inlet->domain().get(); @@ -813,6 +1112,72 @@ void LV2EffectComponent::lazy_init() this->node = node; m_ossia_process = std::make_shared(node); + + // Grow voice pool on main thread (lilv_plugin_instantiate can dlopen / preload samples) + if(strategy.routing == LV2::voice_routing::per_channel) + { + m_pool_sample_rate = ctx.execState->sampleRate; + m_pool_pushed = node->voices.size(); + m_pool_max_requested = m_pool_pushed; + + m_grow_timer = new QTimer(this); + m_grow_timer->setInterval(50); + connect(m_grow_timer, &QTimer::timeout, this, [this] { growVoicePoolTick(); }); + m_grow_timer->start(); + } +} + +void LV2EffectComponent::growVoicePoolTick() +{ + auto node_base = this->node; + if(!node_base) + return; + auto* node = static_cast(node_base.get()); + + // High-water mark catches transients the audio thread smoothed (2 -> 64 -> 2) + const auto requested + = node->requested_voices.load(std::memory_order_acquire); + if(requested > m_pool_max_requested) + m_pool_max_requested = requested; + + if(m_pool_pushed >= m_pool_max_requested) + return; + + // Snapshot voice 0 so new instances inherit loaded samples / ongoing edits + LilvState* state = node->snapshot_state_raw(); + + // 50 ms x 4 = ~80 voices/s; samplers may preload banks per instantiation + constexpr std::size_t kBatch = 4; + const std::size_t to_add + = std::min(kBatch, m_pool_max_requested - m_pool_pushed); + + auto weak_node = std::weak_ptr{this->node}; + for(std::size_t i = 0; i < to_add; ++i) + { + auto v = node->make_voice_for_pool(m_pool_sample_rate, state); + if(!v) + { + qWarning() << "LV2: failed to grow voice pool"; + break; + } + // Voice cleanup is main-thread-only; bounce back if the node died + in_exec([weak_node, v = std::move(v)]() mutable { + if(auto n = weak_node.lock()) + { + static_cast(n.get())->append_voice(std::move(v)); + } + else + { + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [v = std::move(v)]() mutable { v.reset(); }); + } + }); + ++m_pool_pushed; + } + + if(state) + lilv_state_free(state); } void LV2EffectComponent::writeAtomToUi( diff --git a/src/plugins/score-plugin-lv2/LV2/EffectModel.hpp b/src/plugins/score-plugin-lv2/LV2/EffectModel.hpp index d1ab55fed4..87fb3586bc 100644 --- a/src/plugins/score-plugin-lv2/LV2/EffectModel.hpp +++ b/src/plugins/score-plugin-lv2/LV2/EffectModel.hpp @@ -6,9 +6,12 @@ #include #include #include +#include #include +#include + #include #include @@ -16,6 +19,8 @@ namespace LV2 { class Model; +// Call after a rescan: LilvPlugin* values may dangle if lilv rebuilt its index +void clearPluginCache(); } PROCESS_METADATA( , LV2::Model, "fd5243ba-70b5-4164-b44a-ecb0dcdc0494", "LV2", "LV2", @@ -50,6 +55,10 @@ class Model : public Process::ProcessModel bool hasExternalUI() const noexcept; + void loadPreset(const Process::Preset& preset) override; + Process::Preset savePreset() const noexcept override; + std::vector builtinPresets() const noexcept override; + const LilvPlugin* plugin{}; mutable LV2::EffectContext effectContext; LV2::HostContext* hostContext{}; @@ -88,6 +97,15 @@ class LV2EffectComponent final void writeAtomToUi(uint32_t port_index, uint32_t type, uint32_t size, const void* body); void writeAtomToUi(uint32_t port_index, LV2_Atom& atom); + +private: + // per_channel voice-pool grower (main thread). Audio thread pushes high-water mark. + QTimer* m_grow_timer{}; + int m_pool_sample_rate{}; + std::size_t m_pool_pushed{}; + std::size_t m_pool_max_requested{}; + + void growVoicePoolTick(); }; } diff --git a/src/plugins/score-plugin-lv2/LV2/Library.hpp b/src/plugins/score-plugin-lv2/LV2/Library.hpp index 7c3e9b2465..0e013c4d86 100644 --- a/src/plugins/score-plugin-lv2/LV2/Library.hpp +++ b/src/plugins/score-plugin-lv2/LV2/Library.hpp @@ -4,73 +4,60 @@ #include #include -#include +#include #include +#include namespace LV2 { class LibraryHandler final : public Library::LibraryInterface { SCORE_CONCRETE("570f0b92-a091-47ff-a5c3-a585e07df2bf") + void setup(Library::ProcessesItemModel& model, const score::GUIApplicationContext& ctx) override { - score::TaskPool::instance().post([model = QPointer{&model}, &ctx] { - auto& plug = ctx.applicationPlugin(); - auto& world = plug.lilv; + constexpr static const auto key = Metadata::get(); - // This part can take a few seconds so we do it in threadpool - auto plugs = world.get_all_plugins(); + QModelIndex node = model.find(key); + if(node == QModelIndex{}) + return; - ossia::flat_map> categories; - categories.reserve(50); + auto& parent = *reinterpret_cast(node.internalPointer()); + parent.key = {}; - { - auto lck = std::unique_lock{plug.library_lock}; - if(plug.abort_library_scan) - return; + auto& plug = ctx.applicationPlugin(); - auto it = plugs.begin(); - while(!plugs.is_end(it)) + auto reset_plugs = [&plug, &parent] { + QMap categories; + for(const auto& info : plug.cachedDescriptors()) + { + if(!info.valid) + continue; + QString category + = info.class_label.isEmpty() ? QStringLiteral("Other") : info.class_label; + if(!categories.contains(category)) { - if(plug.abort_library_scan) - return; - - auto plug = plugs.get(it); - const auto class_name = plug.get_class().get_label().as_string(); - const auto plug_name = get_lv2_plugin_name(plug); - categories[class_name].push_back(plug_name); - it = plugs.next(it); + auto& cat_node = parent.emplace_back( + Library::ProcessData{{{}, category, {}}, {}}, &parent); + categories[category] = &cat_node; } + Library::ProcessData pdata{{key, info.name, info.uri}, {}}; + Library::addToLibrary(*categories[category], std::move(pdata)); } + }; - // Back to main thread - QMetaObject::invokeMethod( - QCoreApplication::instance(), [model, categories = std::move(categories)] { - if(!model) - return; + reset_plugs(); - const auto& key = LV2::ProcessFactory{}.concreteKey(); - QModelIndex node = model->find(key); - if(node == QModelIndex{}) - return; - - auto& parent = *reinterpret_cast(node.internalPointer()); - - for(auto& category : categories) - { - // Already sorted through the map - auto& cat = parent.emplace_back( - Library::ProcessData{Process::ProcessData{{}, category.first, {}}, {}}, - &parent); - for(auto& plug : category.second) - { - Library::addToLibrary( - cat, Library::ProcessData{Process::ProcessData{key, plug, plug}, {}}); - } - } - }); + // Full reset: tree edits below are plain-data; partial signals would dangle indexes + QObject::connect( + &plug, &LV2::ApplicationPlugin::descriptorsChanged, &model, + [&model, &parent, reset_plugs] { + model.beginResetModel(); + parent.resize(0); + reset_plugs(); + model.endResetModel(); }); } }; diff --git a/src/plugins/score-plugin-lv2/LV2/Node.hpp b/src/plugins/score-plugin-lv2/LV2/Node.hpp index 50fd009863..6d953c6d92 100644 --- a/src/plugins/score-plugin-lv2/LV2/Node.hpp +++ b/src/plugins/score-plugin-lv2/LV2/Node.hpp @@ -5,370 +5,452 @@ #include #include #include +#include #include +#include #include +#include + +#include + +#include +#include +#include +#include +#include + namespace LV2 { -template -struct lv2_node final : public ossia::graph_node + +// One LilvInstance + per-voice scratch / atom buffers. Pinned (TimePort holds pointers). +struct voice { - LV2Data data; - ossia::float_vector fInControls, fOutControls, fParamMin, fParamMax, fParamInit, - fOtherControls; + SharedInstance instance_holder; + LilvInstance* instance{}; // cached: instance_holder->instance + + ossia::float_vector fInControls; + ossia::float_vector fOutControls; + ossia::float_vector fOtherControls; std::vector fCVs; - std::vector m_midi_atom_ins, m_midi_atom_outs; - std::vector m_midi_2to1; - std::vector m_midi_1to2; - std::vector> m_message_for_midi_atom_ins; - std::vector m_atom_ins, m_atom_outs; - std::vector> m_message_for_atom_ins; + std::vector audio_in_scratch; + std::vector audio_out_scratch; - LilvInstance* fInstance{}; - std::unique_ptr timePositionBuffer{}; - struct MatchedPort + std::vector midi_atom_ins; + std::vector midi_atom_outs; + std::vector midi_2to1; + std::vector midi_1to2; + std::vector> message_for_midi_atom_ins; + + std::vector atom_ins; + std::vector atom_outs; + std::vector> message_for_atom_ins; + + struct TimePort { int port; AtomBuffer* buffer{}; }; - std::vector m_atom_timePosition_midi; - std::vector m_atom_timePosition_owned; + std::vector atom_timePosition_midi; + std::vector atom_timePosition_owned_buffers; + std::vector atom_timePosition_owned; + + const LV2_Worker_Interface* worker{}; + ossia::mpmc_queue> worker_datas; + + voice() = default; + voice(const voice&) = delete; + voice& operator=(const voice&) = delete; + voice(voice&&) = delete; + voice& operator=(voice&&) = delete; + + // Voice 0's instance survives via Model's shared ref and is re-activated next play + ~voice() noexcept + { + if(instance_holder && instance && instance_holder->activated) + { + lilv_instance_deactivate(instance); + instance_holder->activated = false; + } + } +}; + +enum class voice_routing : std::uint8_t +{ + single, + // N voices, channel c -> voice c (mono plug-in on multichannel bus) + per_channel +}; + +struct voice_strategy +{ + voice_routing routing{voice_routing::single}; + std::size_t voice_count{1}; +}; + +// 1-in/1-out plug-ins get N replicated voices for multichannel host buses +inline voice_strategy +choose_voice_strategy(const LV2Data& data, std::size_t replicate_count = 8) noexcept +{ + const auto ains = data.audio_in_ports.size(); + const auto aouts = data.audio_out_ports.size(); + if(ains == 1 && aouts == 1 && replicate_count > 1) + return {voice_routing::per_channel, replicate_count}; + return {voice_routing::single, 1}; +} + +template +struct lv2_node final : public ossia::graph_node +{ + LV2Data data; + std::vector> voices; + voice_routing routing{voice_routing::single}; + + ossia::float_vector fParamMin, fParamMax, fParamInit; + std::unique_ptr timePositionBuffer{}; + + // Matches LV2_BUF_SIZE__maxBlockLength advertised in Context.cpp + std::size_t max_block_size{4096}; + + // Audio-thread high-water mark; monotonic; main-thread grower reads via in_exec + std::atomic requested_voices{0}; OnExecStart on_start; OnExecFinished on_finished; - lv2_node(LV2Data dat, int sampleRate, OnExecStart os, OnExecFinished of) + + // Avoids RT-unfriendly realloc in append_voice for common channel counts + static constexpr std::size_t kVoicePoolReserve = 256; + + lv2_node( + const LV2Data& dat, int sampleRate, voice_strategy strat, OnExecStart os, + OnExecFinished of) : data{dat} + , routing{strat.routing} , on_start{os} , on_finished{of} { this->set_not_fp_safe(); - const std::size_t audio_in_size = data.audio_in_ports.size(); - const std::size_t audio_out_size = data.audio_out_ports.size(); - const std::size_t control_in_size = data.control_in_ports.size(); - const std::size_t control_out_size = data.control_out_ports.size(); - const std::size_t midi_in_size = data.midi_in_ports.size(); - const std::size_t midi_out_size = data.midi_out_ports.size(); - const std::size_t atom_in_size = data.atom_in_ports.size(); - const std::size_t atom_out_size = data.atom_out_ports.size(); - const std::size_t time_in_size = data.time_Position_ports.size(); - const std::size_t cv_size = data.cv_ports.size(); - const std::size_t other_size = data.control_other_ports.size(); - const std::size_t num_ports = data.effect.plugin.get_num_ports(); + if(strat.voice_count < 1) + strat.voice_count = 1; + + const auto audio_in_size = data.audio_in_ports.size(); + const auto audio_out_size = data.audio_out_ports.size(); + const auto cv_size = data.cv_ports.size(); + const auto midi_in_size = data.midi_in_ports.size(); + const auto midi_out_size = data.midi_out_ports.size(); + const auto atom_in_size = data.atom_in_ports.size(); + const auto atom_out_size = data.atom_out_ports.size(); + const auto control_in_size = data.control_in_ports.size(); + const auto control_out_size = data.control_out_ports.size(); if(audio_in_size > 0) - { m_inlets.push_back(new ossia::audio_inlet); - } if(audio_out_size > 0) - { m_outlets.push_back(new ossia::audio_outlet); - } - for(std::size_t i = 0; i < cv_size; i++) - { m_inlets.push_back(new ossia::audio_inlet); - } - for(std::size_t i = 0; i < midi_in_size; i++) - { m_inlets.push_back(new ossia::midi_inlet); - } for(std::size_t i = 0; i < midi_out_size; i++) - { m_outlets.push_back(new ossia::midi_outlet); - } - for(std::size_t i = 0; i < atom_in_size; i++) - { m_inlets.push_back(new ossia::value_inlet); - } for(std::size_t i = 0; i < atom_out_size; i++) - { m_outlets.push_back(new ossia::value_outlet); - } - for(std::size_t i = 0; i < control_in_size; i++) - { m_inlets.push_back(new ossia::value_inlet); - } for(std::size_t i = 0; i < control_out_size; i++) - { m_outlets.push_back(new ossia::value_outlet); - } - - fInControls.resize(control_in_size); - fOutControls.resize(control_out_size); - fOtherControls.resize(other_size); - fCVs.resize(cv_size); - for(std::size_t i = 0; i < cv_size; i++) - { - fCVs[i].resize(4096); - } + const auto num_ports = data.effect.plugin.get_num_ports(); fParamMin.resize(num_ports); fParamMax.resize(num_ports); fParamInit.resize(num_ports); - data.effect.plugin.get_port_ranges_float( fParamMin.data(), fParamMax.data(), fParamInit.data()); - fInstance = data.effect.instance; - data.effect.instance = fInstance; - - if(!fInstance) + if(!data.effect.instance_holder) throw std::runtime_error("Error while creating a LV2 plug-in"); - // MIDI - m_midi_atom_ins.reserve(midi_in_size); - m_midi_2to1.resize(midi_in_size); - m_message_for_midi_atom_ins.resize(midi_in_size); - for(std::size_t i = 0; i < midi_in_size; i++) + voices.reserve( + routing == voice_routing::per_channel ? kVoicePoolReserve : strat.voice_count); + + // Voice 0 reuses the Model's primary instance across play->stop->play + voices.push_back(build_voice(data.effect.instance_holder)); + + // Snapshot so extras inherit loaded samples/models; ports seeded separately below + LilvState* primary_state = (strat.voice_count > 1) ? snapshot_state_raw() : nullptr; + + // lv2_node is constructed on the main thread; LV2EffectComponent grows past this + for(std::size_t i = 1; i < strat.voice_count; ++i) { - m_midi_atom_ins.emplace_back( - 2048, data.host.atom_chunk_id, data.host.midi_event_id, true); + auto handle = instantiate_handle(sampleRate); + if(!handle) + break; + if(primary_state) + lilv_state_restore( + primary_state, handle->instance, nullptr, nullptr, + LV2_STATE_IS_PORTABLE, nullptr); + voices.push_back(build_voice(std::move(handle))); } - m_midi_atom_outs.reserve(midi_out_size); - m_midi_1to2.resize(midi_out_size); + if(primary_state) + lilv_state_free(primary_state); + + if(!data.time_Position_ports.empty()) + timePositionBuffer = std::make_unique(256); + + requested_voices.store(voices.size(), std::memory_order_relaxed); + } + + ~lv2_node() noexcept override + { + // May run on audio thread; voice cleanup is main-thread-only per LV2 spec + if(voices.empty()) + return; + QMetaObject::invokeMethod( + QCoreApplication::instance(), + [pending = std::move(voices)]() mutable { pending.clear(); }); + } + + // [main thread] + std::unique_ptr build_voice(SharedInstance handle) + { + auto v = std::make_unique(); + v->instance_holder = std::move(handle); + v->instance = v->instance_holder->instance; + allocate_voice_buffers(*v); + connect_voice_ports(*v); + activate_voice(*v); + return v; + } + + // [main thread] + SharedInstance instantiate_handle(int sampleRate) + { + auto* inst = lilv_plugin_instantiate( + data.effect.plugin.me, sampleRate, + data.host.global ? data.host.global->features() : data.host.features); + if(!inst) + return {}; + return std::make_shared(inst); + } + + // [main thread] null on failure; grower retries next tick + std::unique_ptr + make_voice_for_pool(int sampleRate, LilvState* state) + { + auto handle = instantiate_handle(sampleRate); + if(!handle) + return nullptr; + + if(state) + lilv_state_restore( + state, handle->instance, nullptr, nullptr, LV2_STATE_IS_PORTABLE, + nullptr); + + return build_voice(std::move(handle)); + } + + // [main thread] Caller frees with lilv_state_free + LilvState* snapshot_state_raw() const noexcept + { + if(voices.empty() || !voices[0]->instance || !data.host.global) + return nullptr; + return lilv_state_new_from_instance( + data.effect.plugin.me, voices[0]->instance, &data.host.global->map, + nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, + LV2_STATE_IS_PORTABLE, nullptr); + } + + // [audio thread] from main-thread grower via in_exec + void append_voice(std::unique_ptr v) noexcept + { + voices.push_back(std::move(v)); + } + + // [audio thread] Monotonic high-water mark + void request_voice_count(std::size_t n) noexcept + { + auto prev = requested_voices.load(std::memory_order_relaxed); + while(n > prev + && !requested_voices.compare_exchange_weak( + prev, n, std::memory_order_release, std::memory_order_relaxed)) + ; + } + + void allocate_voice_buffers(voice& v) + { + const auto control_in_size = data.control_in_ports.size(); + const auto control_out_size = data.control_out_ports.size(); + const auto cv_size = data.cv_ports.size(); + const auto other_size = data.control_other_ports.size(); + const auto midi_in_size = data.midi_in_ports.size(); + const auto midi_out_size = data.midi_out_ports.size(); + const auto atom_in_size = data.atom_in_ports.size(); + const auto atom_out_size = data.atom_out_ports.size(); + const auto audio_in_size = data.audio_in_ports.size(); + const auto audio_out_size = data.audio_out_ports.size(); + + v.fInControls.resize(control_in_size); + v.fOutControls.resize(control_out_size); + v.fOtherControls.resize(other_size); + + v.fCVs.resize(cv_size); + for(auto& cv : v.fCVs) + cv.resize(max_block_size); + + v.audio_in_scratch.resize(audio_in_size); + for(auto& a : v.audio_in_scratch) + a.resize(max_block_size); + + v.audio_out_scratch.resize(audio_out_size); + for(auto& a : v.audio_out_scratch) + a.resize(max_block_size); + + v.midi_atom_ins.reserve(midi_in_size); + v.midi_2to1.resize(midi_in_size); + v.message_for_midi_atom_ins.resize(midi_in_size); + for(std::size_t i = 0; i < midi_in_size; i++) + v.midi_atom_ins.emplace_back( + 2048, data.host.atom_chunk_id, data.host.midi_event_id, true); + + v.midi_atom_outs.reserve(midi_out_size); + v.midi_1to2.resize(midi_out_size); for(std::size_t i = 0; i < midi_out_size; i++) - { - m_midi_atom_outs.emplace_back( + v.midi_atom_outs.emplace_back( 2048, data.host.atom_chunk_id, data.host.midi_event_id, false); - } - // Atom - m_atom_ins.reserve(atom_in_size); - m_message_for_atom_ins.resize(atom_in_size); + v.atom_ins.reserve(atom_in_size); + v.message_for_atom_ins.resize(atom_in_size); for(std::size_t i = 0; i < atom_in_size; i++) - { - m_atom_ins.emplace_back( - 2048, data.host.atom_chunk_id, data.host.atom_object_id, true); // FIXME - } + v.atom_ins.emplace_back( + 2048, data.host.atom_chunk_id, data.host.atom_object_id, true); - m_atom_outs.reserve(atom_out_size); + v.atom_outs.reserve(atom_out_size); for(std::size_t i = 0; i < atom_out_size; i++) - { - m_atom_outs.emplace_back( + v.atom_outs.emplace_back( 2048, data.host.atom_chunk_id, data.host.atom_object_id, false); - } - // Timing - // Note: some plug-ins have the timing port shared with the midi port, others have it separate - for(std::size_t i = 0; i < time_in_size; i++) + // time:Position may share a MIDI port (atom:supports midi+time) or have its own atom port + v.atom_timePosition_owned_buffers.reserve(data.time_Position_ports.size()); + for(int port_index : data.time_Position_ports) { - auto port_index = data.time_Position_ports[i]; - - bool is_midi = false; - for(std::size_t midi_port_k = 0; midi_port_k < data.midi_in_ports.size(); - midi_port_k++) + bool shared = false; + for(std::size_t k = 0; k < data.midi_in_ports.size(); ++k) { - int midi_port_index = data.midi_in_ports[midi_port_k]; - if(midi_port_index == port_index) + if(data.midi_in_ports[k] == port_index) { - m_atom_timePosition_midi.push_back( - MatchedPort{port_index, &m_midi_atom_ins[midi_port_k]}); - is_midi = true; + v.atom_timePosition_midi.push_back({port_index, &v.midi_atom_ins[k]}); + shared = true; break; } } - - if(!is_midi) + if(!shared) { - // Allocate a new port - auto abuf = new AtomBuffer( + v.atom_timePosition_owned_buffers.emplace_back( 256, data.host.atom_chunk_id, data.host.time_Position_id, true); - m_atom_timePosition_owned.push_back(MatchedPort{port_index, abuf}); + v.atom_timePosition_owned.push_back( + {port_index, &v.atom_timePosition_owned_buffers.back()}); } } - // Worker if(lilv_plugin_has_feature(data.effect.plugin.me, data.host.work_schedule) && lilv_plugin_has_extension_data( data.effect.plugin.me, data.host.work_interface)) { - data.effect.worker = static_cast( - lilv_instance_get_extension_data(fInstance, LV2_WORKER__interface)); + v.worker = static_cast( + lilv_instance_get_extension_data(v.instance, LV2_WORKER__interface)); } for(std::size_t i = 0; i < control_in_size; i++) - { - auto port_i = data.control_in_ports[i]; - fInControls[i] = fParamInit[port_i]; - } + v.fInControls[i] = fParamInit[data.control_in_ports[i]]; - if(!m_atom_timePosition_midi.empty() || !m_atom_timePosition_owned.empty()) - { - // inspired from QTractor code, (c) RNCBC - timePositionBuffer = std::make_unique(256); - } - - lilv_instance_activate(fInstance); + (void)control_out_size; + (void)audio_in_size; + (void)audio_out_size; } - void connect_all_ports() + void connect_voice_ports(voice& v) noexcept { - const std::size_t control_in_size = data.control_in_ports.size(); - const std::size_t control_out_size = data.control_out_ports.size(); - const std::size_t midi_in_size = data.midi_in_ports.size(); - const std::size_t midi_out_size = data.midi_out_ports.size(); - const std::size_t atom_in_size = data.atom_in_ports.size(); - const std::size_t atom_out_size = data.atom_out_ports.size(); - const std::size_t cv_size = data.cv_ports.size(); - const std::size_t other_size = data.control_other_ports.size(); - - for(std::size_t i = 0; i < control_in_size; i++) - { - lilv_instance_connect_port(fInstance, data.control_in_ports[i], &fInControls[i]); - } - - for(std::size_t i = 0; i < control_out_size; i++) - { - lilv_instance_connect_port(fInstance, data.control_out_ports[i], &fOutControls[i]); - } - - for(std::size_t i = 0; i < cv_size; i++) - { - lilv_instance_connect_port(fInstance, data.cv_ports[i], fCVs[i].data()); - } - - for(std::size_t i = 0; i < other_size; i++) - { + auto* inst = v.instance; + for(std::size_t i = 0; i < data.control_in_ports.size(); i++) + lilv_instance_connect_port(inst, data.control_in_ports[i], &v.fInControls[i]); + for(std::size_t i = 0; i < data.control_out_ports.size(); i++) + lilv_instance_connect_port(inst, data.control_out_ports[i], &v.fOutControls[i]); + for(std::size_t i = 0; i < data.cv_ports.size(); i++) + lilv_instance_connect_port(inst, data.cv_ports[i], v.fCVs[i].data()); + for(std::size_t i = 0; i < data.control_other_ports.size(); i++) lilv_instance_connect_port( - fInstance, data.control_other_ports[i], &fOtherControls[i]); - } - - for(std::size_t i = 0; i < midi_in_size; i++) - { + inst, data.control_other_ports[i], &v.fOtherControls[i]); + for(std::size_t i = 0; i < v.midi_atom_ins.size(); i++) lilv_instance_connect_port( - fInstance, data.midi_in_ports[i], &m_midi_atom_ins[i].buf->atoms); - } - - for(std::size_t i = 0; i < midi_out_size; i++) - { - auto& out_p = data.midi_out_ports[i]; - auto atoms = &m_midi_atom_outs[i].buf->atoms; - lilv_instance_connect_port(fInstance, out_p, atoms); - } - - for(std::size_t i = 0; i < atom_in_size; i++) - { + inst, data.midi_in_ports[i], &v.midi_atom_ins[i].buf->atoms); + for(std::size_t i = 0; i < v.midi_atom_outs.size(); i++) lilv_instance_connect_port( - fInstance, data.atom_in_ports[i], &m_atom_ins[i].buf->atoms); - } - - for(std::size_t i = 0; i < atom_out_size; i++) - { - auto& out_p = data.atom_out_ports[i]; - auto atoms = &m_atom_outs[i].buf->atoms; - lilv_instance_connect_port(fInstance, out_p, atoms); - } + inst, data.midi_out_ports[i], &v.midi_atom_outs[i].buf->atoms); + for(std::size_t i = 0; i < v.atom_ins.size(); i++) + lilv_instance_connect_port( + inst, data.atom_in_ports[i], &v.atom_ins[i].buf->atoms); + for(std::size_t i = 0; i < v.atom_outs.size(); i++) + lilv_instance_connect_port( + inst, data.atom_out_ports[i], &v.atom_outs[i].buf->atoms); + for(auto& [port, buf] : v.atom_timePosition_owned) + lilv_instance_connect_port(inst, port, &buf->buf->atoms); + } - for(auto& [index, port] : m_atom_timePosition_owned) + void activate_voice(voice& v) noexcept + { + if(v.instance_holder && v.instance && !v.instance_holder->activated) { - lilv_instance_connect_port(fInstance, index, &port->buf->atoms); + lilv_instance_activate(v.instance); + v.instance_holder->activated = true; } } void all_notes_off() noexcept override { - // TODO - } - - [[nodiscard]] std::string label() const noexcept override - { - return fmt::format("lv2 ({})", data.effect.plugin.get_name().as_string()); - } - void preProcess() - { - const std::size_t audio_in_size = data.audio_in_ports.size(); - const std::size_t cv_size = data.cv_ports.size(); - const std::size_t midi_in_size = data.midi_in_ports.size(); - const std::size_t atom_in_size = data.atom_in_ports.size(); - const std::size_t control_in_size = data.control_in_ports.size(); - - // Callback from UI - on_start(); - - // Copy midi - int first_midi_idx = (audio_in_size > 0 ? 1 : 0) + cv_size; - for(std::size_t i = 0; i < m_midi_atom_ins.size(); i++) - { - ossia::midi_port& ossia_port - = this->m_inlets[i + first_midi_idx]->template cast(); - auto& lv2_port = m_midi_atom_ins[i]; - Iterator it{lv2_port.buf}; - - // Message from the UI - for(const Message& msg : this->m_message_for_midi_atom_ins[i]) - { - auto atom = (LV2_Atom*)msg.body.data(); - auto atom_data = (const uint8_t*)LV2_ATOM_BODY(atom); - it.write(0, 0, atom->type, atom->size, atom_data); - } - - // MIDI input - auto& conv = m_midi_2to1[i]; - for(const libremidi::ump& msg : ossia_port.messages) + // CC#120 (All Sound Off) + CC#123 (All Notes Off) on every channel of every voice + if(data.midi_in_ports.empty()) + return; + const int midi_port_index = data.midi_in_ports[0]; + for(auto& v : voices) + { + if(v->message_for_midi_atom_ins.empty()) + continue; + auto& msg_queue = v->message_for_midi_atom_ins[0]; + for(uint8_t ch = 0; ch < 16; ++ch) { - conv.convert( - msg.data, msg.size(), msg.timestamp, - [&](unsigned char* midi1, int bytes, int64_t ts) { - if(bytes > 0) - { - it.write(msg.timestamp, 0, data.host.midi_event_id, bytes, midi1); - } - return stdx::error{}; - }); - } - - // Copy timing for MIDI ports - if(this->m_atom_timePosition_midi.size() != 0) - { - const LV2_Atom* atom = (const LV2_Atom*)timePositionBuffer.get(); - - // Time position - it.write(0, 0, atom->type, atom->size, (const uint8_t*)LV2_ATOM_BODY(atom)); - } - } - - // Copy timing for timing-only ports - for(auto& [port, atoms] : m_atom_timePosition_owned) - { - auto& lv2_port = atoms; - Iterator it{lv2_port->buf}; - { - const LV2_Atom* atom = (const LV2_Atom*)timePositionBuffer.get(); - - // Time position - it.write(0, 0, atom->type, atom->size, (const uint8_t*)LV2_ATOM_BODY(atom)); - } - } - - // FIXME atom_in ports - - // Copy controls - auto control_start - = (audio_in_size > 0 ? 1 : 0) + midi_in_size + atom_in_size + cv_size; - for(std::size_t i = control_start; i < control_in_size; i++) - { - auto& in = m_inlets[i]->template cast().get_data(); - - if(!in.empty()) - { - if(auto f = in.back().value.template target()) + for(uint8_t cc : {uint8_t(120), uint8_t(123)}) { - fInControls[i - control_start] = *f; + Message m; + m.index = uint32_t(midi_port_index); + m.protocol = data.host.atom_eventTransfer; + m.body.resize(sizeof(LV2_Atom) + 3); + auto* atom = reinterpret_cast(m.body.data()); + atom->type = data.host.midi_event_id; + atom->size = 3; + uint8_t* d = m.body.data() + sizeof(LV2_Atom); + d[0] = uint8_t(0xB0 | ch); + d[1] = cc; + d[2] = 0; + msg_queue.push_back(std::move(m)); } } } } + [[nodiscard]] std::string label() const noexcept override + { + return fmt::format("lv2 ({})", data.effect.plugin.get_name().as_string()); + } + void updateTime(const ossia::token_request& tk, ossia::exec_state_facade st) { - LV2::HostContext& host = this->data.host; + LV2::HostContext& host = data.host; auto& forge = host.forge; uint8_t* buffer = timePositionBuffer.get(); lv2_atom_forge_set_buffer(&forge, buffer, 256); @@ -407,46 +489,146 @@ struct lv2_node final : public ossia::graph_node lv2_atom_forge_pop(&forge, &frame); } - void postProcess(int64_t offset) + // Vec slices by voice (clamped); scalars broadcast. Mirrors faust_mono_fx::set_control. + static std::optional + voice_control_value(const ossia::value& val, std::size_t voice_idx) noexcept + { + return ossia::apply_nonnull( + [voice_idx](const auto& v) -> std::optional { + using T = std::decay_t; + if constexpr(std::is_same_v>) + { + if(v.empty()) + return std::nullopt; + const auto& elem = (voice_idx < v.size()) ? v[voice_idx] : v.back(); + return ossia::convert(elem); + } + else if constexpr(std::is_same_v) + return v[std::min(voice_idx, 1)]; + else if constexpr(std::is_same_v) + return v[std::min(voice_idx, 2)]; + else if constexpr(std::is_same_v) + return v[std::min(voice_idx, 3)]; + else if constexpr( + std::is_same_v || std::is_same_v + || std::is_same_v) + return float(v); + else + return std::nullopt; + }, + val.v); + } + + void preProcessVoice(voice& v, std::size_t voice_idx) { - if(data.effect.worker && data.effect.worker->work_response) + const std::size_t audio_in_size = data.audio_in_ports.size(); + const std::size_t cv_size = data.cv_ports.size(); + const std::size_t midi_in_size = data.midi_in_ports.size(); + const std::size_t atom_in_size = data.atom_in_ports.size(); + const std::size_t control_in_size = data.control_in_ports.size(); + + int first_midi_idx = (audio_in_size > 0 ? 1 : 0) + cv_size; + for(std::size_t i = 0; i < v.midi_atom_ins.size(); i++) { - std::vector vec; - while(data.effect.worker_datas.try_dequeue(vec)) + auto& ossia_port + = this->m_inlets[i + first_midi_idx]->template cast(); + auto& lv2_port = v.midi_atom_ins[i]; + Iterator it{lv2_port.buf}; + + for(const Message& msg : v.message_for_midi_atom_ins[i]) + { + auto* atom = (LV2_Atom*)msg.body.data(); + auto* atom_data = (const uint8_t*)LV2_ATOM_BODY(atom); + it.write(0, 0, atom->type, atom->size, atom_data); + } + + auto& conv = v.midi_2to1[i]; + for(const libremidi::ump& msg : ossia_port.messages) { - data.effect.worker->work_response( - data.effect.instance->lv2_handle, vec.size(), vec.data()); + conv.convert( + msg.data, msg.size(), msg.timestamp, + [&](unsigned char* midi1, int bytes, int64_t) { + if(bytes > 0) + it.write(msg.timestamp, 0, data.host.midi_event_id, bytes, midi1); + return stdx::error{}; + }); + } + + if(!v.atom_timePosition_midi.empty()) + { + const LV2_Atom* atom = (const LV2_Atom*)timePositionBuffer.get(); + it.write(0, 0, atom->type, atom->size, (const uint8_t*)LV2_ATOM_BODY(atom)); } } - if(data.effect.worker && data.effect.worker->end_run) + for(auto& [_, buf] : v.atom_timePosition_owned) { - data.effect.worker->end_run(data.effect.instance->lv2_handle); + Iterator it{buf->buf}; + const LV2_Atom* atom = (const LV2_Atom*)timePositionBuffer.get(); + it.write(0, 0, atom->type, atom->size, (const uint8_t*)LV2_ATOM_BODY(atom)); } - const std::size_t audio_out_size = data.audio_out_ports.size(); - const std::size_t midi_out_size = data.midi_out_ports.size(); - const std::size_t control_out_size = data.control_out_ports.size(); + const auto control_start + = (audio_in_size > 0 ? 1 : 0) + midi_in_size + atom_in_size + cv_size; + for(std::size_t i = 0; i < control_in_size; i++) + { + auto& in = m_inlets[control_start + i] + ->template cast() + .get_data(); + if(in.empty()) + continue; + if(auto new_val = voice_control_value(in.back().value, voice_idx)) + v.fInControls[i] = *new_val; + } + + (void)atom_in_size; + } + + void postProcessVoice(voice& v) noexcept + { + if(v.worker && v.worker->work_response) + { + std::vector vec; + while(v.worker_datas.try_dequeue(vec)) + v.worker->work_response(v.instance->lv2_handle, vec.size(), vec.data()); + } + if(v.worker && v.worker->end_run) + v.worker->end_run(v.instance->lv2_handle); + + for(auto& port : v.midi_atom_ins) + port.buf->reset(true); + for(auto& port : v.atom_ins) + port.buf->reset(true); + for(auto& [_, buf] : v.atom_timePosition_owned) + buf->buf->reset(true); + for(auto& port : v.midi_atom_outs) + port.buf->reset(false); + for(auto& port : v.atom_outs) + port.buf->reset(false); + for(auto& mqueue : v.message_for_midi_atom_ins) + mqueue.clear(); + } + + // per_channel: inputs broadcast -> voice 0 outputs are canonical + void surfaceVoiceZeroOutputs(int64_t offset) + { + auto& v0 = *voices[0]; + const auto audio_out_size = data.audio_out_ports.size(); + const auto midi_out_size = data.midi_out_ports.size(); + const auto control_out_size = data.control_out_ports.size(); - // Copy midi - // FIXME the line below definitely does not look right int first_midi_idx = (audio_out_size > 0 ? 1 : 0); - for(std::size_t i = 0; i < m_midi_atom_outs.size(); i++) + for(std::size_t i = 0; i < v0.midi_atom_outs.size(); i++) { - ossia::midi_port& ossia_port + auto& ossia_port = this->m_outlets[i + first_midi_idx]->template cast(); - AtomBuffer& lv2_port = m_midi_atom_outs[i]; - auto& conv = m_midi_1to2[i]; - - const LV2::HostContext& host = this->data.host; + auto& lv2_port = v0.midi_atom_outs[i]; + auto& conv = v0.midi_1to2[i]; LV2_ATOM_SEQUENCE_FOREACH(&lv2_port.buf->atoms, ev) { - if(ev->body.type == host.midi_event_id) + if(ev->body.type == data.host.midi_event_id) { - libremidi::ump msg; - msg.timestamp = ev->time.frames; - auto bytes = (uint8_t*)LV2_ATOM_BODY_CONST(&ev->body); conv.convert( bytes, ev->body.size, ev->time.frames, @@ -460,124 +642,170 @@ struct lv2_node final : public ossia::graph_node } else { - // FIXME + // FIXME forward non-MIDI atom outputs (patch:Set etc.) to a value outlet qDebug() << "Unhandled LV2 event type: " << ev->body.type; } } } - // Copy controls - auto control_start = (audio_out_size > 0 ? 1 : 0) + midi_out_size; - for(std::size_t i = control_start; i < control_out_size; i++) + const auto control_start = (audio_out_size > 0 ? 1 : 0) + midi_out_size; + for(std::size_t i = 0; i < control_out_size; i++) { - auto& out = m_outlets[i]->template cast(); - - out.write_value(fOutControls[i - control_start], offset); + auto& out + = m_outlets[control_start + i]->template cast(); + out.write_value(v0.fOutControls[i], offset); } + } - // Callback to UI - on_finished(); + void run(const ossia::token_request& tk, ossia::exec_state_facade st) noexcept override + { + if(tk.date <= tk.prev_date) + return; + + data.host.current = &data.effect; + if(!data.time_Position_ports.empty()) + updateTime(tk, st); + + on_start(); + for(std::size_t c = 0; c < voices.size(); ++c) + preProcessVoice(*voices[c], c); + + const auto [tick_start, samples] = st.timings(tk); + const bool samples_valid + = samples > 0 && std::size_t(samples) <= max_block_size; - for(AtomBuffer& port : m_midi_atom_ins) + if(samples_valid) { - port.buf->reset(true); + switch(routing) + { + case voice_routing::single: + runSingle(samples); + break; + case voice_routing::per_channel: + runPerChannel(samples); + break; + } } - for(AtomBuffer& port : m_atom_ins) + + surfaceVoiceZeroOutputs(tk.physical_start(st.modelToSamples())); + on_finished(); + + for(auto& v : voices) + postProcessVoice(*v); + } + + struct worker_routing_scope + { + LV2::HostContext& host; + worker_routing_scope(LV2::HostContext& h, voice& v) noexcept + : host{h} { - port.buf->reset(true); + host.current_worker_datas = &v.worker_datas; } - for(auto& [port, atoms] : m_atom_timePosition_owned) + ~worker_routing_scope() noexcept { host.current_worker_datas = nullptr; } + }; + + void runSingle(int samples) noexcept + { + auto& v = *voices[0]; + const auto audio_ins = data.audio_in_ports.size(); + const auto audio_outs = data.audio_out_ports.size(); + + if(audio_ins > 0) { - atoms->buf->reset(true); + const auto& audio_in = m_inlets[0]->template cast(); + const auto in_channels = audio_in.channels(); + for(std::size_t i = 0; i < audio_ins; i++) + { + auto& scratch = v.audio_in_scratch[i]; + std::fill_n(scratch.data(), samples, 0.0f); + if(in_channels > i) + { + const auto& ch = audio_in.channel(i); + const auto n = std::min(samples, ch.size()); + for(std::size_t j = 0; j < n; j++) + scratch[j] = float(ch[j]); + } + lilv_instance_connect_port( + v.instance, data.audio_in_ports[i], scratch.data()); + } } - for(AtomBuffer& port : m_midi_atom_outs) + + for(std::size_t i = 0; i < audio_outs; i++) + lilv_instance_connect_port( + v.instance, data.audio_out_ports[i], v.audio_out_scratch[i].data()); + { - port.buf->reset(false); + worker_routing_scope ws{data.host, v}; + lilv_instance_run(v.instance, samples); } - for(AtomBuffer& port : m_atom_outs) + + if(audio_outs > 0) { - port.buf->reset(false); + auto& out = static_cast(m_outlets[0])->data; + out.set_channels(audio_outs); + for(std::size_t i = 0; i < audio_outs; i++) + { + auto& ch = out.channel(i); + ch.assign( + v.audio_out_scratch[i].begin(), + v.audio_out_scratch[i].begin() + samples); + } } - - for(auto& mqueue : m_message_for_midi_atom_ins) - mqueue.clear(); } - ~lv2_node() override + // voice c <-> channel c; MIDI/controls broadcast in preProcessVoice + void runPerChannel(int samples) noexcept { - lilv_instance_deactivate(fInstance); - if(!m_atom_timePosition_owned.empty()) - for(auto [port, atoms] : m_atom_timePosition_owned) - delete atoms; - } + if(voices.empty()) + return; - void run(const ossia::token_request& tk, ossia::exec_state_facade st) noexcept override - { - if(tk.date > tk.prev_date) + const auto* audio_in_inlet + = m_inlets.empty() ? nullptr : m_inlets[0]->template target(); + const auto in_channels = audio_in_inlet ? audio_in_inlet->channels() : 0; + + // Pure synths drive all voices; otherwise follow input channel count + const auto requested + = (audio_in_inlet ? std::max(in_channels, 1) : voices.size()); + request_voice_count(requested); + + const auto active = std::min(voices.size(), requested); + + auto& out = static_cast(m_outlets[0])->data; + out.set_channels(active); + + for(std::size_t c = 0; c < active; ++c) { - data.host.current = &data.effect; - if(!data.time_Position_ports.empty()) - updateTime(tk, st); - - preProcess(); - - const auto [tick_start, samples] = st.timings(tk); - const auto audio_ins = data.audio_in_ports.size(); - const auto audio_outs = data.audio_out_ports.size(); - ossia::small_vector in_vec; - in_vec.resize(audio_ins); - ossia::small_vector out_vec; - out_vec.resize(audio_outs); - - connect_all_ports(); - if(audio_ins > 0) - { - const auto& audio_in = m_inlets[0]->template cast(); - for(std::size_t i = 0; i < audio_ins; i++) - { - in_vec[i].resize(samples); - if(audio_in.channels() > i) - { - for(std::size_t j = 0; - j < std::min(std::size_t(samples), audio_in.channel(i).size()); j++) - { - in_vec[i][j] = (float)audio_in.channel(i)[j]; - } - } - lilv_instance_connect_port( - fInstance, data.audio_in_ports[i], in_vec[i].data()); - } - } + auto& v = *voices[c]; - if(audio_outs > 0) + if(audio_in_inlet) { - for(std::size_t i = 0; i < audio_outs; i++) + auto& in_scratch = v.audio_in_scratch[0]; + std::fill_n(in_scratch.data(), samples, 0.0f); + if(in_channels > c) { - out_vec[i].resize(samples); - lilv_instance_connect_port( - fInstance, data.audio_out_ports[i], out_vec[i].data()); + const auto& src = audio_in_inlet->channel(c); + const auto n = std::min(samples, src.size()); + for(std::size_t j = 0; j < n; j++) + in_scratch[j] = float(src[j]); } + lilv_instance_connect_port( + v.instance, data.audio_in_ports[0], in_scratch.data()); } - lilv_instance_run(fInstance, samples); + auto& out_scratch = v.audio_out_scratch[0]; + lilv_instance_connect_port( + v.instance, data.audio_out_ports[0], out_scratch.data()); - if(audio_outs > 0) { - auto& audio_out = static_cast(m_outlets[0])->data; - audio_out.set_channels(audio_outs); - for(std::size_t i = 0; i < audio_outs; i++) - { - audio_out.channel(i).clear(); - audio_out.channel(i).reserve(samples); - for(int64_t j = 0; j < samples; j++) - { - audio_out.channel(i).push_back((double)out_vec[i][j]); - } - } + worker_routing_scope ws{data.host, v}; + lilv_instance_run(v.instance, samples); } - postProcess(tk.physical_start(st.modelToSamples())); + auto& dst = out.channel(c); + dst.assign(out_scratch.begin(), out_scratch.begin() + samples); } } }; -} + +} // namespace LV2 diff --git a/src/plugins/score-plugin-lv2/LV2/Window.cpp b/src/plugins/score-plugin-lv2/LV2/Window.cpp index 2747490702..6b08dcaa80 100644 --- a/src/plugins/score-plugin-lv2/LV2/Window.cpp +++ b/src/plugins/score-plugin-lv2/LV2/Window.cpp @@ -95,9 +95,11 @@ Window::Window(const Model& fx, const score::DocumentContext& ctx, QWidget* pare lay->addWidget(widget); m_widget = widget; - auto name = lilv_plugin_get_name(fx.plugin); - setWindowTitle(lilv_node_as_string(name)); - lilv_node_free(name); + { + auto name = lilv_plugin_get_name(fx.plugin); + setWindowTitle(lilv_node_as_string(name)); + lilv_node_free(name); + } // Set up regular updates QPointer fx_ptr{&fx}; diff --git a/src/plugins/score-plugin-lv2/LV2/lv2_atom_helpers.hpp b/src/plugins/score-plugin-lv2/LV2/lv2_atom_helpers.hpp index a32f5d28d6..ea458d09ab 100644 --- a/src/plugins/score-plugin-lv2/LV2/lv2_atom_helpers.hpp +++ b/src/plugins/score-plugin-lv2/LV2/lv2_atom_helpers.hpp @@ -203,10 +203,23 @@ struct AtomBuffer } AtomBuffer() = delete; - AtomBuffer(const AtomBuffer&) = default; - AtomBuffer(AtomBuffer&&) = default; - AtomBuffer& operator=(const AtomBuffer&) = default; - AtomBuffer& operator=(AtomBuffer&&) = default; + AtomBuffer(const AtomBuffer&) = delete; + AtomBuffer& operator=(const AtomBuffer&) = delete; + AtomBuffer(AtomBuffer&& other) noexcept + : buf{other.buf} + { + other.buf = nullptr; + } + AtomBuffer& operator=(AtomBuffer&& other) noexcept + { + if(this != &other) + { + ::operator delete(buf); + buf = other.buf; + other.buf = nullptr; + } + return *this; + } ~AtomBuffer() { ::operator delete(buf); } };