Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
f8d5f16
build: every translation unit of a binary agrees on the asio buffer d…
jcelerier Sep 14, 2026
22137bd
protocols: a mapper's script cannot outlive the device tree it writes…
jcelerier Sep 14, 2026
9301649
js: a device identifier handed to a script outlives the enumeration t…
jcelerier Sep 14, 2026
b21ec95
js: an enumerator is a subscription, and a device name is taken or it…
jcelerier Sep 14, 2026
28c869c
js: one Qt Quick runtime per renderer, and one of them publishes the …
jcelerier Sep 14, 2026
cf9f1a0
js: a destroyed texture source takes its preview node with it
jcelerier Sep 14, 2026
72ea7d6
avnd: a worker result is applied at the start of the node's own tick
jcelerier Sep 14, 2026
4cb3c57
avnd: a CPU-only buffer producer is one object, not one per render list
jcelerier Sep 14, 2026
2e1f654
tests: the manual QML protocol corpus, run by a real Mapper device
jcelerier Sep 14, 2026
e7fd99b
tests: the QML protocol corpus over real TCP, HTTP and datagram sockets
jcelerier Sep 14, 2026
fac285c
tests: the avendish object contract add-ons are written against
jcelerier Sep 14, 2026
f38d103
tests: an ossia output used as a texture source by a QML application
jcelerier Sep 14, 2026
2b15935
tests: the device enumeration contract a QML application drives
jcelerier Sep 14, 2026
1351dd5
tests: Protocols.* from the console engine, not only from a mapper
jcelerier Sep 14, 2026
1713909
tests: a use-case suite that hangs fails the run instead of stalling it
jcelerier Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,16 @@ set(SCORE_ROOT_BINARY_DIR "${CMAKE_CURRENT_BINARY_DIR}")
set(SCORE_AVND_SOURCE_DIR "${SCORE_ROOT_SOURCE_DIR}/src/plugins/score-plugin-avnd")
set(SCORE_SRC "${CMAKE_CURRENT_SOURCE_DIR}/src")

# BOOST_ASIO_ENABLE_BUFFER_DEBUGGING changes the layout of asio's internal
# types, so every translation unit in a binary must agree on it. libossia sets
# it on its own target in Debug; score is what links libossia together with the
# other asio users (e.g. the vendored liblsl), so score is where it has to be
# made uniform. Same condition as libossia's: on Windows boost defines it
# itself and redefining it warns.
if(NOT WIN32)
add_compile_definitions($<$<CONFIG:Debug>:BOOST_ASIO_ENABLE_BUFFER_DEBUGGING>)
endif()

include(3rdparty/3rdparty.cmake)

include_directories("${SCORE_ROOT_BINARY_DIR}")
Expand Down
41 changes: 39 additions & 2 deletions src/plugins/score-plugin-avnd/Crousti/CpuFilterNode.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#if SCORE_PLUGIN_GFX
#include <Crousti/GfxNode.hpp>

#include <ossia/detail/type_if.hpp>

#include <halp/texture.hpp>

namespace oscr
Expand Down Expand Up @@ -43,9 +45,8 @@ struct GfxRenderer<Node_T> final

GfxRenderer(const GfxNode<Node_T>& p)
: score::gfx::GenericNodeRenderer{p}
, state{std::make_shared<Node_T>()}
, state{p.rendererState()}
{
prepareNewState<Node_T>(state, p);
}

score::gfx::TextureRenderTarget
Expand Down Expand Up @@ -421,6 +422,12 @@ struct GfxRenderer<Node_T> final

buffer_outs.prepareUpload(*res);

// The object is shared by every renderer of this node: the upload
// callbacks it holds point at whichever renderer bound them last, so
// make them ours before it runs for us.
if constexpr(CpuOnlyBufferNode<Node_T>)
buffer_outs.bindUploads(*state);

// Run the processor
if_possible(state->runInitialPasses(renderer, commands, res, edge));
if_possible((*state)());
Expand Down Expand Up @@ -505,6 +512,36 @@ struct GfxNode<Node_T> final
initGfxPorts<Node_T>(this, this->input, this->output);
}

//! The object instance a renderer of this node has to use.
//!
//! A node that touches the RHI keeps state that belongs to one RenderList,
//! so it gets one object per renderer. A CPU-only buffer producer
//! (oscr::CpuOnlyBufferNode) has no renderer-side state and a CPU identity
//! that must not be duplicated: it gets a single instance, owned by the
//! node and shared by every renderer of it.
std::shared_ptr<Node_T> rendererState() const noexcept
{
if constexpr(CpuOnlyBufferNode<Node_T>)
{
auto& shared = m_shared.value;
if(!shared)
{
shared = std::make_shared<Node_T>();
prepareNewState<Node_T>(shared, *this);
}
return shared;
}
else
{
auto state = std::make_shared<Node_T>();
prepareNewState<Node_T>(state, *this);
return state;
}
}

[[no_unique_address]] mutable ossia::
type_if<std::shared_ptr<Node_T>, CpuOnlyBufferNode<Node_T>> m_shared;

score::gfx::NodeRenderer*
createRenderer(score::gfx::RenderList& r) const noexcept override
{
Expand Down
90 changes: 73 additions & 17 deletions src/plugins/score-plugin-avnd/Crousti/Executor.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,52 @@

namespace oscr
{
//! An execution node which applies its worker results at the beginning of its
//! own tick.
//!
//! avendish's worker contract is that the function work() returns is invoked
//! back in the processing thread against the object, which may write its
//! outlets from there. Execution::Context::executionQueue cannot honour that:
//! it is drained before the graph runs, and ossia::graph_util::init_node then
//! clears every outlet of the node and sets the tick's frame indices. Queuing
//! the results on the node and draining them here instead means the outlets
//! are cleared, the frame indices are those of this tick, and operator() has
//! not run yet - so whatever the result writes to a port is delivered for
//! this tick, and whatever it writes to an output field is flushed by the
//! usual finish_run() at the end of it.
template <typename Node>
struct node_with_worker : safe_node<Node>
{
using safe_node<Node>::safe_node;

//! Filled on the Qt main thread by Executor::connect_worker, drained on the
//! execution thread: same producer / consumer pair as the execution queue.
Execution::ExecutionCommandQueue worker_results;

void
run(const ossia::token_request& tk, ossia::exec_state_facade st) noexcept override
{
Execution::ExecutionCommand cmd;
if(worker_results.try_dequeue(cmd))
{
const auto [start, frames] = st.timings(tk);
this->start_frame_for_this_tick = start;
this->frame_count_for_this_tick = frames;
do
{
cmd();
} while(worker_results.try_dequeue(cmd));
}

safe_node<Node>::run(tk, st);
}
};

//! The exec node score gives an avnd object: with worker-result delivery if
//! the object has a worker, the plain avendish node otherwise.
template <typename Node>
using exec_node_t
= std::conditional_t<avnd::has_worker<Node>, node_with_worker<Node>, safe_node<Node>>;

template <typename Node>
class CustomNodeProcess : public ossia::node_process
Expand Down Expand Up @@ -166,7 +212,7 @@ class Executor final

auto st = ossia::exec_state_facade{ctx.execState.get()};
std::shared_ptr<safe_node<Node>> ptr;
auto node = new safe_node<Node>{st.bufferSize(), (double)st.sampleRate(), id};
auto node = new exec_node_t<Node>{st.bufferSize(), (double)st.sampleRate(), id};
node->root_inputs().reserve(element.inlets().size());
node->root_outputs().reserve(element.outlets().size());

Expand All @@ -184,7 +230,7 @@ class Executor final
connect_message_bus(element, ctx, ptr->impl.effect);
connect_dynamic_items(element, ptr->impl.effect);
}
connect_worker(ctx, ptr->impl);
connect_worker(ptr);

node->dynamic_ports = element.dynamic_ports;
node->finish_init();
Expand Down Expand Up @@ -806,25 +852,32 @@ class Executor final
}
}

void connect_worker(const ::Execution::Context& ctx, avnd::effect_container<Node>& eff)
void connect_worker(const std::shared_ptr<safe_node<Node>>& node_ptr)
{
if constexpr(avnd::has_worker<Node>)
{
avnd::effect_container<Node>& eff = node_ptr->impl;

// Initialize the thread pool beforehand
auto& tq = score::TaskPool::instance();
using worker_type = decltype(eff.effect.worker);
for(auto& eff : eff.effects())

// An object with a worker is always given the node which delivers the
// results at the beginning of its tick (exec_node_t).
const std::shared_ptr<node_with_worker<Node>> self{
node_ptr, static_cast<node_with_worker<Node>*>(node_ptr.get())};

for(auto& e : eff.effects())
{
std::weak_ptr eff_ptr = std::shared_ptr<Node>(this->node, &eff);
std::weak_ptr qex_ptr = std::shared_ptr<Execution::ExecutionCommandQueue>(
ctx.alias.lock(), &ctx.executionQueue);
std::weak_ptr eff_ptr = std::shared_ptr<Node>(node_ptr, &e);
std::weak_ptr node_wp = self;

eff.worker.request
= [&tq, qex_ptr = std::move(qex_ptr),
e.worker.request
= [&tq, node_wp = std::move(node_wp),
eff_ptr = std::move(eff_ptr)]<typename... Args>(Args&&... f) mutable {
// request() is invoked in the DSP / processor thread
// and just posts the task to the thread pool
tq.post([eff_ptr, qex_ptr, ... ff = std::forward<Args>(f)]() mutable {
tq.post([eff_ptr, node_wp, ... ff = std::forward<Args>(f)]() mutable {
// This happens in the worker thread
// If for some reason the object has already been removed, not much
// reason to perform the work
Expand All @@ -845,19 +898,22 @@ class Executor final
if(!res)
return;

// Execution queue is currently spsc from main thread to an exec thread,
// we cannot just yeet the result back from the thread-pool
// The node's result queue is spsc from the main thread to the
// exec thread, we cannot just yeet the result back from the
// thread-pool
ossia::qt::run_async(
qApp, [eff_ptr = std::move(eff_ptr), qex_ptr = std::move(qex_ptr),
qApp, [eff_ptr = std::move(eff_ptr), node_wp = std::move(node_wp),
res = std::move(res)]() mutable {
// Main thread
std::shared_ptr qex = qex_ptr.lock();
if(!qex)
std::shared_ptr n = node_wp.lock();
if(!n)
return;

qex->enqueue(
n->worker_results.enqueue(
[eff_ptr = std::move(eff_ptr), res = std::move(res)]() mutable {
// DSP / processor thread
// DSP / processor thread, at the beginning of the node's own
// tick: the outlets are cleared and the tick's frame indices
// are set, so the result may write to them.
// We need res to be mutable so that the worker can use it to e.g. store
// old data which will be freed back in the main thread
if(auto p = eff_ptr.lock())
Expand Down
52 changes: 45 additions & 7 deletions src/plugins/score-plugin-avnd/Crousti/GpuUtils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,10 @@ struct buffer_outputs_storage<T>

QRhiResourceUpdateBatch* currentResourceUpdateBatch{};

//! The RenderList these buffers belong to: the object may be shared by
//! several renderers, the buffers never are.
score::gfx::RenderList* m_renderer{};

template <typename Field, std::size_t N, std::size_t NField>
requires avnd::cpu_buffer<std::decay_t<decltype(Field::buffer)>>
void createOutput(
Expand All @@ -1036,21 +1040,36 @@ struct buffer_outputs_storage<T>

buf.handle->create();

m_renderer = &renderer;
bindUpload(port, np);
}

//! Point the object's upload callback at the buffers of this renderer.
//!
//! The object may be shared by every renderer of the node
//! (oscr::CpuOnlyBufferNode): the callback then has to follow whichever
//! renderer is currently running the object, so it is re-bound before each
//! run (bindUploads). It captures two pointers, which std::function stores
//! inline: re-binding allocates nothing.
template <typename Field, std::size_t N>
void bindUpload(Field& port, avnd::predicate_index<N>)
{
port.buffer.upload
= [this, &renderer, &port](const char* data, int64_t offset, int64_t bytesize) {
= [this, &port](const char* data, int64_t offset, int64_t bytesize) {
// FIXME is offset and bytesize relative to the input or the output data ?
SCORE_ASSERT(currentResourceUpdateBatch);
SCORE_ASSERT(m_renderer);
auto& rhi = *m_renderer->state.rhi;
auto& [gfx_port, buf] = m_buffers[N];

if(!buf.handle)
{
if(bytesize > 0)
{
buf.handle = renderer.state.rhi->newBuffer(
buf.handle = rhi.newBuffer(
QRhiBuffer::Static,
score::gfx::compatibleBufferUsage(
*renderer.state.rhi,
QRhiBuffer::StorageBuffer | QRhiBuffer::VertexBuffer),
rhi, QRhiBuffer::StorageBuffer | QRhiBuffer::VertexBuffer),
bytesize);
buf.handle->setName(oscr::getUtf8Name<T>() + "::" + oscr::getUtf8Name(port));
buf.byte_offset = 0;
Expand All @@ -1061,11 +1080,10 @@ struct buffer_outputs_storage<T>
}
else
{
buf.handle = renderer.state.rhi->newBuffer(
buf.handle = rhi.newBuffer(
QRhiBuffer::Static,
score::gfx::compatibleBufferUsage(
*renderer.state.rhi,
QRhiBuffer::StorageBuffer | QRhiBuffer::VertexBuffer),
rhi, QRhiBuffer::StorageBuffer | QRhiBuffer::VertexBuffer),
1);
buf.handle->setName(oscr::getUtf8Name<T>() + "::" + oscr::getUtf8Name(port));
buf.byte_offset = 0;
Expand All @@ -1089,6 +1107,22 @@ struct buffer_outputs_storage<T>
};
}

//! Re-bind the upload callbacks of a shared object to this renderer, before
//! it runs for this renderer.
void bindUploads(auto& state)
{
avnd::buffer_output_introspection<T>::for_all_n(
avnd::get_outputs<T>(state),
[this]<typename Field, std::size_t N>(Field& port, avnd::predicate_index<N> np) {
if constexpr(avnd::cpu_buffer<std::decay_t<decltype(Field::buffer)>> && requires {
port.buffer.upload(nullptr, 0, 0);
})
{
bindUpload(port, np);
}
});
}

template <typename Field, std::size_t N, std::size_t NField>
requires avnd::gpu_buffer<std::decay_t<decltype(Field::buffer)>>
void createOutput(
Expand Down Expand Up @@ -1171,6 +1205,10 @@ struct buffer_outputs_storage<T>
{
}

static void bindUploads(auto&&...)
{
}

static void upload(auto&&...)
{
}
Expand Down
39 changes: 39 additions & 0 deletions src/plugins/score-plugin-avnd/Crousti/Metadatas.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,45 @@ concept GpuComputeNode2
template <typename T>
concept is_gpu = GpuNode<T> || GpuGraphicsNode2<T> || GpuComputeNode2<T>;

//! Does the object declare any of the renderer-side entry points score calls
//! on the object it gives a renderer? Such an object holds state that belongs
//! to one RenderList and cannot be shared between them.
//!
//! Detection is by address-of on the member: an object hiding one of these
//! behind a template or an overload set reads as "no hook" here.
template <typename T>
concept has_renderer_state
= requires(T& t) { t.renderlist; } || requires { &T::init; }
|| requires { &T::update; } || requires { &T::release; }
|| requires { &T::runInitialPasses; } || requires { &T::runRenderPass; }
|| requires { &T::inputAboutToFinish; };

//! An object that feeds the gfx graph without ever touching the RHI: its only
//! gfx ports are CPU buffer outputs, which the renderers upload for it.
//!
//! Everything a renderer owns for it (the QRhiBuffer, its resource updates)
//! is renderer side, and nothing in the object is - while the object itself
//! has a CPU identity that must not be duplicated (e.g. one TCP listener per
//! object). One instance per RenderList would mean one per output window, and
//! rebuilding a renderer would restart it. It gets a single instance shared
//! by every renderer of the node instead (GfxNode::rendererState).
//!
//! This is only about the object's *instances*: such a node still lives in
//! the gfx graph (is_gpu), because a buffer output has nowhere else to go.
template <typename T>
concept CpuOnlyBufferNode
= avnd::cpu_buffer_output_introspection<T>::size > 0
&& avnd::gpu_buffer_output_introspection<T>::size == 0
&& avnd::buffer_input_introspection<T>::size == 0
&& avnd::texture_input_introspection<T>::size == 0
&& avnd::texture_output_introspection<T>::size == 0
&& avnd::geometry_input_introspection<T>::size == 0
&& avnd::geometry_output_introspection<T>::size == 0
&& scene_input_introspection<T>::size == 0
&& scene_output_introspection<T>::size == 0
&& avnd::gpu_render_target_output_port_output_introspection<T>::size == 0
&& !GpuGraphicsNode2<T> && !GpuComputeNode2<T> && !has_renderer_state<T>;

template <typename T>
concept has_ossia_layer = requires { sizeof(typename T::Layer); };
}
6 changes: 5 additions & 1 deletion src/plugins/score-plugin-js/JS/ApplicationPlugin.hpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#pragma once
#include <score/plugins/application/GUIApplicationPlugin.hpp>

#include <score_plugin_js_export.h>

#include <core/application/ApplicationSettings.hpp>

#include <QFileInfo>
Expand All @@ -20,7 +22,9 @@ using network_context_ptr = std::shared_ptr<network_context>;
class QQuickWindow;
namespace JS
{
class ApplicationPlugin final
// Exported like the other plugins' ApplicationPlugin: guiApplicationPlugin<T>()
// dynamic_casts across the plugin boundary and needs the typeinfo visible.
class SCORE_PLUGIN_JS_EXPORT ApplicationPlugin final
: public QObject
, public score::GUIApplicationPlugin
{
Expand Down
Loading
Loading