From 6c77b76bbefa6cd47bfa6f85dec94be87575d8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Tue, 22 Sep 2026 23:09:07 -0400 Subject: [PATCH 01/19] dataflow: apply an interval's gain and pan once A bus's forward_node scaled its output by pan and gain, then audio_outlet::post_process scaled the same outlet again: a bus at 0.5 played at 0.25, and a stereo pan of {0.5, 1} came out as {0.25, 1}. The node now only copies, and post_process is the one place gain and pan are applied, for every audio outlet. has_gain no longer means anything and is gone, along with the two helpers that scaled while copying. Buses set below unity in existing sessions will sound louder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/dataflow/nodes/forward_node.hpp | 26 +--- src/ossia/dataflow/port.cpp | 53 --------- src/ossia/dataflow/port.hpp | 7 -- tests/CMakeLists.txt | 1 + tests/Dataflow/IntervalGainTest.cpp | 139 ++++++++++++++++++++++ 5 files changed, 142 insertions(+), 84 deletions(-) create mode 100644 tests/Dataflow/IntervalGainTest.cpp diff --git a/src/ossia/dataflow/nodes/forward_node.hpp b/src/ossia/dataflow/nodes/forward_node.hpp index 04b77be2c46..19128e361f8 100644 --- a/src/ossia/dataflow/nodes/forward_node.hpp +++ b/src/ossia/dataflow/nodes/forward_node.hpp @@ -22,30 +22,8 @@ class forward_node : public ossia::nonowning_graph_node { // OPTIMIZEME : we copy the ins & outs of the token more // than necessary - { - ossia::audio_port& i = *audio_in; - ossia::audio_port& o = *audio_out; - if(!audio_out.has_gain) - { - o = i; - } - else - { - switch(i.channels()) - { - case 0: - return; - - case 1: - process_audio_out_mono(i, audio_out); - break; - - default: - process_audio_out_general(i, audio_out); - break; - } - } - } + // Gain and pan are applied by audio_outlet::post_process, after run(). + *audio_out = *audio_in; // { // auto i = midi_in.target(); // auto o = midi_out.target(); diff --git a/src/ossia/dataflow/port.cpp b/src/ossia/dataflow/port.cpp index a78d2366908..dd37480e7c6 100644 --- a/src/ossia/dataflow/port.cpp +++ b/src/ossia/dataflow/port.cpp @@ -296,59 +296,6 @@ midi_inlet::~midi_inlet() = default; midi_outlet::~midi_outlet() = default; -void process_audio_out_mono(ossia::audio_port& i, ossia::audio_outlet& audio_out) -{ - ossia::audio_port& o = *audio_out; - const double g = audio_out.gain; - - ensure_vector_sizes(i.get(), audio_out.data.get()); - - const auto N = i.channel(0).size(); - const auto i_ptr = i.channel(0).data(); - const auto o_ptr = o.channel(0).data(); - - for(std::size_t sample = 0; sample < N; sample++) - { - o_ptr[sample] = i_ptr[sample] * g; - } -} - -void process_audio_out_general(ossia::audio_port& i, ossia::audio_outlet& audio_out) -{ - const auto C = i.channels(); - ossia::audio_port& o = *audio_out; - const double g = audio_out.gain; - - while(audio_out.pan.size() < C) - audio_out.pan.push_back(1.); - - ensure_vector_sizes(i.get(), audio_out.data.get()); - - for(auto chan = 0U; chan < C; chan++) - { - auto N = i.channel(chan).size(); - - auto i_ptr = i.channel(chan).data(); - auto o_ptr = o.channel(chan).data(); - - const auto vol = audio_out.pan[chan] * g; - if(vol == 1.) - { - for(std::size_t sample = 0; sample < N; sample++) - { - o_ptr[sample] = i_ptr[sample]; - } - } - else - { - for(std::size_t sample = 0; sample < N; sample++) - { - o_ptr[sample] = i_ptr[sample] * vol; - } - } - } -} - void process_audio_out_mono(ossia::audio_outlet& audio_out) { ossia::audio_port& o = *audio_out; diff --git a/src/ossia/dataflow/port.hpp b/src/ossia/dataflow/port.hpp index 6207e94f5df..e99d1af1a69 100644 --- a/src/ossia/dataflow/port.hpp +++ b/src/ossia/dataflow/port.hpp @@ -251,12 +251,6 @@ void process_audio_out_mono(ossia::audio_outlet& audio_out); OSSIA_EXPORT void process_audio_out_general(ossia::audio_outlet& audio_out); -OSSIA_EXPORT -void process_audio_out_mono(ossia::audio_port& i, ossia::audio_outlet& audio_out); - -OSSIA_EXPORT -void process_audio_out_general(ossia::audio_port& i, ossia::audio_outlet& audio_out); - struct OSSIA_EXPORT audio_outlet : public ossia::outlet { audio_outlet() noexcept { init(); } @@ -300,7 +294,6 @@ struct OSSIA_EXPORT audio_outlet : public ossia::outlet ossia::value_inlet pan_inlet; ossia::audio_port data; - bool has_gain{}; private: void init() noexcept diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 98c097f0d4f..5fb6ec40116 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -115,6 +115,7 @@ if(OSSIA_DATAFLOW) ossia_add_test(ValuePortTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/ValuePortTest.cpp") ossia_add_test(PortRegistrationTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/PortRegistrationTest.cpp") ossia_add_test(OutletUnitTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/OutletUnitTest.cpp") + ossia_add_test(IntervalGainTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/IntervalGainTest.cpp") ossia_add_test(MidiNodeTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/MidiNodeTest.cpp") ossia_add_test(DataflowTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/DataflowTest.cpp") ossia_add_test(TickMethodTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/TickMethodTest.cpp") diff --git a/tests/Dataflow/IntervalGainTest.cpp b/tests/Dataflow/IntervalGainTest.cpp new file mode 100644 index 00000000000..288e75279cd --- /dev/null +++ b/tests/Dataflow/IntervalGainTest.cpp @@ -0,0 +1,139 @@ +// The gain and pan of an interval's audio outlet scale what its parent receives, +// once. + +#include + +#include +#include +#include +#include + +#include +#include + +#include + +namespace +{ +constexpr int frames = 16; + +class constant_source final : public ossia::nonowning_graph_node +{ +public: + explicit constant_source(std::vector levels) + : m_levels{std::move(levels)} + { + m_outlets.push_back(&audio_out); + } + + std::string label() const noexcept override { return "constant_source"; } + + void run(const ossia::token_request&, ossia::exec_state_facade) noexcept override + { + auto& o = *audio_out; + o.set_channels(m_levels.size()); + for(std::size_t c = 0; c < m_levels.size(); c++) + o.channel(c).assign(frames, m_levels[c]); + } + + ossia::audio_outlet audio_out; + +private: + std::vector m_levels; +}; + +// source -> interval -> parent, as score builds a process in an interval whose +// outlet propagates to the enclosing scenario. +struct Chain +{ + std::shared_ptr g + = ossia::make_graph(ossia::graph_setup_options{}); + ossia::execution_state e; + std::shared_ptr source; + std::shared_ptr interval + = std::make_shared(); + std::shared_ptr parent + = std::make_shared(); + + explicit Chain(std::vector levels) + : source{std::make_shared(std::move(levels))} + { + g->add_node(source); + g->add_node(interval); + g->add_node(parent); + g->connect(ossia::make_glutton_edge(*g, 0, 0, source, interval)); + g->connect(ossia::make_glutton_edge(*g, 0, 0, interval, parent)); + } + + const ossia::audio_vector& tick() + { + const ossia::simple_token_request tk{ + ossia::time_value{0}, ossia::time_value{frames}}; + for(auto& n : + {ossia::node_ptr{source}, ossia::node_ptr{interval}, ossia::node_ptr{parent}}) + n->request(tk); + + e.bufferSize = frames; + e.begin_tick(); + g->state(e); + e.commit(); + return parent->audio_out.data.get(); + } +}; + +void check_levels(const ossia::audio_vector& out, const std::vector& expected) +{ + REQUIRE(out.size() == expected.size()); + for(std::size_t c = 0; c < expected.size(); c++) + { + REQUIRE(out[c].size() == std::size_t(frames)); + for(double s : out[c]) + CHECK(s == Catch::Approx(expected[c])); + } +} +} + +TEST_CASE("An interval's gain reaches its parent once", "[dataflow][interval][gain]") +{ + SECTION("mono") + { + Chain c{{1.}}; + c.interval->audio_out.gain = 0.5; + check_levels(c.tick(), {0.5}); + } + + SECTION("stereo") + { + Chain c{{1., 1.}}; + c.interval->audio_out.gain = 0.5; + check_levels(c.tick(), {0.5, 0.5}); + } + + SECTION("over several ticks") + { + Chain c{{1., 1.}}; + c.interval->audio_out.gain = 0.5; + c.tick(); + check_levels(c.tick(), {0.5, 0.5}); + } +} + +TEST_CASE("An interval's pan reaches its parent once", "[dataflow][interval][pan]") +{ + Chain c{{1., 1.}}; + c.interval->audio_out.pan = ossia::pan_weight{0.5, 1.}; + check_levels(c.tick(), {0.5, 1.}); +} + +TEST_CASE("An automated gain applies in the tick it arrives", "[dataflow][interval][gain]") +{ + Chain c{{1., 1.}}; + c.interval->audio_out.gain_inlet.data.write_value(0.25f, 0); + check_levels(c.tick(), {0.25, 0.25}); +} + +TEST_CASE("A unity interval passes its input through", "[dataflow][interval][gain]") +{ + Chain c{{0.25, -0.75}}; + check_levels(c.tick(), {0.25, -0.75}); +} From 5fc7c799fba41a15409d3f2c407cceea88231163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Tue, 22 Sep 2026 23:09:07 -0400 Subject: [PATCH 02/19] audio: apply each device gain exactly once, with /out/main as the master The gain of /out/main scaled only what was written to /out/main, one writer at a time. It is now a post-sum trim: audio_protocol::apply_main_gain scales every hardware output once the tick has been summed into them, so signals sent straight to /out/N follow the master too. A change ramps over the buffer. Each audio_parameter now says where its gain applies: on what the graph writes (outputs, virtual ports), on what it reads (inputs, whose gain was ignored until now), or outside, after the sum (/out/main). The gain itself is an atomic float, since the UI writes it while the audio thread reads it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- examples/Dataflow/MiniDAW.cpp | 1 + src/ossia/audio/audio_parameter.cpp | 19 ++--- src/ossia/audio/audio_parameter.hpp | 20 ++++- src/ossia/audio/audio_protocol.cpp | 37 +++++++++ src/ossia/audio/audio_protocol.hpp | 8 ++ tests/CMakeLists.txt | 1 + tests/Dataflow/MainGainTest.cpp | 117 ++++++++++++++++++++++++++++ 7 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 tests/Dataflow/MainGainTest.cpp diff --git a/examples/Dataflow/MiniDAW.cpp b/examples/Dataflow/MiniDAW.cpp index f6e575fff92..f55fd40936d 100644 --- a/examples/Dataflow/MiniDAW.cpp +++ b/examples/Dataflow/MiniDAW.cpp @@ -28,6 +28,7 @@ struct tick { proto.setup_buffers(st); (*this)(st.frames, st.seconds); + proto.apply_main_gain(st); } void operator()(unsigned long frameCount, double seconds) diff --git a/src/ossia/audio/audio_parameter.cpp b/src/ossia/audio/audio_parameter.cpp index 9d3f7222c6f..388f75f64fa 100644 --- a/src/ossia/audio/audio_parameter.cpp +++ b/src/ossia/audio/audio_parameter.cpp @@ -31,6 +31,7 @@ void audio_parameter::clone_value(audio_vector& res_vec) const } } + const double g = stage == gain_stage::pull ? gain() : 1.; auto min_chan = std::min(res_vec.size(), audio.size()); for(std::size_t chan = 0; chan < min_chan; chan++) { @@ -43,12 +44,13 @@ void audio_parameter::clone_value(audio_vector& res_vec) const res.resize(N); for(std::size_t i = 0; i < N; i++) - res[i] += double(src[i]); + res[i] += double(src[i]) * g; } } void audio_parameter::push_value(const audio_port& port) { + const double g = stage == gain_stage::push ? gain() : 1.; auto min_chan = std::min(port.channels(), (std::size_t)audio.size()); for(std::size_t chan = 0; chan < min_chan; chan++) { @@ -57,7 +59,7 @@ void audio_parameter::push_value(const audio_port& port) const auto N = std::min(src.size(), (std::size_t)dst.size()); for(std::size_t i = 0; i < N; i++) { - dst[i] += float(src[i] * m_gain); + dst[i] += float(src[i] * g); } } } @@ -83,19 +85,16 @@ net::parameter_base& audio_parameter::push_value() value audio_parameter::value() const { - return m_gain; + return gain(); } ossia::value audio_parameter::set_value(const ossia::value& v) { auto flt = ossia::convert(v); auto vol = ossia::clamp(flt, 0.f, 1.f); - if(m_gain != vol) - { - m_gain = vol; + if(m_gain.exchange(vol, std::memory_order_relaxed) != vol) send(vol); - } - return m_gain; + return vol; } ossia::value audio_parameter::set_value(ossia::value&& v) @@ -155,6 +154,7 @@ virtual_audio_parameter::virtual_audio_parameter(int num_channels, net::node_bas void virtual_audio_parameter::push_value(const audio_port& port) { + const double g = gain(); auto min_chan = std::min(port.channels(), (std::size_t)audio.size()); for(std::size_t chan = 0; chan < min_chan; chan++) { @@ -164,7 +164,7 @@ void virtual_audio_parameter::push_value(const audio_port& port) for(std::size_t i = 0; i < N; i++) { // Important: here we must not mix - dst[i] = float(src[i] * m_gain); + dst[i] = float(src[i] * g); } } } @@ -182,6 +182,7 @@ mapped_audio_parameter::mapped_audio_parameter( , mapping(std::move(m)) , is_output{output} { + stage = output ? gain_stage::push : gain_stage::pull; auto& proto = static_cast(n.get_device().get_protocol()); proto.register_parameter(*this); } diff --git a/src/ossia/audio/audio_parameter.hpp b/src/ossia/audio/audio_parameter.hpp index da338f6d1f7..06fe08ee809 100644 --- a/src/ossia/audio/audio_parameter.hpp +++ b/src/ossia/audio/audio_parameter.hpp @@ -7,6 +7,8 @@ #include #include +#include + namespace ossia { class OSSIA_EXPORT audio_parameter : public ossia::net::parameter_base @@ -14,10 +16,23 @@ class OSSIA_EXPORT audio_parameter : public ossia::net::parameter_base public: ossia::small_vector, 8> audio; - double m_gain{1.}; + + //! Where gain() is applied, so that it is applied exactly once. + enum class gain_stage : uint8_t + { + push, //!< on what the graph writes (outputs, virtual ports) + pull, //!< on what the graph reads (inputs) + external, //!< by whoever owns the buffers, after the sum (/out/main) + } stage{gain_stage::push}; explicit audio_parameter(ossia::net::node_base& n); + //! Linear gain, readable from any thread. + [[nodiscard]] float gain() const noexcept + { + return m_gain.load(std::memory_order_relaxed); + } + virtual ~audio_parameter(); void clone_value(audio_vector& res) const; @@ -38,6 +53,9 @@ class OSSIA_EXPORT audio_parameter : public ossia::net::parameter_base net::parameter_base& set_domain(const domain&) override; bounding_mode get_bounding() const noexcept override; net::parameter_base& set_bounding(bounding_mode) override; + +protected: + std::atomic m_gain{1.f}; }; class OSSIA_EXPORT virtual_audio_parameter final : public audio_parameter diff --git a/src/ossia/audio/audio_protocol.cpp b/src/ossia/audio/audio_protocol.cpp index 651a7fcbbb7..ce06e26f7ba 100644 --- a/src/ossia/audio/audio_protocol.cpp +++ b/src/ossia/audio/audio_protocol.cpp @@ -53,10 +53,13 @@ void audio_protocol::setup_tree(int inputs, int outputs) = ossia::net::find_parameter_or_create_node(root, "/in/main"); main_audio_out = ossia::net::find_parameter_or_create_node(root, "/out/main"); + main_audio_in->stage = audio_parameter::gain_stage::pull; + main_audio_out->stage = audio_parameter::gain_stage::external; for(int i = 0; i < inputs; i++) { audio_ins.push_back(ossia::net::find_parameter_or_create_node( root, "/in/" + std::to_string(i + 1))); + audio_ins.back()->stage = audio_parameter::gain_stage::pull; } for(int i = 0; i < outputs; i++) { @@ -77,6 +80,40 @@ void audio_protocol::setup_tree(int inputs, int outputs) } } +void audio_protocol::apply_main_gain(const audio_tick_state& state) noexcept +{ + const float start = m_main_gain; + const float target = main_audio_out ? main_audio_out->gain() : 1.f; + m_main_gain = target; + + const auto frames = state.frames; + if(frames == 0) + return; + + if(start == target) + { + if(target == 1.f) + return; + + for(int c = 0; c < state.n_out; c++) + { + float* out = state.outputs[c]; + for(std::size_t i = 0; i < frames; i++) + out[i] *= target; + } + } + else + { + const float step = (target - start) / float(frames); + for(int c = 0; c < state.n_out; c++) + { + float* out = state.outputs[c]; + for(std::size_t i = 0; i < frames; i++) + out[i] *= start + step * float(i + 1); + } + } +} + void audio_protocol::advance_tick(std::size_t count) { if(main_audio_in) diff --git a/src/ossia/audio/audio_protocol.hpp b/src/ossia/audio/audio_protocol.hpp index 9b907c3b14e..d16c843517b 100644 --- a/src/ossia/audio/audio_protocol.hpp +++ b/src/ossia/audio/audio_protocol.hpp @@ -22,6 +22,11 @@ class OSSIA_EXPORT audio_protocol final : public ossia::net::protocol_base // Drops every span pointing into the driver's buffers. void clear_buffers() noexcept; + // Scales every hardware output by the gain of /out/main, once the whole + // tick has been summed into them: /out/main is the master, whichever address + // a signal was sent to. A gain change ramps over the buffer. + void apply_main_gain(const audio_tick_state& state) noexcept; + bool pull(ossia::net::parameter_base&) override; bool push(const ossia::net::parameter_base&, const ossia::value& v) override; bool push_bundle(const std::vector&) override; @@ -49,6 +54,9 @@ class OSSIA_EXPORT audio_protocol final : public ossia::net::protocol_base protected: ossia::net::device_base* m_dev{}; + +private: + float m_main_gain{1.f}; }; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5fb6ec40116..498ae7b835e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -116,6 +116,7 @@ if(OSSIA_DATAFLOW) ossia_add_test(PortRegistrationTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/PortRegistrationTest.cpp") ossia_add_test(OutletUnitTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/OutletUnitTest.cpp") ossia_add_test(IntervalGainTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/IntervalGainTest.cpp") + ossia_add_test(MainGainTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/MainGainTest.cpp") ossia_add_test(MidiNodeTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/MidiNodeTest.cpp") ossia_add_test(DataflowTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/DataflowTest.cpp") ossia_add_test(TickMethodTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/TickMethodTest.cpp") diff --git a/tests/Dataflow/MainGainTest.cpp b/tests/Dataflow/MainGainTest.cpp new file mode 100644 index 00000000000..227513e04b6 --- /dev/null +++ b/tests/Dataflow/MainGainTest.cpp @@ -0,0 +1,117 @@ +// The gain of /out/main scales every hardware output once, after the tick has +// summed everything into them. + +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include + +namespace +{ +constexpr int frames = 8; + +struct Card +{ + ossia::audio_protocol* proto{}; + ossia::net::generic_device dev; + + std::array, 2> out_buffers{}; + std::array outputs{out_buffers[0].data(), out_buffers[1].data()}; + + Card() + : dev{[this] { + auto p = std::make_unique(); + proto = p.get(); + return p; + }(), "audio"} + { + proto->setup_tree(0, 2); + } + + ossia::audio_tick_state state() + { + return {.inputs = nullptr, .outputs = outputs.data(), .n_in = 0, .n_out = 2, + .frames = frames}; + } + + static ossia::audio_port constant(std::size_t channels, double v) + { + ossia::audio_port p; + p.set_channels(channels); + for(std::size_t c = 0; c < channels; c++) + p.channel(c).assign(frames, v); + return p; + } + + // One audio callback: silence, the graph's writes, then the main gain. + void tick(const std::function& writes) + { + auto st = state(); + proto->setup_buffers(st); + writes(); + proto->apply_main_gain(st); + } +}; +} + +TEST_CASE("/out/main scales the root's output once", "[audio][main][gain]") +{ + Card c; + c.proto->main_audio_out->set_value(0.5f); + + // First callback ramps from unity; the second one is steady. + auto write = [&] { c.proto->main_audio_out->push_value(Card::constant(2, 1.)); }; + c.tick(write); + CHECK(c.out_buffers[0][frames - 1] == Catch::Approx(0.5)); + + c.tick(write); + for(auto& chan : c.out_buffers) + for(float s : chan) + CHECK(s == Catch::Approx(0.5)); +} + +TEST_CASE("/out/main scales what is sent straight to /out/N", "[audio][main][gain]") +{ + Card c; + c.proto->main_audio_out->set_value(0.5f); + c.proto->audio_outs[1]->set_value(0.5f); + + auto write = [&] { + c.proto->main_audio_out->push_value(Card::constant(2, 1.)); + c.proto->audio_outs[1]->push_value(Card::constant(1, 1.)); + }; + c.tick(write); + c.tick(write); + + // left: 1 * main; right: (1 + 1 * 0.5) * main + for(float s : c.out_buffers[0]) + CHECK(s == Catch::Approx(0.5)); + for(float s : c.out_buffers[1]) + CHECK(s == Catch::Approx(0.75)); +} + +TEST_CASE("A main gain change ramps over one buffer", "[audio][main][gain]") +{ + Card c; + auto write = [&] { c.proto->main_audio_out->push_value(Card::constant(1, 1.)); }; + c.tick(write); + CHECK(c.out_buffers[0][0] == Catch::Approx(1.)); + + c.proto->main_audio_out->set_value(0.f); + c.tick(write); + for(int i = 1; i < frames; i++) + CHECK(c.out_buffers[0][i] < c.out_buffers[0][i - 1]); + CHECK(c.out_buffers[0][frames - 1] == Catch::Approx(0.)); + + c.tick(write); + for(float s : c.out_buffers[0]) + CHECK(s == Catch::Approx(0.)); +} From f0f524375b1828b2d2d41606b5cdff6e0bb76f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Tue, 22 Sep 2026 23:41:32 -0400 Subject: [PATCH 03/19] dataflow: ramp an outlet's gain and pan changes, and never grow pan A gain or pan change now ramps linearly over one buffer instead of stepping, which clicked. The first buffer an outlet carries is scaled directly: there is nothing to ramp from. Pan ramps on the first 16 channels; past those, a change steps. Channels past the end of the pan weights have a weight of 1. They used to get one pushed onto the vector on the audio thread, which allocated as soon as a signal had more than two channels. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/dataflow/port.cpp | 88 ++++++++++++----------------- src/ossia/dataflow/port.hpp | 22 ++++++-- tests/Dataflow/IntervalGainTest.cpp | 38 +++++++++++++ 3 files changed, 91 insertions(+), 57 deletions(-) diff --git a/src/ossia/dataflow/port.cpp b/src/ossia/dataflow/port.cpp index dd37480e7c6..ad0b6f89c61 100644 --- a/src/ossia/dataflow/port.cpp +++ b/src/ossia/dataflow/port.cpp @@ -276,66 +276,52 @@ void audio_outlet::post_process() if(auto& gain_msg = std::as_const(gain_inlet).data.get_data(); !gain_msg.empty()) gain = ossia::convert(gain_msg.back().value); - // TODO pan - switch(data.channels()) - { - case 0: - return; - - case 1: - process_audio_out_mono(*this); - break; - - default: - process_audio_out_general(*this); - break; - } -} - -midi_inlet::~midi_inlet() = default; - -midi_outlet::~midi_outlet() = default; - -void process_audio_out_mono(ossia::audio_outlet& audio_out) -{ - ossia::audio_port& o = *audio_out; - - const double g = audio_out.gain; - if(g == 1.) + // TODO pan inlet + const std::size_t C = data.channels(); + if(C == 0) return; - const auto N = o.channel(0).size(); - const auto o_ptr = o.channel(0).data(); + const bool ramp = m_applied; + const double to_gain = gain; + const double from_gain = ramp ? m_applied_gain : to_gain; + m_applied = true; + m_applied_gain = to_gain; - for(std::size_t sample = 0; sample < N; sample++) + for(std::size_t c = 0; c < C; c++) { - o_ptr[sample] *= g; - } -} - -void process_audio_out_general(ossia::audio_outlet& audio_out) -{ - ossia::audio_port& o = *audio_out; - const auto C = o.channels(); - const double g = audio_out.gain; - - while(audio_out.pan.size() < C) - audio_out.pan.push_back(1.); - - for(auto chan = 0U; chan < C; chan++) - { - auto N = o.channel(chan).size(); + const double to_pan = (C == 1 || c >= pan.size()) ? 1. : pan[c]; + double from_pan = to_pan; + if(c < ramped_pan_channels) + { + if(ramp) + from_pan = m_applied_pan[c]; + m_applied_pan[c] = float(to_pan); + } - auto o_ptr = o.channel(chan).data(); + const double from = from_gain * from_pan; + const double to = to_gain * to_pan; - const auto vol = audio_out.pan[chan] * g; - if(vol == 1.) - continue; - for(std::size_t sample = 0; sample < N; sample++) + auto& chan = data.channel(c); + const std::size_t N = chan.size(); + double* samples = chan.data(); + if(from == to) { - o_ptr[sample] *= vol; + if(to == 1.) + continue; + for(std::size_t i = 0; i < N; i++) + samples[i] *= to; + } + else if(N > 0) + { + const double step = (to - from) / double(N); + for(std::size_t i = 0; i < N; i++) + samples[i] *= from + step * double(i + 1); } } } +midi_inlet::~midi_inlet() = default; + +midi_outlet::~midi_outlet() = default; + } diff --git a/src/ossia/dataflow/port.hpp b/src/ossia/dataflow/port.hpp index e99d1af1a69..d537c79e47c 100644 --- a/src/ossia/dataflow/port.hpp +++ b/src/ossia/dataflow/port.hpp @@ -8,6 +8,8 @@ #include #include +#include + namespace ossia { struct OSSIA_EXPORT port @@ -245,12 +247,6 @@ struct OSSIA_EXPORT value_inlet : public ossia::inlet struct audio_outlet; -OSSIA_EXPORT -void process_audio_out_mono(ossia::audio_outlet& audio_out); - -OSSIA_EXPORT -void process_audio_out_general(ossia::audio_outlet& audio_out); - struct OSSIA_EXPORT audio_outlet : public ossia::outlet { audio_outlet() noexcept { init(); } @@ -287,6 +283,8 @@ struct OSSIA_EXPORT audio_outlet : public ossia::outlet void post_process() override; + //! Linear gain, and per-channel weights: channels past the end of pan + //! have a weight of 1. A mono signal ignores pan. double gain{1.}; pan_weight pan{1., 1.}; @@ -296,6 +294,18 @@ struct OSSIA_EXPORT audio_outlet : public ossia::outlet ossia::audio_port data; private: + // What post_process last applied, so that a change ramps over one buffer + // instead of clicking. Pan ramps on the first channels only. Nothing ramps + // before the first buffer carrying audio. + static constexpr std::size_t ramped_pan_channels = 16; + bool m_applied{}; + double m_applied_gain{1.}; + std::array m_applied_pan = [] { + std::array a; + a.fill(1.f); + return a; + }(); + void init() noexcept { this->child_inlets.resize(2); diff --git a/tests/Dataflow/IntervalGainTest.cpp b/tests/Dataflow/IntervalGainTest.cpp index 288e75279cd..a962870f043 100644 --- a/tests/Dataflow/IntervalGainTest.cpp +++ b/tests/Dataflow/IntervalGainTest.cpp @@ -137,3 +137,41 @@ TEST_CASE("A unity interval passes its input through", "[dataflow][interval][gai Chain c{{0.25, -0.75}}; check_levels(c.tick(), {0.25, -0.75}); } + +TEST_CASE("A gain change ramps over one buffer", "[dataflow][interval][gain]") +{ + Chain c{{1.}}; + check_levels(c.tick(), {1.}); + + c.interval->audio_out.gain = 0.; + const auto& out = c.tick(); + REQUIRE(out.size() == 1); + for(int i = 1; i < frames; i++) + CHECK(out[0][i] < out[0][i - 1]); + CHECK(out[0][frames - 1] == Catch::Approx(0.)); + + check_levels(c.tick(), {0.}); +} + +TEST_CASE("A pan change ramps over one buffer", "[dataflow][interval][pan]") +{ + Chain c{{1., 1.}}; + c.tick(); + + c.interval->audio_out.pan = ossia::pan_weight{0., 1.}; + const auto& out = c.tick(); + REQUIRE(out.size() == 2); + CHECK(out[0][0] > 0.); + CHECK(out[0][frames - 1] == Catch::Approx(0.)); + for(double s : out[1]) + CHECK(s == Catch::Approx(1.)); +} + +TEST_CASE("Channels past the pan weights keep unity", "[dataflow][interval][pan]") +{ + Chain c{{1., 1., 1., 1.}}; + c.interval->audio_out.pan = ossia::pan_weight{0.5, 0.5}; + c.tick(); + check_levels(c.tick(), {0.5, 0.5, 1., 1.}); + CHECK(c.interval->audio_out.pan.size() == 2); +} From b5763c0561fccb0f896d9f0806cac09e788d0175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Tue, 22 Sep 2026 23:51:04 -0400 Subject: [PATCH 04/19] dataflow: a telemetry arena for levels going to the interface ossia::telemetry::arena gathers informative data from the execution into one frame at fixed slots, handed to the interface through a triple buffer. Every metering point accumulates peak, sum of squares and clips on its own writer thread; the tick publishes once per period. A frame the interface has not read yet is folded into the next one, so no peak is lost whatever the reading rate, and nothing is allocated or locked on the audio thread. An audio outlet can carry a tap, fed after its gain and pan. The hardware inputs and outputs have taps of their own, fed from the driver's buffers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/dataflow/port.cpp | 8 ++ src/ossia/dataflow/port.hpp | 9 ++ src/ossia/dataflow/telemetry.cpp | 196 +++++++++++++++++++++++++++++ src/ossia/dataflow/telemetry.hpp | 148 ++++++++++++++++++++++ src/ossia_sources.cmake | 2 + tests/CMakeLists.txt | 1 + tests/Dataflow/TelemetryTest.cpp | 208 +++++++++++++++++++++++++++++++ 7 files changed, 572 insertions(+) create mode 100644 src/ossia/dataflow/telemetry.cpp create mode 100644 src/ossia/dataflow/telemetry.hpp create mode 100644 tests/Dataflow/TelemetryTest.cpp diff --git a/src/ossia/dataflow/port.cpp b/src/ossia/dataflow/port.cpp index ad0b6f89c61..abdd8351a42 100644 --- a/src/ossia/dataflow/port.cpp +++ b/src/ossia/dataflow/port.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -279,7 +280,11 @@ void audio_outlet::post_process() // TODO pan inlet const std::size_t C = data.channels(); if(C == 0) + { + if(meter) + meter->pending.accumulate(data.get()); return; + } const bool ramp = m_applied; const double to_gain = gain; @@ -318,6 +323,9 @@ void audio_outlet::post_process() samples[i] *= from + step * double(i + 1); } } + + if(meter) + meter->pending.accumulate(data.get()); } midi_inlet::~midi_inlet() = default; diff --git a/src/ossia/dataflow/port.hpp b/src/ossia/dataflow/port.hpp index d537c79e47c..32290487a32 100644 --- a/src/ossia/dataflow/port.hpp +++ b/src/ossia/dataflow/port.hpp @@ -9,6 +9,7 @@ #include #include +#include namespace ossia { @@ -246,6 +247,10 @@ struct OSSIA_EXPORT value_inlet : public ossia::inlet }; struct audio_outlet; +namespace telemetry +{ +struct meter_tap; +} struct OSSIA_EXPORT audio_outlet : public ossia::outlet { @@ -293,6 +298,10 @@ struct OSSIA_EXPORT audio_outlet : public ossia::outlet ossia::audio_port data; + //! When set, post_process feeds it what the outlet carries after gain and + //! pan. Swap it in and out from the audio thread only. + std::shared_ptr meter; + private: // What post_process last applied, so that a change ramps over one buffer // instead of clicking. Pan ramps on the first channels only. Nothing ramps diff --git a/src/ossia/dataflow/telemetry.cpp b/src/ossia/dataflow/telemetry.cpp new file mode 100644 index 00000000000..67c9a07273e --- /dev/null +++ b/src/ossia/dataflow/telemetry.cpp @@ -0,0 +1,196 @@ +#include + +#include +#include + +namespace ossia::telemetry +{ +void meter_levels::clear() noexcept +{ + const std::size_t n = std::min(channels, max_meter_channels); + std::fill_n(peak.begin(), n, 0.f); + std::fill_n(sum_squares.begin(), n, 0.f); + clipped.fill(0); + channels = 0; + ticks = 0; + frames = 0; +} + +void meter_levels::merge(const meter_levels& other) noexcept +{ + const std::size_t n = std::min( + std::max(channels, other.channels), max_meter_channels); + for(std::size_t c = channels; c < n; c++) + { + peak[c] = 0.f; + sum_squares[c] = 0.f; + } + for(std::size_t c = 0; c < std::min(other.channels, n); c++) + { + peak[c] = std::max(peak[c], other.peak[c]); + sum_squares[c] += other.sum_squares[c]; + } + for(std::size_t i = 0; i < clipped.size(); i++) + clipped[i] |= other.clipped[i]; + channels = n; + ticks += other.ticks; + frames += other.frames; +} + +namespace +{ +template +void accumulate_channel( + meter_levels& m, std::size_t c, const Sample* samples, std::size_t n) noexcept +{ + float p = m.peak[c]; + float s = 0.f; + bool clip = false; + for(std::size_t i = 0; i < n; i++) + { + const float x = std::abs(float(samples[i])); + p = std::max(p, x); + s += x * x; + clip |= x >= 1.f; + } + m.peak[c] = p; + m.sum_squares[c] += s; + if(clip) + m.clipped[c / 32] |= 1u << (c % 32); +} + +// A channel that appears mid-way starts from silence. +void grow_to(meter_levels& m, std::size_t chans) noexcept +{ + chans = std::min(chans, max_meter_channels); + for(std::size_t c = m.channels; c < chans; c++) + { + m.peak[c] = 0.f; + m.sum_squares[c] = 0.f; + } + m.channels = std::max(m.channels, chans); +} +} + +void meter_levels::accumulate(const ossia::audio_vector& chans) noexcept +{ + grow_to(*this, chans.size()); + std::size_t n = 0; + for(std::size_t c = 0; c < channels && c < chans.size(); c++) + { + accumulate_channel(*this, c, chans[c].data(), chans[c].size()); + n = std::max(n, chans[c].size()); + } + ticks++; + frames += n; +} + +void meter_levels::accumulate( + const float* const* chans, int count, std::size_t n) noexcept +{ + grow_to(*this, std::max(count, 0)); + for(std::size_t c = 0; c < channels && c < std::size_t(count); c++) + accumulate_channel(*this, c, chans[c], n); + ticks++; + frames += n; +} + +float meter_levels::rms(std::size_t chan) const noexcept +{ + if(chan >= channels || frames == 0) + return 0.f; + return std::sqrt(sum_squares[chan] / float(frames)); +} + +namespace +{ +frame make_frame(std::size_t capacity) +{ + frame f; + f.meters.resize(capacity); + return f; +} +} + +arena::arena(std::size_t meter_capacity) + : m_sources(meter_capacity) + , m_unread(meter_capacity) + , m_staging{make_frame(meter_capacity)} + , m_buffer{make_frame(meter_capacity)} + , m_read{make_frame(meter_capacity)} +{ +} + +arena::~arena() = default; + +void arena::attach( + std::size_t index, uint32_t generation, tap_kind kind, + std::shared_ptr& tap) noexcept +{ + if(index >= m_sources.size()) + return; + + auto& src = m_sources[index]; + std::swap(src.tap, tap); + src.generation = generation; + src.kind = src.tap ? kind : tap_kind::none; + m_unread[index].generation = generation; + m_unread[index].levels.clear(); +} + +void arena::accumulate_hardware(const ossia::audio_tick_state& st) noexcept +{ + for(auto& src : m_sources) + { + switch(src.kind) + { + case tap_kind::hardware_inputs: + src.tap->pending.accumulate(st.inputs, st.n_in, st.frames); + break; + case tap_kind::hardware_outputs: + src.tap->pending.accumulate(st.outputs, st.n_out, st.frames); + break; + default: + break; + } + } +} + +void arena::tick(std::size_t frames, int sample_rate) noexcept +{ + m_frames += frames; + m_frames_since_publish += frames; + m_sample_rate = sample_rate; + if(m_frames_since_publish >= m_publish_interval.load(std::memory_order_relaxed)) + publish(); +} + +void arena::publish() noexcept +{ + m_frames_since_publish = 0; + + // A frame still waiting in the buffer has not been read: fold what came + // since into it rather than replace it. + const bool unread = m_buffer.has_new_data(); + + for(std::size_t i = 0; i < m_sources.size(); i++) + { + auto& src = m_sources[i]; + auto& acc = m_unread[i].levels; + if(!unread) + acc.clear(); + if(src.tap) + { + acc.merge(src.tap->pending); + src.tap->pending.clear(); + } + m_staging.meters[i].generation = m_unread[i].generation; + m_staging.meters[i].levels = acc; + } + + m_staging.publish_seq = ++m_seq; + m_staging.frames = m_frames; + m_staging.sample_rate = m_sample_rate; + m_buffer.produce(m_staging); +} +} diff --git a/src/ossia/dataflow/telemetry.hpp b/src/ossia/dataflow/telemetry.hpp new file mode 100644 index 00000000000..38aba7773b6 --- /dev/null +++ b/src/ossia/dataflow/telemetry.hpp @@ -0,0 +1,148 @@ +#pragma once +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +/** + * Informative data going from the execution back to the user interface. + * + * The audio thread never allocates, never locks and never loses a peak: + * - every metering point (a tap) accumulates on its own writer thread; + * - once per publication period, the tick thread gathers all taps into one + * frame at a fixed slot per tap and hands it over through a triple buffer; + * - levels the interface has not read yet are merged into the next frame + * instead of being dropped, so the interface can read at any rate. + * + * Slots are allocated by the interface, which pairs each index with a + * generation: a reader only trusts a slot whose generation it expects. + */ +namespace ossia::telemetry +{ +inline constexpr std::size_t max_meter_channels = 128; + +//! Levels accumulated over a span of frames. +struct OSSIA_EXPORT meter_levels +{ + //! Widest signal seen. 0 with ticks > 0: the tap ran and carried nothing. + uint32_t channels{}; + //! How many times the tap was fed. 0: it did not run. + uint32_t ticks{}; + uint64_t frames{}; + std::array peak{}; + std::array sum_squares{}; + std::array clipped{}; + + void clear() noexcept; + void merge(const meter_levels& other) noexcept; + void accumulate(const ossia::audio_vector& chans) noexcept; + void accumulate(const float* const* chans, int count, std::size_t frames) noexcept; + + [[nodiscard]] bool is_clipped(std::size_t chan) const noexcept + { + return chan < max_meter_channels && (clipped[chan / 32] >> (chan % 32)) & 1u; + } + [[nodiscard]] float rms(std::size_t chan) const noexcept; +}; + +//! One metering point. Only its writer thread touches it while it is attached. +struct meter_tap +{ + meter_levels pending; +}; + +enum class tap_kind : uint8_t +{ + none, + node, //!< fed by the graph, e.g. an audio outlet's post_process + hardware_inputs, //!< fed by the tick from the driver's inputs + hardware_outputs, //!< fed by the tick from the driver's outputs, after the mix +}; + +struct meter_slot +{ + uint32_t generation{}; + meter_levels levels; +}; + +struct frame +{ + uint64_t publish_seq{}; + //! Frames executed since the arena started. + uint64_t frames{}; + int32_t sample_rate{}; + std::vector meters; +}; + +class OSSIA_EXPORT arena +{ +public: + //! Allocates everything the audio thread will ever touch. + explicit arena(std::size_t meter_capacity); + ~arena(); + + arena(const arena&) = delete; + arena& operator=(const arena&) = delete; + + [[nodiscard]] std::size_t meter_capacity() const noexcept { return m_sources.size(); } + + // Audio thread // + + //! Swaps the tap in: the previous one comes back in `tap`, so that it is + //! released wherever the caller's closure is destroyed. + void attach( + std::size_t index, uint32_t generation, tap_kind kind, + std::shared_ptr& tap) noexcept; + + //! Feeds the hardware taps from the driver's buffers. Call after the mix. + void accumulate_hardware(const ossia::audio_tick_state& st) noexcept; + + //! Counts the frames of this tick and publishes once a period has elapsed. + void tick(std::size_t frames, int sample_rate) noexcept; + + // Any thread // + + //! Frames between two publications, e.g. the UI refresh period. + void set_publish_interval(uint64_t frames) noexcept + { + m_publish_interval.store(frames, std::memory_order_relaxed); + } + + // UI thread // + + //! Takes the latest frame, if one was published since the last call. + bool consume() noexcept { return m_buffer.consume(m_read); } + [[nodiscard]] const frame& latest() const noexcept { return m_read; } + +private: + void publish() noexcept; + + struct source + { + std::shared_ptr tap; + uint32_t generation{}; + tap_kind kind{}; + }; + + // Audio thread only. + std::vector m_sources; + std::vector m_unread; + frame m_staging; + uint64_t m_frames{}; + uint64_t m_frames_since_publish{}; + uint64_t m_seq{}; + int32_t m_sample_rate{}; + + std::atomic m_publish_interval{4096}; + ossia::triple_buffer m_buffer; + + // UI thread only. + frame m_read; +}; +} diff --git a/src/ossia_sources.cmake b/src/ossia_sources.cmake index 8ce142c7b5e..fb6ff0164b6 100644 --- a/src/ossia_sources.cmake +++ b/src/ossia_sources.cmake @@ -864,6 +864,7 @@ set(OSSIA_DATAFLOW_HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/nodes/timestretch/repitch_stretcher.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/nodes/timestretch/rubberband_stretcher.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/port.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/telemetry.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/safe_nodes/port.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/safe_nodes/tick_policies.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/sample_to_float.hpp" @@ -894,6 +895,7 @@ set(OSSIA_DATAFLOW_SRCS "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/data.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/geometry_port.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/port.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/telemetry.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/graph_node.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/execution_state.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/nodes/state.cpp" diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 498ae7b835e..3e5aab7b8e2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -117,6 +117,7 @@ if(OSSIA_DATAFLOW) ossia_add_test(OutletUnitTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/OutletUnitTest.cpp") ossia_add_test(IntervalGainTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/IntervalGainTest.cpp") ossia_add_test(MainGainTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/MainGainTest.cpp") + ossia_add_test(TelemetryTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/TelemetryTest.cpp") ossia_add_test(MidiNodeTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/MidiNodeTest.cpp") ossia_add_test(DataflowTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/DataflowTest.cpp") ossia_add_test(TickMethodTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/TickMethodTest.cpp") diff --git a/tests/Dataflow/TelemetryTest.cpp b/tests/Dataflow/TelemetryTest.cpp new file mode 100644 index 00000000000..0b8a3a873d0 --- /dev/null +++ b/tests/Dataflow/TelemetryTest.cpp @@ -0,0 +1,208 @@ +// The telemetry arena hands meter levels from the audio thread to the +// interface without losing a peak, whatever the reading rate. + +#include + +#include +#include + +#include +#include + +#include +#include + +using namespace ossia::telemetry; + +namespace +{ +constexpr std::size_t frames = 8; + +ossia::audio_vector constant(std::initializer_list levels) +{ + ossia::audio_vector v; + for(double l : levels) + { + ossia::audio_channel c; + c.assign(frames, l); + v.push_back(std::move(c)); + } + return v; +} + +struct Fixture +{ + arena a{4}; + std::shared_ptr tap = std::make_shared(); + std::weak_ptr weak = tap; + + Fixture() + { + a.set_publish_interval(frames); + a.attach(1, 7, tap_kind::node, tap); + // The arena holds the tap now; the caller got the previous one back. + REQUIRE(!tap); + REQUIRE(!weak.expired()); + } + + // One tick in which the tap's writer carries `v`. + void tick(const ossia::audio_vector& v) + { + weak.lock()->pending.accumulate(v); + a.tick(frames, 48000); + } + + const meter_slot& slot() { return a.latest().meters[1]; } +}; +} + +TEST_CASE("A published frame carries the tap's levels", "[telemetry]") +{ + Fixture f; + f.tick(constant({0.5, -0.25})); + + REQUIRE(f.a.consume()); + const auto& s = f.slot(); + CHECK(s.generation == 7); + CHECK(s.levels.channels == 2); + CHECK(s.levels.ticks == 1); + CHECK(s.levels.frames == frames); + CHECK(s.levels.peak[0] == Catch::Approx(0.5)); + CHECK(s.levels.peak[1] == Catch::Approx(0.25)); + CHECK(s.levels.rms(0) == Catch::Approx(0.5)); + CHECK(!s.levels.is_clipped(0)); + + CHECK(!f.a.consume()); +} + +TEST_CASE("Nothing is published before the interval has elapsed", "[telemetry]") +{ + Fixture f; + f.a.set_publish_interval(3 * frames); + f.tick(constant({0.1})); + f.tick(constant({0.9})); + CHECK(!f.a.consume()); + + f.tick(constant({0.2})); + REQUIRE(f.a.consume()); + CHECK(f.slot().levels.peak[0] == Catch::Approx(0.9)); + CHECK(f.slot().levels.ticks == 3); +} + +TEST_CASE("A frame nobody read is folded into the next one", "[telemetry]") +{ + Fixture f; + f.tick(constant({0.9})); + f.tick(constant({0.1})); + f.tick(constant({0.2})); + + REQUIRE(f.a.consume()); + CHECK(f.slot().levels.peak[0] == Catch::Approx(0.9)); + CHECK(f.slot().levels.ticks == 3); +} + +TEST_CASE("A frame that was read is not counted again", "[telemetry]") +{ + Fixture f; + f.tick(constant({0.9})); + REQUIRE(f.a.consume()); + + f.tick(constant({0.1})); + REQUIRE(f.a.consume()); + CHECK(f.slot().levels.peak[0] == Catch::Approx(0.1)); + CHECK(f.slot().levels.ticks == 1); +} + +TEST_CASE("A clip stays flagged until read", "[telemetry]") +{ + Fixture f; + f.tick(constant({0.1, 1.5})); + f.tick(constant({0.1, 0.1})); + REQUIRE(f.a.consume()); + CHECK(!f.slot().levels.is_clipped(0)); + CHECK(f.slot().levels.is_clipped(1)); +} + +TEST_CASE("A signal that widens and narrows keeps its widest count", "[telemetry]") +{ + Fixture f; + f.tick(constant({0.1})); + f.tick(constant({0.1, 0.2, 0.3})); + f.tick(constant({0.4})); + REQUIRE(f.a.consume()); + CHECK(f.slot().levels.channels == 3); + CHECK(f.slot().levels.peak[0] == Catch::Approx(0.4)); + CHECK(f.slot().levels.peak[2] == Catch::Approx(0.3)); +} + +TEST_CASE("A tap that did not run reads as not running", "[telemetry]") +{ + Fixture f; + f.a.tick(frames, 48000); + REQUIRE(f.a.consume()); + CHECK(f.slot().levels.ticks == 0); + + f.tick({}); + REQUIRE(f.a.consume()); + CHECK(f.slot().levels.ticks == 1); + CHECK(f.slot().levels.channels == 0); +} + +TEST_CASE("Detaching hands the tap back", "[telemetry]") +{ + Fixture f; + std::shared_ptr none; + f.a.attach(1, 8, tap_kind::none, none); + REQUIRE(none); + none.reset(); + CHECK(f.weak.expired()); + + f.a.tick(frames, 48000); + REQUIRE(f.a.consume()); + CHECK(f.slot().generation == 8); + CHECK(f.slot().levels.ticks == 0); +} + +TEST_CASE("Hardware taps read the driver's buffers", "[telemetry]") +{ + arena a{2}; + a.set_publish_interval(frames); + auto in = std::make_shared(); + auto out = std::make_shared(); + a.attach(0, 1, tap_kind::hardware_inputs, in); + a.attach(1, 1, tap_kind::hardware_outputs, out); + + std::array l{}, r{}; + l.fill(0.5f); + r.fill(-0.75f); + std::array chans{l.data(), r.data()}; + ossia::audio_tick_state st{ + .inputs = chans.data(), .outputs = chans.data(), .n_in = 1, .n_out = 2, + .frames = frames}; + a.accumulate_hardware(st); + a.tick(frames, 48000); + + REQUIRE(a.consume()); + CHECK(a.latest().meters[0].levels.channels == 1); + CHECK(a.latest().meters[1].levels.channels == 2); + CHECK(a.latest().meters[1].levels.peak[1] == Catch::Approx(0.75)); + CHECK(a.latest().sample_rate == 48000); + CHECK(a.latest().frames == frames); +} + +TEST_CASE("An outlet meters what it carries after its gain", "[telemetry][dataflow]") +{ + ossia::audio_outlet out; + auto tap = std::make_shared(); + out.meter = tap; + out.gain = 0.5; + + out->set_channels(1); + out->channel(0).assign(frames, 1.); + out.post_process(); + CHECK(tap->pending.peak[0] == Catch::Approx(0.5)); + + out->set_channels(0); + out.post_process(); + CHECK(tap->pending.ticks == 2); +} From 458189000e6ba4c52d259096503a78d913112e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 00:58:12 -0400 Subject: [PATCH 05/19] faust: a mono generator makes a channel with nothing plugged in Every program with at most one input and one output runs as a mono effect, one clone per incoming channel. A generator has no input, so with nothing cabled into it it made no channel at all: `process = os.osc(440);` in a box was silent. It now makes one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- .../dataflow/nodes/faust/faust_utils.hpp | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/ossia/dataflow/nodes/faust/faust_utils.hpp b/src/ossia/dataflow/nodes/faust/faust_utils.hpp index 2440f11a1ae..921a2a795f7 100644 --- a/src/ossia/dataflow/nodes/faust/faust_utils.hpp +++ b/src/ossia/dataflow/nodes/faust/faust_utils.hpp @@ -479,8 +479,11 @@ struct faust_node_utils = self.root_outputs()[0]->template cast(); const int64_t n_in = audio_in.channels(); - audio_out.set_channels(n_in); - while(self.clones.size() < n_in) + // One clone per incoming channel; a generator has none to follow and + // makes one. + const int64_t n_chans = dsp.getNumInputs() == 0 ? 1 : n_in; + audio_out.set_channels(n_chans); + while(self.clones.size() < n_chans) { self.clones.emplace_back(dsp.clone(), self.clones[0]); } @@ -505,15 +508,18 @@ struct faust_node_utils memset(input, 0, d * sizeof(float)); float* output = (float*)alloca(d * sizeof(float)); - for(int i = 0; i < n_in; i++) + for(int i = 0; i < n_chans; i++) { - auto& in_chan = audio_in.channel(i); auto& out_chan = audio_out.channel(i); auto& clone = self.clones[i]; - in_chan.resize(e.bufferSize()); out_chan.resize(e.bufferSize()); - copy_input_mono(self, d, n_in, input, in_chan); + if(i < n_in) + { + auto& in_chan = audio_in.channel(i); + in_chan.resize(e.bufferSize()); + copy_input_mono(self, d, n_in, input, in_chan); + } memset(output, 0, d * sizeof(float)); for(int z = 0; z < d; z++) { @@ -537,14 +543,18 @@ struct faust_node_utils } else { - for(int i = 0; i < n_in; i++) + for(int i = 0; i < n_chans; i++) { - auto& in_chan = audio_in.channel(i); auto& out_chan = audio_out.channel(i); - in_chan.resize(e.bufferSize()); out_chan.resize(e.bufferSize()); - double* input = in_chan.data() + st; + double* input = nullptr; + if(i < n_in) + { + auto& in_chan = audio_in.channel(i); + in_chan.resize(e.bufferSize()); + input = in_chan.data() + st; + } double* output = out_chan.data() + st; self.clones[i].fx->compute(d, &input, &output); From ff76cfa62298a4a9f3b1e78e6abc01c6e647a1ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 00:58:12 -0400 Subject: [PATCH 06/19] audio: the dummy engine can have channels It hands the graph silent inputs and discards its outputs, so that a session without a sound card can still be routed and metered. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/audio/dummy_protocol.hpp | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/ossia/audio/dummy_protocol.hpp b/src/ossia/audio/dummy_protocol.hpp index 3e8f14633f1..585d94c6be2 100644 --- a/src/ossia/audio/dummy_protocol.hpp +++ b/src/ossia/audio/dummy_protocol.hpp @@ -3,6 +3,7 @@ #include #include +#include namespace ossia { @@ -12,12 +13,21 @@ class dummy_engine final : public audio_engine std::atomic_bool m_active; public: - dummy_engine(int rate, int bs) + //! Silent inputs and discarded outputs, so that a graph can be routed and + //! metered without a sound card. + dummy_engine(int rate, int bs, int inputs = 0, int outputs = 0) { effective_sample_rate = rate; effective_buffer_size = bs; - effective_inputs = 0; - effective_outputs = 0; + effective_inputs = std::max(inputs, 0); + effective_outputs = std::max(outputs, 0); + + m_inputs.resize(effective_inputs, std::vector(bs)); + m_outputs.resize(effective_outputs, std::vector(bs)); + for(auto& c : m_inputs) + m_inputPtrs.push_back(c.data()); + for(auto& c : m_outputs) + m_outputPtrs.push_back(c.data()); setup_thread(); } @@ -92,8 +102,9 @@ class dummy_engine final : public audio_engine if(samples < 0) samples = 0; - ossia::audio_tick_state ts{nullptr, nullptr, 0, - 0, (uint64_t)samples, ns_total / 1e9}; + ossia::audio_tick_state ts{ + m_inputPtrs.data(), m_outputPtrs.data(), effective_inputs, + effective_outputs, (uint64_t)samples, ns_total / 1e9}; audio_tick(ts); start = clk::now(); @@ -117,6 +128,10 @@ class dummy_engine final : public audio_engine ~dummy_engine() override { stop(); } private: + std::vector> m_inputs; + std::vector> m_outputs; + std::vector m_inputPtrs; + std::vector m_outputPtrs; ossia::thread m_runThread; }; } From d452d655523e2b9f5731ccb3748c8434df29b7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 01:12:41 -0400 Subject: [PATCH 07/19] dataflow: time nodes into telemetry taps instead of a hash map The benchmark filled a hash map of node pointers the executors inserted into on the audio thread, and that score then copied on the audio thread every 50 ticks; keys were never removed, so a node allocated where a removed one was got its numbers. The executors now add each run's time to the node's own bench tap, when it has one and the bench_state says to measure, and the telemetry arena publishes the taps like the meters, with the span of frames they cover so that a reader can turn them into a share of the real time. The parallel executor now also benchmarks without a logger; the variant it would have used did not run nodes on the ticks it did not measure, and is fixed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/dataflow/bench_map.hpp | 13 -- src/ossia/dataflow/bench_state.hpp | 12 ++ src/ossia/dataflow/graph/graph.cpp | 4 +- src/ossia/dataflow/graph/graph_interface.hpp | 4 +- .../dataflow/graph/graph_parallel_impl.hpp | 11 +- src/ossia/dataflow/graph/graph_static.hpp | 2 +- src/ossia/dataflow/graph/graph_utils.hpp | 2 +- src/ossia/dataflow/graph/node_executors.hpp | 141 ++++++------------ src/ossia/dataflow/graph/tick_setup.hpp | 2 +- src/ossia/dataflow/graph_node.hpp | 10 ++ src/ossia/dataflow/telemetry.cpp | 48 +++++- src/ossia/dataflow/telemetry.hpp | 65 +++++++- src/ossia_sources.cmake | 2 +- tests/Dataflow/IntervalGainTest.cpp | 31 +++- tests/Dataflow/TelemetryTest.cpp | 44 ++++++ 15 files changed, 259 insertions(+), 132 deletions(-) delete mode 100644 src/ossia/dataflow/bench_map.hpp create mode 100644 src/ossia/dataflow/bench_state.hpp diff --git a/src/ossia/dataflow/bench_map.hpp b/src/ossia/dataflow/bench_map.hpp deleted file mode 100644 index 848d9e7cb27..00000000000 --- a/src/ossia/dataflow/bench_map.hpp +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once -#include -#include -namespace ossia -{ -class graph_node; -struct bench_map : ossia::hash_map> -{ - using ossia::hash_map< - const ossia::graph_node*, std::optional>::hash_map; - bool measure{}; -}; -} diff --git a/src/ossia/dataflow/bench_state.hpp b/src/ossia/dataflow/bench_state.hpp new file mode 100644 index 00000000000..a6cf1cb83e1 --- /dev/null +++ b/src/ossia/dataflow/bench_state.hpp @@ -0,0 +1,12 @@ +#pragma once +#include + +namespace ossia +{ +//! Whether the graph times its nodes. Each node's time goes to its +//! graph_node::bench_tap. +struct bench_state +{ + std::atomic_bool measure{}; +}; +} diff --git a/src/ossia/dataflow/graph/graph.cpp b/src/ossia/dataflow/graph/graph.cpp index cd5b0d29cfe..7ab2dea38ef 100644 --- a/src/ossia/dataflow/graph/graph.cpp +++ b/src/ossia/dataflow/graph/graph.cpp @@ -93,9 +93,9 @@ edge_ptr graph_interface::allocate_edge( pool, c, pout, pin, pout_node, pin_node); } -std::shared_ptr bench_ptr() +std::shared_ptr bench_ptr() { - static std::shared_ptr b = std::make_shared(); + static std::shared_ptr b = std::make_shared(); return b; } template diff --git a/src/ossia/dataflow/graph/graph_interface.hpp b/src/ossia/dataflow/graph/graph_interface.hpp index 6da658d4e29..4e33f596043 100644 --- a/src/ossia/dataflow/graph/graph_interface.hpp +++ b/src/ossia/dataflow/graph/graph_interface.hpp @@ -16,7 +16,7 @@ class logger; namespace ossia { struct edge_pool; -struct bench_map; +struct bench_state; struct connection; class time_interval; class OSSIA_EXPORT graph_interface @@ -73,7 +73,7 @@ struct graph_setup_options bool parallel{}; int parallel_threads = 8; std::shared_ptr log{}; - std::shared_ptr bench{}; + std::shared_ptr bench{}; }; struct tick_setup_options diff --git a/src/ossia/dataflow/graph/graph_parallel_impl.hpp b/src/ossia/dataflow/graph/graph_parallel_impl.hpp index be940a2df77..9902e8f3b78 100644 --- a/src/ossia/dataflow/graph/graph_parallel_impl.hpp +++ b/src/ossia/dataflow/graph/graph_parallel_impl.hpp @@ -204,7 +204,7 @@ struct custom_parallel_update { public: std::shared_ptr logger; - std::shared_ptr perf_map; + std::shared_ptr perf_map; template custom_parallel_update(Graph_T& g, const ossia::graph_setup_options& opt) @@ -229,7 +229,6 @@ struct custom_parallel_update node_exec_logger_bench{cur_state, *perf_map, *logger}); for(auto node : topo_order) { - (*perf_map)[node] = std::nullopt; flow_nodes[node] = flow_graph.emplace(*node); } } @@ -242,6 +241,14 @@ struct custom_parallel_update } } } + else if(perf_map) + { + executor.set_task_executor(node_exec_bench{cur_state, *perf_map}); + for(auto node : topo_order) + { + flow_nodes[node] = flow_graph.emplace(*node); + } + } else { executor.set_task_executor(node_exec{cur_state}); diff --git a/src/ossia/dataflow/graph/graph_static.hpp b/src/ossia/dataflow/graph/graph_static.hpp index fbaf2df4224..c49dbf42bc5 100644 --- a/src/ossia/dataflow/graph/graph_static.hpp +++ b/src/ossia/dataflow/graph/graph_static.hpp @@ -1,5 +1,5 @@ #pragma once -#include +#include #include #include #include diff --git a/src/ossia/dataflow/graph/graph_utils.hpp b/src/ossia/dataflow/graph/graph_utils.hpp index f295a7fdc7d..2cd0384600f 100644 --- a/src/ossia/dataflow/graph/graph_utils.hpp +++ b/src/ossia/dataflow/graph/graph_utils.hpp @@ -1,5 +1,5 @@ #pragma once -#include +#include #include #include #include diff --git a/src/ossia/dataflow/graph/node_executors.hpp b/src/ossia/dataflow/graph/node_executors.hpp index 804fd11b9e1..6b1a49cc922 100644 --- a/src/ossia/dataflow/graph/node_executors.hpp +++ b/src/ossia/dataflow/graph/node_executors.hpp @@ -1,9 +1,30 @@ #pragma once #include #include +#include + +#include namespace ossia { +//! Runs a node, adding the time it took to its bench tap when measuring. +template +inline void exec_timed(graph_node& node, bool measure, F&& exec) +{ + if(auto* tap = node.bench_tap.get(); measure && tap) + { + const auto t0 = std::chrono::steady_clock::now(); + exec(); + const auto t1 = std::chrono::steady_clock::now(); + tap->pending.add( + std::chrono::duration_cast(t1 - t0).count()); + } + else + { + exec(); + } +} + struct node_exec { execution_state*& g; @@ -25,27 +46,17 @@ struct node_exec struct node_exec_bench { execution_state*& g; - bench_map& perf; + bench_state& perf; void operator()(graph_node& node) try { - if(perf.measure) + if(node.enabled()) { - if(node.enabled()) - { - assert(graph_util::can_execute(node, *g)); - - auto t0 = std::chrono::steady_clock::now(); + assert(graph_util::can_execute(node, *g)); + exec_timed(node, perf.measure.load(std::memory_order_relaxed), [&] { graph_util::exec_node(node, *g); - auto t1 = std::chrono::steady_clock::now(); - perf[&node] - = std::chrono::duration_cast(t1 - t0).count(); - } - else - { - perf[&node] = 0; - } + }); } } catch(...) @@ -80,38 +91,21 @@ struct node_exec_logger struct node_exec_logger_bench { execution_state*& g; - bench_map& perf; + bench_state& perf; ossia::logger_type& logger; void operator()(graph_node& node) try { - if(perf.measure) + if(node.enabled()) { - if(node.enabled()) - { - assert(graph_util::can_execute(node, *g)); - - auto t0 = std::chrono::steady_clock::now(); + assert(graph_util::can_execute(node, *g)); + exec_timed(node, perf.measure.load(std::memory_order_relaxed), [&] { if(!node.logged()) graph_util::exec_node(node, *g); else graph_util::exec_node(node, *g, logger); - auto t1 = std::chrono::steady_clock::now(); - perf[&node] - = std::chrono::duration_cast(t1 - t0).count(); - } - else - { - perf[&node] = 0; - } - } - else - { - if(!node.logged()) - graph_util::exec_node(node, *g); - else - graph_util::exec_node(node, *g, logger); + }); } } catch(...) @@ -159,7 +153,7 @@ struct static_exec struct static_exec_bench { - std::shared_ptr perf; + std::shared_ptr perf; template static_exec_bench(Graph_T&) { @@ -181,35 +175,13 @@ struct static_exec_bench std::vector& active_nodes) try { - auto& p = *perf; - if(p.measure) - { - for(auto node : active_nodes) - { - if(node->enabled()) - { - assert(graph_util::can_execute(*node, e)); - auto t0 = std::chrono::steady_clock::now(); - graph_util::exec_node(*node, e); - auto t1 = std::chrono::steady_clock::now(); - p[node] - = std::chrono::duration_cast(t1 - t0).count(); - } - else - { - p[node] = 0; - } - } - } - else + const bool measure = perf->measure.load(std::memory_order_relaxed); + for(auto node : active_nodes) { - for(auto node : active_nodes) + if(node->enabled()) { - if(node->enabled()) - { - assert(graph_util::can_execute(*node, e)); - graph_util::exec_node(*node, e); - } + assert(graph_util::can_execute(*node, e)); + exec_timed(*node, measure, [&] { graph_util::exec_node(*node, e); }); } } } @@ -235,7 +207,7 @@ struct static_exec_logger { } - std::shared_ptr perf; + std::shared_ptr perf; std::shared_ptr logger; template void operator()( @@ -274,7 +246,7 @@ struct static_exec_logger_bench perf = t; } - std::shared_ptr perf; + std::shared_ptr perf; std::shared_ptr logger; template @@ -287,41 +259,18 @@ struct static_exec_logger_bench std::vector& active_nodes) try { - auto& p = *perf; - if(p.measure) - { - for(auto node : active_nodes) - { - if(node->enabled()) - { - assert(graph_util::can_execute(*node, e)); - auto t0 = std::chrono::steady_clock::now(); - if(!node->logged()) - graph_util::exec_node(*node, e); - else - graph_util::exec_node(*node, e, *logger); - auto t1 = std::chrono::steady_clock::now(); - p[node] - = std::chrono::duration_cast(t1 - t0).count(); - } - else - { - p[node] = 0; - } - } - } - else + const bool measure = perf->measure.load(std::memory_order_relaxed); + for(auto node : active_nodes) { - for(auto node : active_nodes) + if(node->enabled()) { - if(node->enabled()) - { - assert(graph_util::can_execute(*node, e)); + assert(graph_util::can_execute(*node, e)); + exec_timed(*node, measure, [&] { if(!node->logged()) graph_util::exec_node(*node, e); else graph_util::exec_node(*node, e, *logger); - } + }); } } } diff --git a/src/ossia/dataflow/graph/tick_setup.hpp b/src/ossia/dataflow/graph/tick_setup.hpp index c1fa1330791..0f3ede6f8f3 100644 --- a/src/ossia/dataflow/graph/tick_setup.hpp +++ b/src/ossia/dataflow/graph/tick_setup.hpp @@ -6,7 +6,7 @@ namespace ossia { class scenario; OSSIA_EXPORT -std::shared_ptr bench_ptr(); +std::shared_ptr bench_ptr(); OSSIA_EXPORT smallfun::function make_tick( diff --git a/src/ossia/dataflow/graph_node.hpp b/src/ossia/dataflow/graph_node.hpp index 7e29ecb60af..40b7b537111 100644 --- a/src/ossia/dataflow/graph_node.hpp +++ b/src/ossia/dataflow/graph_node.hpp @@ -13,9 +13,15 @@ #include #include +#include + namespace ossia { class graph; +namespace telemetry +{ +struct bench_tap; +} struct timed_value; struct typed_value; class state; @@ -205,6 +211,10 @@ class OSSIA_EXPORT graph_node virtual void all_notes_off() noexcept; token_request_vec requested_tokens; + //! When set and benchmarking is on, the executors add the time this node + //! takes to run. Swap it in and out from the audio thread only. + std::shared_ptr bench_tap; + protected: inlets m_inlets; outlets m_outlets; diff --git a/src/ossia/dataflow/telemetry.cpp b/src/ossia/dataflow/telemetry.cpp index 67c9a07273e..c842cac14d9 100644 --- a/src/ossia/dataflow/telemetry.cpp +++ b/src/ossia/dataflow/telemetry.cpp @@ -104,20 +104,23 @@ float meter_levels::rms(std::size_t chan) const noexcept namespace { -frame make_frame(std::size_t capacity) +frame make_frame(std::size_t meters, std::size_t benches) { frame f; - f.meters.resize(capacity); + f.meters.resize(meters); + f.benches.resize(benches); return f; } } -arena::arena(std::size_t meter_capacity) +arena::arena(std::size_t meter_capacity, std::size_t bench_capacity) : m_sources(meter_capacity) , m_unread(meter_capacity) - , m_staging{make_frame(meter_capacity)} - , m_buffer{make_frame(meter_capacity)} - , m_read{make_frame(meter_capacity)} + , m_benchSources(bench_capacity) + , m_benchUnread(bench_capacity) + , m_staging{make_frame(meter_capacity, bench_capacity)} + , m_buffer{make_frame(meter_capacity, bench_capacity)} + , m_read{make_frame(meter_capacity, bench_capacity)} { } @@ -138,6 +141,19 @@ void arena::attach( m_unread[index].levels.clear(); } +void arena::attach_bench( + std::size_t index, uint32_t generation, std::shared_ptr& tap) noexcept +{ + if(index >= m_benchSources.size()) + return; + + auto& src = m_benchSources[index]; + std::swap(src.tap, tap); + src.generation = generation; + m_benchUnread[index].generation = generation; + m_benchUnread[index].levels.clear(); +} + void arena::accumulate_hardware(const ossia::audio_tick_state& st) noexcept { for(auto& src : m_sources) @@ -167,12 +183,27 @@ void arena::tick(std::size_t frames, int sample_rate) noexcept void arena::publish() noexcept { - m_frames_since_publish = 0; - // A frame still waiting in the buffer has not been read: fold what came // since into it rather than replace it. const bool unread = m_buffer.has_new_data(); + m_unreadFrames = (unread ? m_unreadFrames : 0) + m_frames_since_publish; + m_frames_since_publish = 0; + + for(std::size_t i = 0; i < m_benchSources.size(); i++) + { + auto& src = m_benchSources[i]; + auto& acc = m_benchUnread[i].levels; + if(!unread) + acc.clear(); + if(src.tap) + { + acc.merge(src.tap->pending); + src.tap->pending.clear(); + } + m_staging.benches[i] = m_benchUnread[i]; + } + for(std::size_t i = 0; i < m_sources.size(); i++) { auto& src = m_sources[i]; @@ -190,6 +221,7 @@ void arena::publish() noexcept m_staging.publish_seq = ++m_seq; m_staging.frames = m_frames; + m_staging.window_frames = m_unreadFrames; m_staging.sample_rate = m_sample_rate; m_buffer.produce(m_staging); } diff --git a/src/ossia/dataflow/telemetry.hpp b/src/ossia/dataflow/telemetry.hpp index 38aba7773b6..8fcb72f1649 100644 --- a/src/ossia/dataflow/telemetry.hpp +++ b/src/ossia/dataflow/telemetry.hpp @@ -57,6 +57,35 @@ struct meter_tap meter_levels pending; }; +//! Time a node spent running over a span of frames. +struct bench_levels +{ + uint64_t ns{}; + uint64_t max_ns{}; + uint32_t runs{}; + + void clear() noexcept { *this = {}; } + void add(int64_t t) noexcept + { + const auto n = uint64_t(t > 0 ? t : 0); + ns += n; + max_ns = n > max_ns ? n : max_ns; + runs++; + } + void merge(const bench_levels& o) noexcept + { + ns += o.ns; + max_ns = o.max_ns > max_ns ? o.max_ns : max_ns; + runs += o.runs; + } +}; + +//! One timed node, fed by the graph's executors. +struct bench_tap +{ + bench_levels pending; +}; + enum class tap_kind : uint8_t { none, @@ -71,26 +100,48 @@ struct meter_slot meter_levels levels; }; +struct bench_slot +{ + uint32_t generation{}; + bench_levels levels; +}; + struct frame { uint64_t publish_seq{}; //! Frames executed since the arena started. uint64_t frames{}; + //! Frames the slots cover: since the previous frame that was read. + uint64_t window_frames{}; int32_t sample_rate{}; std::vector meters; + std::vector benches; + + //! Share of the real time a bench slot's node took to run, 1 being all of + //! it. + [[nodiscard]] double load(const bench_slot& b) const noexcept + { + if(window_frames == 0 || sample_rate <= 0) + return 0.; + return double(b.levels.ns) * sample_rate / (1e9 * double(window_frames)); + } }; class OSSIA_EXPORT arena { public: //! Allocates everything the audio thread will ever touch. - explicit arena(std::size_t meter_capacity); + explicit arena(std::size_t meter_capacity, std::size_t bench_capacity = 0); ~arena(); arena(const arena&) = delete; arena& operator=(const arena&) = delete; [[nodiscard]] std::size_t meter_capacity() const noexcept { return m_sources.size(); } + [[nodiscard]] std::size_t bench_capacity() const noexcept + { + return m_benchSources.size(); + } // Audio thread // @@ -99,6 +150,9 @@ class OSSIA_EXPORT arena void attach( std::size_t index, uint32_t generation, tap_kind kind, std::shared_ptr& tap) noexcept; + //! Same, for a timed node. + void attach_bench( + std::size_t index, uint32_t generation, std::shared_ptr& tap) noexcept; //! Feeds the hardware taps from the driver's buffers. Call after the mix. void accumulate_hardware(const ossia::audio_tick_state& st) noexcept; @@ -130,9 +184,18 @@ class OSSIA_EXPORT arena tap_kind kind{}; }; + struct bench_source + { + std::shared_ptr tap; + uint32_t generation{}; + }; + // Audio thread only. std::vector m_sources; std::vector m_unread; + std::vector m_benchSources; + std::vector m_benchUnread; + uint64_t m_unreadFrames{}; frame m_staging; uint64_t m_frames{}; uint64_t m_frames_since_publish{}; diff --git a/src/ossia_sources.cmake b/src/ossia_sources.cmake index fb6ff0164b6..420ea3bcc00 100644 --- a/src/ossia_sources.cmake +++ b/src/ossia_sources.cmake @@ -786,7 +786,7 @@ set(OSSIA_DATAFLOW_HEADERS "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/audio_lock.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/audio_port.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/audio_stretch_mode.hpp" - "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/bench_map.hpp" + "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/bench_state.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/connection.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/control_inlets.hpp" "${CMAKE_CURRENT_SOURCE_DIR}/ossia/dataflow/data.hpp" diff --git a/tests/Dataflow/IntervalGainTest.cpp b/tests/Dataflow/IntervalGainTest.cpp index a962870f043..325ec924efb 100644 --- a/tests/Dataflow/IntervalGainTest.cpp +++ b/tests/Dataflow/IntervalGainTest.cpp @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include #include #include @@ -46,8 +48,7 @@ class constant_source final : public ossia::nonowning_graph_node // outlet propagates to the enclosing scenario. struct Chain { - std::shared_ptr g - = ossia::make_graph(ossia::graph_setup_options{}); + std::shared_ptr g; ossia::execution_state e; std::shared_ptr source; std::shared_ptr interval @@ -55,8 +56,9 @@ struct Chain std::shared_ptr parent = std::make_shared(); - explicit Chain(std::vector levels) - : source{std::make_shared(std::move(levels))} + explicit Chain(std::vector levels, const ossia::graph_setup_options& opt = {}) + : g{ossia::make_graph(opt)} + , source{std::make_shared(std::move(levels))} { g->add_node(source); g->add_node(interval); @@ -175,3 +177,24 @@ TEST_CASE("Channels past the pan weights keep unity", "[dataflow][interval][pan] check_levels(c.tick(), {0.5, 0.5, 1., 1.}); CHECK(c.interval->audio_out.pan.size() == 2); } + +TEST_CASE("A benchmarked graph times the nodes that have a tap", "[dataflow][bench]") +{ + auto bench = std::make_shared(); + bench->measure = true; + ossia::graph_setup_options opt; + opt.bench = bench; + + Chain c{{1.}, opt}; + + auto tap = std::make_shared(); + c.source->bench_tap = tap; + + check_levels(c.tick(), {1.}); + CHECK(tap->pending.runs == 1); + CHECK(!c.interval->bench_tap); + + bench->measure = false; + check_levels(c.tick(), {1.}); + CHECK(tap->pending.runs == 1); +} diff --git a/tests/Dataflow/TelemetryTest.cpp b/tests/Dataflow/TelemetryTest.cpp index 0b8a3a873d0..a94f585a6e2 100644 --- a/tests/Dataflow/TelemetryTest.cpp +++ b/tests/Dataflow/TelemetryTest.cpp @@ -206,3 +206,47 @@ TEST_CASE("An outlet meters what it carries after its gain", "[telemetry][datafl out.post_process(); CHECK(tap->pending.ticks == 2); } + +TEST_CASE("A timed node's share of the real time comes back", "[telemetry][bench]") +{ + arena a{0, 2}; + a.set_publish_interval(frames); + auto tap = std::make_shared(); + std::weak_ptr weak = tap; + a.attach_bench(1, 3, tap); + REQUIRE(!tap); + + // Half of a buffer of `frames` frames at 48 kHz, in two runs. + const int64_t buffer_ns = int64_t(1e9 * frames / 48000); + weak.lock()->pending.add(buffer_ns / 4); + weak.lock()->pending.add(buffer_ns / 4); + a.tick(frames, 48000); + + REQUIRE(a.consume()); + const auto& f = a.latest(); + const auto& s = f.benches[1]; + CHECK(s.generation == 3); + CHECK(s.levels.runs == 2); + CHECK(s.levels.max_ns == uint64_t(buffer_ns / 4)); + CHECK(f.window_frames == frames); + CHECK(f.load(s) == Catch::Approx(0.5).epsilon(0.01)); +} + +TEST_CASE("Time not read yet is folded into the next frame", "[telemetry][bench]") +{ + arena a{0, 1}; + a.set_publish_interval(frames); + auto tap = std::make_shared(); + auto* t = tap.get(); + a.attach_bench(0, 1, tap); + + t->pending.add(100); + a.tick(frames, 48000); + t->pending.add(300); + a.tick(frames, 48000); + + REQUIRE(a.consume()); + CHECK(a.latest().benches[0].levels.ns == 400); + CHECK(a.latest().benches[0].levels.max_ns == 300); + CHECK(a.latest().window_frames == 2 * frames); +} From d4791fb63a91e71617b130f7568fd7a787e3b4e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 01:32:48 -0400 Subject: [PATCH 08/19] dataflow: an audio inlet can carry a meter tap It is fed in pre_process, once every source has been mixed into the inlet, so a view can show what a process receives. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/dataflow/port.cpp | 6 ++++++ src/ossia/dataflow/port.hpp | 16 +++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/ossia/dataflow/port.cpp b/src/ossia/dataflow/port.cpp index abdd8351a42..74cc2ba6e7c 100644 --- a/src/ossia/dataflow/port.cpp +++ b/src/ossia/dataflow/port.cpp @@ -263,6 +263,12 @@ value_inlet::~value_inlet() = default; value_outlet::~value_outlet() = default; audio_inlet::~audio_inlet() = default; +void audio_inlet::pre_process() +{ + if(meter) + meter->pending.accumulate(data.get()); +} + audio_outlet::~audio_outlet() = default; texture_inlet::~texture_inlet() = default; diff --git a/src/ossia/dataflow/port.hpp b/src/ossia/dataflow/port.hpp index 32290487a32..49e2c299c6b 100644 --- a/src/ossia/dataflow/port.hpp +++ b/src/ossia/dataflow/port.hpp @@ -153,6 +153,11 @@ struct OSSIA_EXPORT outlet : public port friend struct midi_outlet; }; +namespace telemetry +{ +struct meter_tap; +} + struct OSSIA_EXPORT audio_inlet : public ossia::inlet { audio_inlet() noexcept = default; @@ -181,7 +186,13 @@ struct OSSIA_EXPORT audio_inlet : public ossia::inlet return audio_port::which; } + void pre_process() override; + ossia::audio_port data; + + //! When set, pre_process feeds it what the inlet receives, once every + //! source has been mixed in. Swap it in and out from the audio thread only. + std::shared_ptr meter; }; struct OSSIA_EXPORT midi_inlet : public ossia::inlet @@ -247,11 +258,6 @@ struct OSSIA_EXPORT value_inlet : public ossia::inlet }; struct audio_outlet; -namespace telemetry -{ -struct meter_tap; -} - struct OSSIA_EXPORT audio_outlet : public ossia::outlet { audio_outlet() noexcept { init(); } From a8bd1444f376429b54cecbcb49a5c3986be4ad46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 01:51:20 -0400 Subject: [PATCH 09/19] audio: /in/main's gain scales every input The main input is the input trim, the way /out/main is the master: reading /in/N or a mapped input applies its own gain and /in/main's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/audio/audio_parameter.cpp | 4 ++- src/ossia/audio/audio_parameter.hpp | 4 +++ src/ossia/audio/audio_protocol.cpp | 9 +++++++ tests/Dataflow/MainGainTest.cpp | 41 +++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/ossia/audio/audio_parameter.cpp b/src/ossia/audio/audio_parameter.cpp index 388f75f64fa..25777749428 100644 --- a/src/ossia/audio/audio_parameter.cpp +++ b/src/ossia/audio/audio_parameter.cpp @@ -31,7 +31,9 @@ void audio_parameter::clone_value(audio_vector& res_vec) const } } - const double g = stage == gain_stage::pull ? gain() : 1.; + double g = stage == gain_stage::pull ? gain() : 1.; + if(upstream) + g *= upstream->gain(); auto min_chan = std::min(res_vec.size(), audio.size()); for(std::size_t chan = 0; chan < min_chan; chan++) { diff --git a/src/ossia/audio/audio_parameter.hpp b/src/ossia/audio/audio_parameter.hpp index 06fe08ee809..2686ec4ebf4 100644 --- a/src/ossia/audio/audio_parameter.hpp +++ b/src/ossia/audio/audio_parameter.hpp @@ -25,6 +25,10 @@ class OSSIA_EXPORT audio_parameter : public ossia::net::parameter_base external, //!< by whoever owns the buffers, after the sum (/out/main) } stage{gain_stage::push}; + //! A parameter whose gain also applies to this one: /in/main for the + //! inputs. + const audio_parameter* upstream{}; + explicit audio_parameter(ossia::net::node_base& n); //! Linear gain, readable from any thread. diff --git a/src/ossia/audio/audio_protocol.cpp b/src/ossia/audio/audio_protocol.cpp index ce06e26f7ba..a0cea2af225 100644 --- a/src/ossia/audio/audio_protocol.cpp +++ b/src/ossia/audio/audio_protocol.cpp @@ -60,6 +60,7 @@ void audio_protocol::setup_tree(int inputs, int outputs) audio_ins.push_back(ossia::net::find_parameter_or_create_node( root, "/in/" + std::to_string(i + 1))); audio_ins.back()->stage = audio_parameter::gain_stage::pull; + audio_ins.back()->upstream = main_audio_in; } for(int i = 0; i < outputs; i++) { @@ -73,6 +74,9 @@ void audio_protocol::setup_tree(int inputs, int outputs) audio_ins[i]->audio.resize(1); } + for(auto p : in_mappings) + p->upstream = main_audio_in; + main_audio_out->audio.resize(outputs); for(int i = 0; i < outputs; i++) { @@ -225,9 +229,14 @@ void audio_protocol::set_device(ossia::net::device_base& dev) void audio_protocol::register_parameter(mapped_audio_parameter& p) { if(p.is_output) + { out_mappings.push_back(&p); + } else + { + p.upstream = main_audio_in; in_mappings.push_back(&p); + } } void audio_protocol::unregister_parameter(mapped_audio_parameter& p) diff --git a/tests/Dataflow/MainGainTest.cpp b/tests/Dataflow/MainGainTest.cpp index 227513e04b6..9328b30ee64 100644 --- a/tests/Dataflow/MainGainTest.cpp +++ b/tests/Dataflow/MainGainTest.cpp @@ -115,3 +115,44 @@ TEST_CASE("A main gain change ramps over one buffer", "[audio][main][gain]") for(float s : c.out_buffers[0]) CHECK(s == Catch::Approx(0.)); } + +TEST_CASE("/in/main scales every input read", "[audio][main][gain]") +{ + ossia::audio_protocol* proto{}; + ossia::net::generic_device dev{ + [&] { + auto p = std::make_unique(); + proto = p.get(); + return p; + }(), + "audio"}; + proto->setup_tree(2, 0); + + std::array l{}, r{}; + l.fill(1.f); + r.fill(1.f); + std::array inputs{l.data(), r.data()}; + proto->setup_buffers( + {.inputs = inputs.data(), .outputs = nullptr, .n_in = 2, .n_out = 0, + .frames = frames}); + + proto->main_audio_in->set_value(0.5f); + proto->audio_ins[1]->set_value(0.5f); + + auto read = [](const ossia::audio_parameter& p) { + ossia::audio_vector v; + p.clone_value(v); + return v; + }; + + const auto main = read(*proto->main_audio_in); + REQUIRE(main.size() == 2); + CHECK(main[0][0] == Catch::Approx(0.5)); + + const auto first = read(*proto->audio_ins[0]); + REQUIRE(first.size() == 1); + CHECK(first[0][0] == Catch::Approx(0.5)); + + const auto second = read(*proto->audio_ins[1]); + CHECK(second[0][0] == Catch::Approx(0.25)); +} From 84beea7171096d0ddbaf36ed2ce5ec7cc70d7522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 08:16:55 -0400 Subject: [PATCH 10/19] dataflow: an audio outlet can upmix what it carries Before gain and pan apply, a signal narrower than upmix_channels can be widened by repeating its channels (a mono signal is duplicated, a stereo one becomes L R L R...) or by adding silent channels. A mono bus repeated to two channels can then be panned. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/dataflow/port.cpp | 19 +++++++++++++++ src/ossia/dataflow/port.hpp | 12 +++++++++ tests/Dataflow/IntervalGainTest.cpp | 38 +++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/src/ossia/dataflow/port.cpp b/src/ossia/dataflow/port.cpp index 74cc2ba6e7c..e25f1da94ef 100644 --- a/src/ossia/dataflow/port.cpp +++ b/src/ossia/dataflow/port.cpp @@ -283,6 +283,25 @@ void audio_outlet::post_process() if(auto& gain_msg = std::as_const(gain_inlet).data.get_data(); !gain_msg.empty()) gain = ossia::convert(gain_msg.back().value); + if(upmix != upmix_mode::none) + { + const std::size_t in = data.channels(); + const std::size_t out = upmix_channels; + if(in > 0 && in < out) + { + data.set_channels(out); + const std::size_t n = data.channel(0).size(); + for(std::size_t c = in; c < out; c++) + { + auto& chan = data.channel(c); + if(upmix == upmix_mode::repeat) + chan.assign(data.channel(c % in).begin(), data.channel(c % in).end()); + else + chan.assign(n, 0.); + } + } + } + // TODO pan inlet const std::size_t C = data.channels(); if(C == 0) diff --git a/src/ossia/dataflow/port.hpp b/src/ossia/dataflow/port.hpp index 49e2c299c6b..06d5972b111 100644 --- a/src/ossia/dataflow/port.hpp +++ b/src/ossia/dataflow/port.hpp @@ -9,6 +9,7 @@ #include #include +#include #include namespace ossia @@ -294,6 +295,17 @@ struct OSSIA_EXPORT audio_outlet : public ossia::outlet void post_process() override; + //! How a signal narrower than upmix_channels is widened, before gain and + //! pan apply. + enum class upmix_mode : uint8_t + { + none, + repeat, //!< channel c takes input channel c modulo the input width + pad, //!< silent channels are added + }; + upmix_mode upmix{upmix_mode::none}; + uint16_t upmix_channels{}; + //! Linear gain, and per-channel weights: channels past the end of pan //! have a weight of 1. A mono signal ignores pan. double gain{1.}; diff --git a/tests/Dataflow/IntervalGainTest.cpp b/tests/Dataflow/IntervalGainTest.cpp index 325ec924efb..dda42092c40 100644 --- a/tests/Dataflow/IntervalGainTest.cpp +++ b/tests/Dataflow/IntervalGainTest.cpp @@ -198,3 +198,41 @@ TEST_CASE("A benchmarked graph times the nodes that have a tap", "[dataflow][ben check_levels(c.tick(), {1.}); CHECK(tap->pending.runs == 1); } + +TEST_CASE("A mono interval repeated to stereo can be panned", "[dataflow][interval][upmix]") +{ + Chain c{{1.}}; + c.interval->audio_out.upmix = ossia::audio_outlet::upmix_mode::repeat; + c.interval->audio_out.upmix_channels = 2; + c.interval->audio_out.pan = ossia::pan_weight{1., 0.25}; + check_levels(c.tick(), {1., 0.25}); +} + +TEST_CASE("Repeating wraps the channels around", "[dataflow][interval][upmix]") +{ + Chain c{{0.1, 0.2}}; + c.interval->audio_out.upmix = ossia::audio_outlet::upmix_mode::repeat; + c.interval->audio_out.upmix_channels = 5; + check_levels(c.tick(), {0.1, 0.2, 0.1, 0.2, 0.1}); +} + +TEST_CASE("Padding adds silent channels", "[dataflow][interval][upmix]") +{ + Chain c{{0.5}}; + c.interval->audio_out.upmix = ossia::audio_outlet::upmix_mode::pad; + c.interval->audio_out.upmix_channels = 3; + check_levels(c.tick(), {0.5, 0., 0.}); +} + +TEST_CASE("A signal already wide enough is left as it is", "[dataflow][interval][upmix]") +{ + Chain c{{0.1, 0.2, 0.3}}; + c.interval->audio_out.upmix = ossia::audio_outlet::upmix_mode::repeat; + c.interval->audio_out.upmix_channels = 2; + check_levels(c.tick(), {0.1, 0.2, 0.3}); + + Chain silent{{}}; + silent.interval->audio_out.upmix = ossia::audio_outlet::upmix_mode::repeat; + silent.interval->audio_out.upmix_channels = 2; + CHECK(silent.tick().empty()); +} From db8634e8041251924177755e1fc5a878899dc669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 09:19:06 -0400 Subject: [PATCH 11/19] audio: run changes to the ports while the callback skips its tick audio_engine::run_parked runs a function while the audio callback skips its tick, the way it does when stopped: the audio protocol's lists of ports can then change without the callback walking them. A virtual port can change its channel count. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/audio/audio_engine.cpp | 16 +++++++++++ src/ossia/audio/audio_engine.hpp | 5 ++++ src/ossia/audio/audio_parameter.hpp | 8 ++++++ tests/CMakeLists.txt | 1 + tests/Dataflow/AudioEngineParkTest.cpp | 37 ++++++++++++++++++++++++++ 5 files changed, 67 insertions(+) create mode 100644 tests/Dataflow/AudioEngineParkTest.cpp diff --git a/src/ossia/audio/audio_engine.cpp b/src/ossia/audio/audio_engine.cpp index 4611e93dcce..963f4c59ca3 100644 --- a/src/ossia/audio/audio_engine.cpp +++ b/src/ossia/audio/audio_engine.cpp @@ -87,6 +87,22 @@ void audio_engine::sync() } } +void audio_engine::run_parked(const std::function& f) +{ + if(stop_processing || !running()) + { + f(); + return; + } + + // Past sync(), the callback that may have been running has returned and the + // next ones skip their tick until stop_processing is lowered. + stop_processing = true; + sync(); + f(); + stop_processing = false; +} + void audio_engine::gc() { // try to make deallocations happen in the main thread as far as possible diff --git a/src/ossia/audio/audio_engine.hpp b/src/ossia/audio/audio_engine.hpp index 6484c975c5e..abc46d61be7 100644 --- a/src/ossia/audio/audio_engine.hpp +++ b/src/ossia/audio/audio_engine.hpp @@ -6,6 +6,7 @@ #include #include +#include #include @@ -24,6 +25,10 @@ class OSSIA_EXPORT audio_engine void gc(); void sync(); + //! Runs f while the audio callback skips its tick, as it does when stopped: + //! f may change what the tick reads, such as the audio protocol's ports. + void run_parked(const std::function& f); + using fun_type = smallfun::function; void set_tick(fun_type&& t); void load_audio_tick(); diff --git a/src/ossia/audio/audio_parameter.hpp b/src/ossia/audio/audio_parameter.hpp index 2686ec4ebf4..a86460b23e5 100644 --- a/src/ossia/audio/audio_parameter.hpp +++ b/src/ossia/audio/audio_parameter.hpp @@ -70,6 +70,14 @@ class OSSIA_EXPORT virtual_audio_parameter final : public audio_parameter public: virtual_audio_parameter(int num_channels, ossia::net::node_base& n); + //! Changes the channel count; only while the audio callback does not run. + void set_channels(int num_channels) + { + const auto bs = m_audio_data.empty() ? 0 : m_audio_data.front().size(); + m_audio_data.resize(std::max(num_channels, 0)); + set_buffer_size(int(bs)); + } + void set_buffer_size(int bs) { const auto chan = m_audio_data.size(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3e5aab7b8e2..3ec89562347 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -118,6 +118,7 @@ if(OSSIA_DATAFLOW) ossia_add_test(IntervalGainTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/IntervalGainTest.cpp") ossia_add_test(MainGainTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/MainGainTest.cpp") ossia_add_test(TelemetryTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/TelemetryTest.cpp") + ossia_add_test(AudioEngineParkTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/AudioEngineParkTest.cpp") ossia_add_test(MidiNodeTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/MidiNodeTest.cpp") ossia_add_test(DataflowTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/DataflowTest.cpp") ossia_add_test(TickMethodTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/TickMethodTest.cpp") diff --git a/tests/Dataflow/AudioEngineParkTest.cpp b/tests/Dataflow/AudioEngineParkTest.cpp new file mode 100644 index 00000000000..5bd5db236b4 --- /dev/null +++ b/tests/Dataflow/AudioEngineParkTest.cpp @@ -0,0 +1,37 @@ +// While an engine is parked, its callback does not run the tick. + +#include + +#include + +#include + +#include +#include +#include + +TEST_CASE("A parked engine skips its tick until released", "[audio][engine]") +{ + ossia::dummy_engine engine{48000, 64, 0, 2}; + std::atomic_int ticks{}; + engine.set_tick([&](const ossia::audio_tick_state&) { ticks++; }); + + auto wait_for_ticks = [&](int n) { + const int start = ticks; + for(int i = 0; i < 500 && ticks < start + n; i++) + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + return ticks - start >= n; + }; + REQUIRE(wait_for_ticks(3)); + + int during{}; + engine.run_parked([&] { + const int before = ticks; + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + during = ticks - before; + }); + CHECK(during == 0); + + CHECK(wait_for_ticks(3)); + engine.stop(); +} From b5ed8fa017f2efbe2339cc430f9442fbeea6b3eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 09:51:14 -0400 Subject: [PATCH 12/19] telemetry: carry the playheads of the intervals The interface reads the latest position of each interval from the arena instead of receiving a closure per tick. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/dataflow/telemetry.cpp | 34 ++++++++++++++++++++++++++----- src/ossia/dataflow/telemetry.hpp | 35 +++++++++++++++++++++++++++++++- tests/Dataflow/TelemetryTest.cpp | 21 +++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/ossia/dataflow/telemetry.cpp b/src/ossia/dataflow/telemetry.cpp index c842cac14d9..0b77a302768 100644 --- a/src/ossia/dataflow/telemetry.cpp +++ b/src/ossia/dataflow/telemetry.cpp @@ -104,23 +104,27 @@ float meter_levels::rms(std::size_t chan) const noexcept namespace { -frame make_frame(std::size_t meters, std::size_t benches) +frame make_frame(std::size_t meters, std::size_t benches, std::size_t playheads) { frame f; f.meters.resize(meters); f.benches.resize(benches); + f.playheads.resize(playheads); return f; } } -arena::arena(std::size_t meter_capacity, std::size_t bench_capacity) +arena::arena( + std::size_t meter_capacity, std::size_t bench_capacity, + std::size_t playhead_capacity) : m_sources(meter_capacity) , m_unread(meter_capacity) , m_benchSources(bench_capacity) , m_benchUnread(bench_capacity) - , m_staging{make_frame(meter_capacity, bench_capacity)} - , m_buffer{make_frame(meter_capacity, bench_capacity)} - , m_read{make_frame(meter_capacity, bench_capacity)} + , m_playheadSources(playhead_capacity) + , m_staging{make_frame(meter_capacity, bench_capacity, playhead_capacity)} + , m_buffer{make_frame(meter_capacity, bench_capacity, playhead_capacity)} + , m_read{make_frame(meter_capacity, bench_capacity, playhead_capacity)} { } @@ -154,6 +158,17 @@ void arena::attach_bench( m_benchUnread[index].levels.clear(); } +void arena::attach_playhead( + std::size_t index, uint32_t generation, std::shared_ptr& tap) noexcept +{ + if(index >= m_playheadSources.size()) + return; + + auto& src = m_playheadSources[index]; + std::swap(src.tap, tap); + src.generation = generation; +} + void arena::accumulate_hardware(const ossia::audio_tick_state& st) noexcept { for(auto& src : m_sources) @@ -219,6 +234,15 @@ void arena::publish() noexcept m_staging.meters[i].levels = acc; } + for(std::size_t i = 0; i < m_playheadSources.size(); i++) + { + const auto& src = m_playheadSources[i]; + auto& slot = m_staging.playheads[i]; + slot.generation = src.generation; + slot.date = src.tap ? src.tap->date : 0; + slot.running = src.tap && src.tap->running; + } + m_staging.publish_seq = ++m_seq; m_staging.frames = m_frames; m_staging.window_frames = m_unreadFrames; diff --git a/src/ossia/dataflow/telemetry.hpp b/src/ossia/dataflow/telemetry.hpp index 8fcb72f1649..5009c644db9 100644 --- a/src/ossia/dataflow/telemetry.hpp +++ b/src/ossia/dataflow/telemetry.hpp @@ -86,6 +86,21 @@ struct bench_tap bench_levels pending; }; +//! Where something that runs in time is: written by the tick, the latest +//! value wins. +struct playhead_tap +{ + int64_t date{}; + bool running{}; +}; + +struct playhead_slot +{ + uint32_t generation{}; + int64_t date{}; + bool running{}; +}; + enum class tap_kind : uint8_t { none, @@ -116,6 +131,7 @@ struct frame int32_t sample_rate{}; std::vector meters; std::vector benches; + std::vector playheads; //! Share of the real time a bench slot's node took to run, 1 being all of //! it. @@ -131,7 +147,9 @@ class OSSIA_EXPORT arena { public: //! Allocates everything the audio thread will ever touch. - explicit arena(std::size_t meter_capacity, std::size_t bench_capacity = 0); + explicit arena( + std::size_t meter_capacity, std::size_t bench_capacity = 0, + std::size_t playhead_capacity = 0); ~arena(); arena(const arena&) = delete; @@ -142,6 +160,10 @@ class OSSIA_EXPORT arena { return m_benchSources.size(); } + [[nodiscard]] std::size_t playhead_capacity() const noexcept + { + return m_playheadSources.size(); + } // Audio thread // @@ -153,6 +175,10 @@ class OSSIA_EXPORT arena //! Same, for a timed node. void attach_bench( std::size_t index, uint32_t generation, std::shared_ptr& tap) noexcept; + //! Same, for a playhead. + void attach_playhead( + std::size_t index, uint32_t generation, + std::shared_ptr& tap) noexcept; //! Feeds the hardware taps from the driver's buffers. Call after the mix. void accumulate_hardware(const ossia::audio_tick_state& st) noexcept; @@ -195,6 +221,13 @@ class OSSIA_EXPORT arena std::vector m_unread; std::vector m_benchSources; std::vector m_benchUnread; + + struct playhead_source + { + std::shared_ptr tap; + uint32_t generation{}; + }; + std::vector m_playheadSources; uint64_t m_unreadFrames{}; frame m_staging; uint64_t m_frames{}; diff --git a/tests/Dataflow/TelemetryTest.cpp b/tests/Dataflow/TelemetryTest.cpp index a94f585a6e2..fc589fb9eb3 100644 --- a/tests/Dataflow/TelemetryTest.cpp +++ b/tests/Dataflow/TelemetryTest.cpp @@ -250,3 +250,24 @@ TEST_CASE("Time not read yet is folded into the next frame", "[telemetry][bench] CHECK(a.latest().benches[0].levels.max_ns == 300); CHECK(a.latest().window_frames == 2 * frames); } + +TEST_CASE("A playhead publishes its latest position", "[telemetry][playhead]") +{ + arena a{0, 0, 2}; + a.set_publish_interval(frames); + auto tap = std::make_shared(); + auto* t = tap.get(); + a.attach_playhead(1, 4, tap); + + t->running = true; + t->date = 100; + t->date = 250; + a.tick(frames, 48000); + + REQUIRE(a.consume()); + const auto& p = a.latest().playheads[1]; + CHECK(p.generation == 4); + CHECK(p.running); + CHECK(p.date == 250); + CHECK(!a.latest().playheads[0].running); +} From 15557291b171687db73612f2a2785917d64d0d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 10:04:52 -0400 Subject: [PATCH 13/19] triple_buffer: let the producer write in place and the reader read in place Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/detail/triple_buffer.hpp | 26 ++++++++++++ tests/CMakeLists.txt | 1 + tests/Dataflow/TripleBufferTest.cpp | 65 +++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 tests/Dataflow/TripleBufferTest.cpp diff --git a/src/ossia/detail/triple_buffer.hpp b/src/ossia/detail/triple_buffer.hpp index 4adc03b7bdb..3a3472f0026 100644 --- a/src/ossia/detail/triple_buffer.hpp +++ b/src/ossia/detail/triple_buffer.hpp @@ -83,6 +83,32 @@ class triple_buffer m_write_idx = old_mid & index_mask; } + // The producer's slot, to be written in place and then published. It holds + // whatever was written into it two publications ago, so containers in it + // keep their capacity and assigning to them does not allocate. + T& write_buffer() noexcept { return m_buffers[m_write_idx].data; } + + void publish() noexcept + { + const uint8_t new_mid = static_cast(m_write_idx | dirty_bit); + const uint8_t old_mid = m_mid_state.exchange(new_mid, std::memory_order_acq_rel); + m_write_idx = old_mid & index_mask; + } + + // Makes the latest published value the one read_buffer() gives, if there + // is a new one; the consumer reads it in place. + bool consume() noexcept + { + const uint8_t state = m_mid_state.load(std::memory_order_acquire); + if(!(state & dirty_bit)) + return false; + + const uint8_t new_mid = m_read_idx; + const uint8_t old_mid = m_mid_state.exchange(new_mid, std::memory_order_acq_rel); + m_read_idx = old_mid & index_mask; + return true; + } + // Consume latest data if available. Returns true and updates `result` // if new data was produced since the last consume(); false otherwise. bool consume(T& result) noexcept diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3ec89562347..46b4e808726 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -118,6 +118,7 @@ if(OSSIA_DATAFLOW) ossia_add_test(IntervalGainTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/IntervalGainTest.cpp") ossia_add_test(MainGainTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/MainGainTest.cpp") ossia_add_test(TelemetryTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/TelemetryTest.cpp") + ossia_add_test(TripleBufferTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/TripleBufferTest.cpp") ossia_add_test(AudioEngineParkTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/AudioEngineParkTest.cpp") ossia_add_test(MidiNodeTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/MidiNodeTest.cpp") ossia_add_test(DataflowTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/DataflowTest.cpp") diff --git a/tests/Dataflow/TripleBufferTest.cpp b/tests/Dataflow/TripleBufferTest.cpp new file mode 100644 index 00000000000..0527051c88e --- /dev/null +++ b/tests/Dataflow/TripleBufferTest.cpp @@ -0,0 +1,65 @@ +// A value written in place into a triple buffer reaches the reader whole, +// and the producer's containers keep their capacity from one write to the next. + +#include + +#include + +#include +#include +#include +#include + +namespace +{ +using value = std::tuple, std::string>; + +void write(ossia::triple_buffer& b, float f, std::size_t n) +{ + auto& [x, v, s] = b.write_buffer(); + x = f; + v.assign(n, f); + s = "abc"; + b.publish(); +} +} + +TEST_CASE("The reader gets the latest value written in place", "[triple_buffer]") +{ + ossia::triple_buffer b; + CHECK(!b.consume()); + + write(b, 1.f, 4); + write(b, 2.f, 8); + REQUIRE(b.consume()); + const auto& [x, v, s] = b.read_buffer(); + CHECK(x == 2.f); + CHECK(v == std::vector(8, 2.f)); + CHECK(s == "abc"); + + CHECK(!b.consume()); + CHECK(std::get<0>(b.read_buffer()) == 2.f); +} + +TEST_CASE("Writing in place stops allocating once every slot is warm", "[triple_buffer]") +{ + ossia::triple_buffer b; + // Each slot gets its capacity the first time the producer writes into it. + for(int i = 0; i < 3; i++) + { + write(b, float(i), 64); + b.consume(); + } + + std::vector seen; + for(int i = 0; i < 12; i++) + { + seen.push_back(std::get<1>(b.write_buffer()).data()); + write(b, float(i), 64); + if(i % 2) + b.consume(); + } + std::sort(seen.begin(), seen.end()); + seen.erase(std::unique(seen.begin(), seen.end()), seen.end()); + CHECK(seen.size() <= 3); +} From 253f0cae55661469593053b2077336acd1f0f9ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 10:12:22 -0400 Subject: [PATCH 14/19] faust: publish the values of the controls and displays for the interface The interface read the DSP's zones while the DSP wrote them. Each tick now copies them into a triple buffer that is sized once, so it never allocates. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/dataflow/nodes/faust/faust_node.hpp | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/ossia/dataflow/nodes/faust/faust_node.hpp b/src/ossia/dataflow/nodes/faust/faust_node.hpp index 27eeef060bd..9864d2bd07f 100644 --- a/src/ossia/dataflow/nodes/faust/faust_node.hpp +++ b/src/ossia/dataflow/nodes/faust/faust_node.hpp @@ -1,17 +1,58 @@ #pragma once #include #include +#include + +#include +#include namespace ossia::nodes { using faust_port_array = ossia::small_vector, 8>; + +//! The values of the controls then of the displays of a Faust node, as of its +//! latest tick, for the interface: reading the zones directly would race with +//! the DSP writing them. +struct faust_ui_values +{ + std::optional>> buffer; + + //! Once the zones are known; every slot gets its size, so that publishing + //! never allocates. + void init(const faust_port_array& controls, const faust_port_array& displays) + { + buffer.emplace(std::vector(controls.size() + displays.size())); + } + + void publish(const faust_port_array& controls, const faust_port_array& displays) noexcept + { + if(!buffer) + return; + auto& w = buffer->write_buffer(); + std::size_t i = 0; + for(auto& c : controls) + w[i++] = *c.second; + for(auto& d : displays) + w[i++] = *d.second; + buffer->publish(); + } + + //! The latest values, or nullptr when there are none since the previous call. + const std::vector* consume() noexcept + { + if(!buffer || !buffer->consume()) + return nullptr; + return &buffer->read_buffer(); + } +}; class faust_mono_fx final : public ossia::graph_node { public: std::shared_ptr m_dsp{}; faust_port_array controls; faust_port_array displays; + faust_ui_values ui; int generation{}; struct clone @@ -90,6 +131,7 @@ class faust_mono_fx final : public ossia::graph_node // Initialize the controls faust_exec_ui ex{*this}; m_dsp->buildUserInterface(&ex); + ui.init(controls, displays); // Preallocate for the most common case, two channels clones.emplace_back(m_dsp.get(), controls, displays); @@ -99,6 +141,7 @@ class faust_mono_fx final : public ossia::graph_node void run(const ossia::token_request& tk, ossia::exec_state_facade e) noexcept override { faust_node_utils{}.exec_mono_fx(*this, *m_dsp, tk, e); + ui.publish(controls, displays); } [[nodiscard]] std::string label() const noexcept override { return "Faust"; } @@ -125,6 +168,7 @@ class faust_fx final : public ossia::graph_node public: faust_port_array controls; faust_port_array displays; + faust_ui_values ui; int generation{}; faust_fx(std::shared_ptr dsp) : m_dsp{std::move(dsp)} @@ -133,12 +177,14 @@ class faust_fx final : public ossia::graph_node m_outlets.push_back(new ossia::audio_outlet); faust_exec_ui ex{*this}; m_dsp->buildUserInterface(&ex); + ui.init(controls, displays); } void set_control(int i, float v) noexcept { *controls[i].second = v; } void run(const ossia::token_request& tk, ossia::exec_state_facade e) noexcept override { faust_node_utils{}.exec(*this, *m_dsp, tk, e); + ui.publish(controls, displays); } [[nodiscard]] std::string label() const noexcept override { return "Faust"; } @@ -158,6 +204,7 @@ class faust_synth final : public ossia::graph_node public: faust_port_array controls; faust_port_array displays; + faust_ui_values ui; int generation{}; void set_control(int i, float v) noexcept { *controls[i].second = v; } @@ -170,11 +217,13 @@ class faust_synth final : public ossia::graph_node m_outlets.push_back(new ossia::audio_outlet); faust_exec_ui ex{*this}; m_dsp->buildUserInterface(&ex); + ui.init(controls, displays); } void run(const ossia::token_request& tk, ossia::exec_state_facade e) noexcept override { faust_node_utils{}.exec_synth(*this, *m_dsp, tk, e); + ui.publish(controls, displays); } [[nodiscard]] std::string label() const noexcept override { return "Faust Synth"; } From fa50176312e83cdfc4f0d30bb116e07320e13875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 10:44:49 -0400 Subject: [PATCH 15/19] audio: a virtual port can be metered The interface sets the address of a tap that the telemetry arena owns; what the graph writes to the port is accumulated into it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/audio/audio_parameter.cpp | 12 ++++++++++++ src/ossia/audio/audio_parameter.hpp | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/ossia/audio/audio_parameter.cpp b/src/ossia/audio/audio_parameter.cpp index 25777749428..02cbd8f35d0 100644 --- a/src/ossia/audio/audio_parameter.cpp +++ b/src/ossia/audio/audio_parameter.cpp @@ -3,8 +3,11 @@ #include "audio_protocol.hpp" #include +#include #include +#include + namespace ossia { @@ -169,6 +172,15 @@ void virtual_audio_parameter::push_value(const audio_port& port) dst[i] = float(src[i] * g); } } + + if(auto m = meter.load(std::memory_order_acquire)) + { + std::array chans; + const auto n = std::min(audio.size(), chans.size()); + for(std::size_t c = 0; c < n; c++) + chans[c] = audio[c].data(); + m->pending.accumulate(chans.data(), int(n), n > 0 ? audio[0].size() : 0); + } } virtual_audio_parameter::~virtual_audio_parameter() diff --git a/src/ossia/audio/audio_parameter.hpp b/src/ossia/audio/audio_parameter.hpp index a86460b23e5..c9c42368aa8 100644 --- a/src/ossia/audio/audio_parameter.hpp +++ b/src/ossia/audio/audio_parameter.hpp @@ -9,6 +9,11 @@ #include +namespace ossia::telemetry +{ +struct meter_tap; +} + namespace ossia { class OSSIA_EXPORT audio_parameter : public ossia::net::parameter_base @@ -91,6 +96,11 @@ class OSSIA_EXPORT virtual_audio_parameter final : public audio_parameter void push_value(const audio_port& port) override; + //! Meters what the graph writes here. Set by the interface thread; the + //! telemetry arena owns the tap and keeps it alive until the audio thread + //! can no longer be writing to it. + std::atomic meter{}; + virtual ~virtual_audio_parameter(); }; From d0995f2f4069cebdb62f46a696138c67b5beb3cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 10:44:49 -0400 Subject: [PATCH 16/19] execution_state: forget a parameter that is about to be destroyed The local state keeps an entry for every parameter it ever wrote to and writes to all of them at each commit, so removing a port that a process had written to crashed the next tick. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- .../dataflow/execution/execution_policy.hpp | 3 + .../local_state_execution_policy.cpp | 18 +++++ .../local_state_execution_policy.hpp | 1 + src/ossia/dataflow/execution_state.cpp | 6 ++ src/ossia/dataflow/execution_state.hpp | 4 + tests/Dataflow/TelemetryTest.cpp | 78 +++++++++++++++++++ 6 files changed, 110 insertions(+) diff --git a/src/ossia/dataflow/execution/execution_policy.hpp b/src/ossia/dataflow/execution/execution_policy.hpp index 43bc5cb5a2d..6d0e5e342f5 100644 --- a/src/ossia/dataflow/execution/execution_policy.hpp +++ b/src/ossia/dataflow/execution/execution_policy.hpp @@ -43,5 +43,8 @@ struct OSSIA_TEST_EXPORT execution_state_policy virtual void insert(ossia::net::parameter_base& dest, const ossia::value_port& v) = 0; virtual void insert(ossia::audio_parameter& dest, const audio_port& v) = 0; virtual void insert(ossia::net::midi::midi_parameter& dest, const midi_port& v) = 0; + + //! Drops everything kept for a parameter that is about to be destroyed. + virtual void forget(const net::parameter_base& p) noexcept { } }; } diff --git a/src/ossia/dataflow/execution/local_state_execution_policy.cpp b/src/ossia/dataflow/execution/local_state_execution_policy.cpp index b233ca9a369..b5eabbb26ff 100644 --- a/src/ossia/dataflow/execution/local_state_execution_policy.cpp +++ b/src/ossia/dataflow/execution/local_state_execution_policy.cpp @@ -341,6 +341,24 @@ bool local_state_execution_policy::in_local_scope(net::parameter_base& other) co || is_in(other, m_midiState)); } +void local_state_execution_policy::forget(const net::parameter_base& p) noexcept +{ + auto param = const_cast(&p); + m_valueState.erase(param); + m_midiState.erase(param); +#if defined(OSSIA_PROTOCOL_AUDIO) + if(auto audio = dynamic_cast(param)) + { + if(auto it = m_audioState.find(audio); it != m_audioState.end()) + { + for(auto& vec : it->second.get()) + ossia::audio_buffer_pool::instance().release(std::move(vec)); + m_audioState.erase(it); + } + } +#endif +} + void local_state_execution_policy::clear_local_state() { m_msgIndex = 0; diff --git a/src/ossia/dataflow/execution/local_state_execution_policy.hpp b/src/ossia/dataflow/execution/local_state_execution_policy.hpp index 3db6e68a1c1..6dddaabdded 100644 --- a/src/ossia/dataflow/execution/local_state_execution_policy.hpp +++ b/src/ossia/dataflow/execution/local_state_execution_policy.hpp @@ -21,6 +21,7 @@ struct OSSIA_TEST_EXPORT local_state_execution_policy : execution_state_policy bool find_and_copy(net::parameter_base& addr, inlet& in) override; void clear_local_state() override; + void forget(const net::parameter_base& p) noexcept override; void commit_common(); diff --git a/src/ossia/dataflow/execution_state.cpp b/src/ossia/dataflow/execution_state.cpp index 5bff0d66cb8..7b3e378f5d4 100644 --- a/src/ossia/dataflow/execution_state.cpp +++ b/src/ossia/dataflow/execution_state.cpp @@ -477,6 +477,12 @@ void execution_state::insert( { m_policy->insert(dest, std::move(v)); } +void execution_state::forget(const ossia::net::parameter_base& p) noexcept +{ + if(m_policy) + m_policy->forget(p); +} + void execution_state::insert(ossia::audio_parameter& dest, const ossia::audio_port& v) { m_policy->insert(dest, std::move(v)); diff --git a/src/ossia/dataflow/execution_state.hpp b/src/ossia/dataflow/execution_state.hpp index 00c2644326f..6fdb5fe3ae8 100644 --- a/src/ossia/dataflow/execution_state.hpp +++ b/src/ossia/dataflow/execution_state.hpp @@ -97,6 +97,10 @@ struct OSSIA_EXPORT execution_state : public Nano::Observer void commit(); + //! Drops everything the execution kept for a parameter about to be + //! destroyed; only while the graph does not run. + void forget(const ossia::net::parameter_base& p) noexcept; + bool in_local_scope(ossia::net::parameter_base& other) const; int sampleRate{44100}; diff --git a/tests/Dataflow/TelemetryTest.cpp b/tests/Dataflow/TelemetryTest.cpp index fc589fb9eb3..b2d104b18b4 100644 --- a/tests/Dataflow/TelemetryTest.cpp +++ b/tests/Dataflow/TelemetryTest.cpp @@ -3,8 +3,12 @@ #include +#include +#include +#include #include #include +#include #include #include @@ -271,3 +275,77 @@ TEST_CASE("A playhead publishes its latest position", "[telemetry][playhead]") CHECK(p.date == 250); CHECK(!a.latest().playheads[0].running); } + +TEST_CASE("A virtual port meters what the graph writes to it", "[telemetry][audio]") +{ + ossia::audio_protocol* proto{}; + ossia::net::generic_device dev{ + [&] { + auto p = std::make_unique(); + proto = p.get(); + return p; + }(), + "audio"}; + auto& node = ossia::net::create_node(dev.get_root_node(), "/bus"); + node.set_parameter(std::make_unique(2, node)); + auto& param = static_cast(*node.get_parameter()); + proto->setup_buffers( + {.inputs = nullptr, .outputs = nullptr, .n_in = 0, .n_out = 0, .frames = frames}); + + ossia::audio_port port; + port.set_channels(2); + port.channel(0).assign(frames, 0.5); + port.channel(1).assign(frames, -0.75); + + // Nothing is metered without a tap. + param.push_value(port); + + meter_tap tap; + param.meter = &tap; + param.push_value(port); + CHECK(tap.pending.ticks == 1); + CHECK(tap.pending.channels == 2); + CHECK(tap.pending.peak[0] == Catch::Approx(0.5)); + CHECK(tap.pending.peak[1] == Catch::Approx(0.75)); + + param.meter = nullptr; + param.push_value(port); + CHECK(tap.pending.ticks == 1); +} + +TEST_CASE("A forgotten parameter is not written to again", "[telemetry][audio]") +{ + // The execution keeps an entry per parameter it ever wrote to, and writes + // to it at every commit: a parameter about to be destroyed must be dropped. + ossia::audio_protocol* proto{}; + ossia::net::generic_device dev{ + [&] { + auto p = std::make_unique(); + proto = p.get(); + return p; + }(), + "audio"}; + auto& node = ossia::net::create_node(dev.get_root_node(), "/bus"); + node.set_parameter(std::make_unique(1, node)); + auto& param = static_cast(*node.get_parameter()); + proto->setup_buffers( + {.inputs = nullptr, .outputs = nullptr, .n_in = 0, .n_out = 0, .frames = frames}); + + meter_tap tap; + param.meter = &tap; + + ossia::execution_state st; + ossia::audio_port port; + port.set_channels(1); + port.channel(0).assign(frames, 0.5); + st.insert(param, port); + st.commit(); + CHECK(tap.pending.ticks == 1); + st.commit(); + CHECK(tap.pending.ticks == 2); + + st.forget(param); + st.commit(); + CHECK(tap.pending.ticks == 2); + param.meter = nullptr; +} From e014a85423523e90555da49d12292ecb6aabc386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 19:40:46 -0400 Subject: [PATCH 17/19] audio: change the ports between two ticks instead of stopping the audio audio_engine::run_between_ticks runs a short change on the audio thread at the start of the next callback and waits for it; run_parked, which skipped callbacks, is gone. The audio protocol can defer port changes: its callback walks its own lists, swapped in between two ticks, and a virtual port's channels are made off the audio thread and swapped the same way. The execution state no longer writes to a parameter nothing wrote to during the tick: its entries outlive their parameters. execution_state::forget is removed, as it is not needed any more. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/audio/audio_engine.cpp | 44 ++++++++++--- src/ossia/audio/audio_engine.hpp | 14 ++++- src/ossia/audio/audio_parameter.cpp | 12 ++++ src/ossia/audio/audio_parameter.hpp | 15 +++++ src/ossia/audio/audio_protocol.cpp | 63 +++++++++++++------ src/ossia/audio/audio_protocol.hpp | 21 +++++++ .../dataflow/execution/execution_policy.hpp | 3 - .../local_state_execution_policy.cpp | 23 ++----- .../local_state_execution_policy.hpp | 1 - src/ossia/dataflow/execution_state.cpp | 6 -- src/ossia/dataflow/execution_state.hpp | 4 -- tests/CMakeLists.txt | 2 +- .../Dataflow/AudioEngineBetweenTicksTest.cpp | 61 ++++++++++++++++++ tests/Dataflow/AudioEngineParkTest.cpp | 37 ----------- tests/Dataflow/TelemetryTest.cpp | 11 ++-- 15 files changed, 210 insertions(+), 107 deletions(-) create mode 100644 tests/Dataflow/AudioEngineBetweenTicksTest.cpp delete mode 100644 tests/Dataflow/AudioEngineParkTest.cpp diff --git a/src/ossia/audio/audio_engine.cpp b/src/ossia/audio/audio_engine.cpp index 963f4c59ca3..8264508cf5d 100644 --- a/src/ossia/audio/audio_engine.cpp +++ b/src/ossia/audio/audio_engine.cpp @@ -10,6 +10,8 @@ #include #include +#include +#include #include namespace ossia @@ -87,20 +89,40 @@ void audio_engine::sync() } } -void audio_engine::run_parked(const std::function& f) +void audio_engine::run_between_ticks(const std::function& f) { - if(stop_processing || !running()) + if(!running()) { f(); return; } - // Past sync(), the callback that may have been running has returned and the - // next ones skip their tick until stop_processing is lowered. - stop_processing = true; - sync(); - f(); - stop_processing = false; + m_betweenDone.store(false, std::memory_order_relaxed); + m_between.store(&f, std::memory_order_release); + + int buffer_ms = 8; + if(effective_sample_rate > 0) + buffer_ms = std::max( + 1, int(std::ceil(effective_buffer_size * 1000. / effective_sample_rate))); + + // A few buffers' worth: a callback that does not come by then is not coming. + const auto deadline + = std::chrono::steady_clock::now() + std::chrono::milliseconds(8 * buffer_ms + 50); + while(!m_betweenDone.load(std::memory_order_acquire)) + { + if(std::chrono::steady_clock::now() > deadline) + { + // Taken back before the callback took it: it is safe to run it here. + if(m_between.exchange(nullptr, std::memory_order_acq_rel) == &f) + { + ossia::logger().warn("Audio engine: no callback, running the change directly"); + f(); + return; + } + // The callback took it and is running it: it will be done shortly. + } + std::this_thread::sleep_for(std::chrono::microseconds(100)); + } } void audio_engine::gc() @@ -124,6 +146,12 @@ void audio_engine::set_tick(audio_engine::fun_type&& t) void audio_engine::load_audio_tick() { + if(auto f = m_between.exchange(nullptr, std::memory_order_acq_rel)) + { + (*f)(); + m_betweenDone.store(true, std::memory_order_release); + } + fun_type tick; while(tick_funlist.try_dequeue(tick)) { diff --git a/src/ossia/audio/audio_engine.hpp b/src/ossia/audio/audio_engine.hpp index abc46d61be7..735566eca56 100644 --- a/src/ossia/audio/audio_engine.hpp +++ b/src/ossia/audio/audio_engine.hpp @@ -25,9 +25,13 @@ class OSSIA_EXPORT audio_engine void gc(); void sync(); - //! Runs f while the audio callback skips its tick, as it does when stopped: - //! f may change what the tick reads, such as the audio protocol's ports. - void run_parked(const std::function& f); + //! Runs f on the audio thread at the start of the next callback, before + //! its tick, and returns once it ran: f may change what the tick reads, such + //! as the audio protocol's ports. The audio does not stop; the caller waits + //! for about one buffer. f must neither allocate nor free, nor block. + //! When no callback comes, f runs on the calling thread. Never call it from + //! the audio thread. + void run_between_ticks(const std::function& f); using fun_type = smallfun::function; void set_tick(fun_type&& t); @@ -57,6 +61,10 @@ class OSSIA_EXPORT audio_engine std::shared_ptr self; std::atomic_int64_t request{-1}; std::atomic_int64_t reply{-1}; + + // What run_between_ticks hands to the callback, and whether it ran. + std::atomic*> m_between{}; + std::atomic_bool m_betweenDone{}; }; OSSIA_EXPORT diff --git a/src/ossia/audio/audio_parameter.cpp b/src/ossia/audio/audio_parameter.cpp index 02cbd8f35d0..2e696b13568 100644 --- a/src/ossia/audio/audio_parameter.cpp +++ b/src/ossia/audio/audio_parameter.cpp @@ -157,6 +157,18 @@ virtual_audio_parameter::virtual_audio_parameter(int num_channels, net::node_bas proto.register_parameter(*this); } +auto virtual_audio_parameter::make_channels(int num_channels) const -> channels +{ + const auto bs = m_audio_data.empty() ? std::size_t(512) : m_audio_data.front().size(); + channels c; + c.data.resize(std::max(num_channels, 0)); + for(auto& d : c.data) + d.resize(bs); + for(auto& d : c.data) + c.spans.push_back(d); + return c; +} + void virtual_audio_parameter::push_value(const audio_port& port) { const double g = gain(); diff --git a/src/ossia/audio/audio_parameter.hpp b/src/ossia/audio/audio_parameter.hpp index c9c42368aa8..b6cd841b152 100644 --- a/src/ossia/audio/audio_parameter.hpp +++ b/src/ossia/audio/audio_parameter.hpp @@ -75,6 +75,21 @@ class OSSIA_EXPORT virtual_audio_parameter final : public audio_parameter public: virtual_audio_parameter(int num_channels, ossia::net::node_base& n); + //! Buffers for another channel count, made off the audio thread. + struct channels + { + std::vector data; + ossia::small_vector, 8> spans; + }; + [[nodiscard]] channels make_channels(int num_channels) const; + //! Takes buffers made by make_channels, between two ticks; `c` gets the + //! previous ones, to be freed off the audio thread. + void swap_channels(channels& c) noexcept + { + std::swap(m_audio_data, c.data); + std::swap(audio, c.spans); + } + //! Changes the channel count; only while the audio callback does not run. void set_channels(int num_channels) { diff --git a/src/ossia/audio/audio_protocol.cpp b/src/ossia/audio/audio_protocol.cpp index a0cea2af225..8654fa1b367 100644 --- a/src/ossia/audio/audio_protocol.cpp +++ b/src/ossia/audio/audio_protocol.cpp @@ -152,7 +152,7 @@ void audio_protocol::advance_tick(std::size_t count) } } - for(auto in : in_mappings) + for(auto in : m_live.in_mappings) { for(auto& chan : in->audio) { @@ -174,7 +174,7 @@ void audio_protocol::advance_tick(std::size_t count) } } - for(auto out : out_mappings) + for(auto out : m_live.out_mappings) { for(auto& chan : out->audio) { @@ -226,6 +226,16 @@ void audio_protocol::set_device(ossia::net::device_base& dev) m_dev = &dev; } +namespace +{ +template +void erase_one(std::vector& v, T* p) +{ + if(auto it = ossia::find(v, p); it != v.end()) + v.erase(it); +} +} + void audio_protocol::register_parameter(mapped_audio_parameter& p) { if(p.is_output) @@ -237,34 +247,47 @@ void audio_protocol::register_parameter(mapped_audio_parameter& p) p.upstream = main_audio_in; in_mappings.push_back(&p); } + if(!m_deferred) + m_live = current_ports(); } void audio_protocol::unregister_parameter(mapped_audio_parameter& p) { - if(p.is_output) - { - auto it = ossia::find(out_mappings, &p); - if(it != out_mappings.end()) - out_mappings.erase(it); - } - else - { - auto it = ossia::find(in_mappings, &p); - if(it != in_mappings.end()) - in_mappings.erase(it); - } + // Wherever it is: its direction may have changed since it was registered. + erase_one(out_mappings, &p); + erase_one(in_mappings, &p); + if(!m_deferred) + m_live = current_ports(); } void audio_protocol::register_parameter(virtual_audio_parameter& p) { virtaudio.push_back(&p); + if(!m_deferred) + m_live = current_ports(); } void audio_protocol::unregister_parameter(virtual_audio_parameter& p) { - auto it = ossia::find(virtaudio, &p); - if(it != virtaudio.end()) - virtaudio.erase(it); + erase_one(virtaudio, &p); + if(!m_deferred) + m_live = current_ports(); +} + +void audio_protocol::defer_port_changes(bool b) +{ + m_deferred = b; + m_live = current_ports(); +} + +audio_protocol::ports audio_protocol::current_ports() const +{ + return {in_mappings, out_mappings, virtaudio}; +} + +void audio_protocol::swap_ports(ports& p) noexcept +{ + std::swap(m_live, p); } void audio_protocol::setup_buffers(ossia::audio_tick_state state) @@ -278,7 +301,7 @@ void audio_protocol::setup_buffers(ossia::audio_tick_state state) const std::span::size_type fc = state.frames; // Prepare virtual audio inputs - for(auto virt : virtaudio) + for(auto virt : m_live.virtaudio) { virt->set_buffer_size(state.frames); } @@ -290,7 +313,7 @@ void audio_protocol::setup_buffers(ossia::audio_tick_state state) audio_ins[i]->audio[0] = {state.inputs[i], fc}; } - for(auto mapped : in_mappings) + for(auto mapped : m_live.in_mappings) { mapped->audio.resize(mapped->mapping.size()); for(std::size_t i = 0; i < mapped->mapping.size(); i++) @@ -318,7 +341,7 @@ void audio_protocol::setup_buffers(ossia::audio_tick_state state) audio_outs[i]->audio[0] = {state.outputs[i], fc}; } - for(auto mapped : out_mappings) + for(auto mapped : m_live.out_mappings) { mapped->audio.resize(mapped->mapping.size()); for(std::size_t i = 0; i < mapped->mapping.size(); i++) diff --git a/src/ossia/audio/audio_protocol.hpp b/src/ossia/audio/audio_protocol.hpp index d16c843517b..0d36f396151 100644 --- a/src/ossia/audio/audio_protocol.hpp +++ b/src/ossia/audio/audio_protocol.hpp @@ -42,12 +42,31 @@ class OSSIA_EXPORT audio_protocol final : public ossia::net::protocol_base void register_parameter(virtual_audio_parameter& p); void unregister_parameter(virtual_audio_parameter& p); + //! The mapped and virtual ports that the audio callback walks. + struct ports + { + std::vector in_mappings; + std::vector out_mappings; + std::vector virtaudio; + }; + + //! By default the callback walks the lists below as they are changed. Once + //! deferred, it walks its own copy, which only changes through swap_ports: + //! the lists can then change while the audio runs. + void defer_port_changes(bool b); + //! A copy of the lists below, made off the audio thread. + [[nodiscard]] ports current_ports() const; + //! Hands the lists to the callback: call it between two ticks. `p` gets the + //! previous ones, to be freed off the audio thread. + void swap_ports(ports& p) noexcept; + [[nodiscard]] ossia::net::device_base& get_device() const { return *m_dev; } ossia::audio_parameter* main_audio_in{}; ossia::audio_parameter* main_audio_out{}; std::vector audio_ins; std::vector audio_outs; + //! The mapped and virtual ports, as the interface sees them. std::vector in_mappings; std::vector out_mappings; std::vector virtaudio; @@ -56,6 +75,8 @@ class OSSIA_EXPORT audio_protocol final : public ossia::net::protocol_base ossia::net::device_base* m_dev{}; private: + ports m_live; + bool m_deferred{}; float m_main_gain{1.f}; }; diff --git a/src/ossia/dataflow/execution/execution_policy.hpp b/src/ossia/dataflow/execution/execution_policy.hpp index 6d0e5e342f5..43bc5cb5a2d 100644 --- a/src/ossia/dataflow/execution/execution_policy.hpp +++ b/src/ossia/dataflow/execution/execution_policy.hpp @@ -43,8 +43,5 @@ struct OSSIA_TEST_EXPORT execution_state_policy virtual void insert(ossia::net::parameter_base& dest, const ossia::value_port& v) = 0; virtual void insert(ossia::audio_parameter& dest, const audio_port& v) = 0; virtual void insert(ossia::net::midi::midi_parameter& dest, const midi_port& v) = 0; - - //! Drops everything kept for a parameter that is about to be destroyed. - virtual void forget(const net::parameter_base& p) noexcept { } }; } diff --git a/src/ossia/dataflow/execution/local_state_execution_policy.cpp b/src/ossia/dataflow/execution/local_state_execution_policy.cpp index b5eabbb26ff..d63fa4ac732 100644 --- a/src/ossia/dataflow/execution/local_state_execution_policy.cpp +++ b/src/ossia/dataflow/execution/local_state_execution_policy.cpp @@ -46,7 +46,10 @@ void local_state_execution_policy::commit_common() // Why not just push to the audio address for(auto& elt : m_audioState) { - assert(elt.first); + // An entry stays once its parameter was written to, and the parameter may + // be gone since: only the ones written during this tick are touched. + if(elt.second.get().empty()) + continue; elt.first->push_value(elt.second); for(auto& vec : elt.second.get()) @@ -341,24 +344,6 @@ bool local_state_execution_policy::in_local_scope(net::parameter_base& other) co || is_in(other, m_midiState)); } -void local_state_execution_policy::forget(const net::parameter_base& p) noexcept -{ - auto param = const_cast(&p); - m_valueState.erase(param); - m_midiState.erase(param); -#if defined(OSSIA_PROTOCOL_AUDIO) - if(auto audio = dynamic_cast(param)) - { - if(auto it = m_audioState.find(audio); it != m_audioState.end()) - { - for(auto& vec : it->second.get()) - ossia::audio_buffer_pool::instance().release(std::move(vec)); - m_audioState.erase(it); - } - } -#endif -} - void local_state_execution_policy::clear_local_state() { m_msgIndex = 0; diff --git a/src/ossia/dataflow/execution/local_state_execution_policy.hpp b/src/ossia/dataflow/execution/local_state_execution_policy.hpp index 6dddaabdded..3db6e68a1c1 100644 --- a/src/ossia/dataflow/execution/local_state_execution_policy.hpp +++ b/src/ossia/dataflow/execution/local_state_execution_policy.hpp @@ -21,7 +21,6 @@ struct OSSIA_TEST_EXPORT local_state_execution_policy : execution_state_policy bool find_and_copy(net::parameter_base& addr, inlet& in) override; void clear_local_state() override; - void forget(const net::parameter_base& p) noexcept override; void commit_common(); diff --git a/src/ossia/dataflow/execution_state.cpp b/src/ossia/dataflow/execution_state.cpp index 7b3e378f5d4..5bff0d66cb8 100644 --- a/src/ossia/dataflow/execution_state.cpp +++ b/src/ossia/dataflow/execution_state.cpp @@ -477,12 +477,6 @@ void execution_state::insert( { m_policy->insert(dest, std::move(v)); } -void execution_state::forget(const ossia::net::parameter_base& p) noexcept -{ - if(m_policy) - m_policy->forget(p); -} - void execution_state::insert(ossia::audio_parameter& dest, const ossia::audio_port& v) { m_policy->insert(dest, std::move(v)); diff --git a/src/ossia/dataflow/execution_state.hpp b/src/ossia/dataflow/execution_state.hpp index 6fdb5fe3ae8..00c2644326f 100644 --- a/src/ossia/dataflow/execution_state.hpp +++ b/src/ossia/dataflow/execution_state.hpp @@ -97,10 +97,6 @@ struct OSSIA_EXPORT execution_state : public Nano::Observer void commit(); - //! Drops everything the execution kept for a parameter about to be - //! destroyed; only while the graph does not run. - void forget(const ossia::net::parameter_base& p) noexcept; - bool in_local_scope(ossia::net::parameter_base& other) const; int sampleRate{44100}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 46b4e808726..33e07d20b0b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -119,7 +119,7 @@ if(OSSIA_DATAFLOW) ossia_add_test(MainGainTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/MainGainTest.cpp") ossia_add_test(TelemetryTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/TelemetryTest.cpp") ossia_add_test(TripleBufferTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/TripleBufferTest.cpp") - ossia_add_test(AudioEngineParkTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/AudioEngineParkTest.cpp") + ossia_add_test(AudioEngineBetweenTicksTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/AudioEngineBetweenTicksTest.cpp") ossia_add_test(MidiNodeTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/MidiNodeTest.cpp") ossia_add_test(DataflowTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/DataflowTest.cpp") ossia_add_test(TickMethodTest "${CMAKE_CURRENT_SOURCE_DIR}/Dataflow/TickMethodTest.cpp") diff --git a/tests/Dataflow/AudioEngineBetweenTicksTest.cpp b/tests/Dataflow/AudioEngineBetweenTicksTest.cpp new file mode 100644 index 00000000000..c66a312ddde --- /dev/null +++ b/tests/Dataflow/AudioEngineBetweenTicksTest.cpp @@ -0,0 +1,61 @@ +// A change run between two ticks runs on the audio thread, without the audio +// missing a tick, and runs directly when the engine does not call back. + +#include + +#include + +#include + +#include +#include +#include + +namespace +{ +bool wait_for(std::atomic_int& ticks, int n) +{ + const int start = ticks; + for(int i = 0; i < 500 && ticks < start + n; i++) + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + return ticks - start >= n; +} +} + +TEST_CASE("A change runs between two ticks of a running engine", "[audio][engine]") +{ + ossia::dummy_engine engine{48000, 64, 0, 2}; + std::atomic_int ticks{}; + std::atomic_bool in_tick{}; + std::atomic_bool overlapped{}; + engine.set_tick([&](const ossia::audio_tick_state&) { + in_tick = true; + ticks++; + in_tick = false; + }); + REQUIRE(wait_for(ticks, 3)); + + for(int i = 0; i < 20; i++) + { + std::thread::id ran_on; + engine.run_between_ticks([&] { + overlapped = overlapped || in_tick; + ran_on = std::this_thread::get_id(); + }); + CHECK(ran_on != std::this_thread::get_id()); + } + CHECK(!overlapped); + + // The ticks kept coming meanwhile. + CHECK(wait_for(ticks, 3)); + engine.stop(); +} + +TEST_CASE("A change runs directly when the engine does not run", "[audio][engine]") +{ + ossia::dummy_engine engine{48000, 64, 0, 2}; + engine.stop(); + bool ran = false; + engine.run_between_ticks([&] { ran = true; }); + CHECK(ran); +} diff --git a/tests/Dataflow/AudioEngineParkTest.cpp b/tests/Dataflow/AudioEngineParkTest.cpp deleted file mode 100644 index 5bd5db236b4..00000000000 --- a/tests/Dataflow/AudioEngineParkTest.cpp +++ /dev/null @@ -1,37 +0,0 @@ -// While an engine is parked, its callback does not run the tick. - -#include - -#include - -#include - -#include -#include -#include - -TEST_CASE("A parked engine skips its tick until released", "[audio][engine]") -{ - ossia::dummy_engine engine{48000, 64, 0, 2}; - std::atomic_int ticks{}; - engine.set_tick([&](const ossia::audio_tick_state&) { ticks++; }); - - auto wait_for_ticks = [&](int n) { - const int start = ticks; - for(int i = 0; i < 500 && ticks < start + n; i++) - std::this_thread::sleep_for(std::chrono::milliseconds(2)); - return ticks - start >= n; - }; - REQUIRE(wait_for_ticks(3)); - - int during{}; - engine.run_parked([&] { - const int before = ticks; - std::this_thread::sleep_for(std::chrono::milliseconds(30)); - during = ticks - before; - }); - CHECK(during == 0); - - CHECK(wait_for_ticks(3)); - engine.stop(); -} diff --git a/tests/Dataflow/TelemetryTest.cpp b/tests/Dataflow/TelemetryTest.cpp index b2d104b18b4..b68c03dbfe4 100644 --- a/tests/Dataflow/TelemetryTest.cpp +++ b/tests/Dataflow/TelemetryTest.cpp @@ -313,10 +313,10 @@ TEST_CASE("A virtual port meters what the graph writes to it", "[telemetry][audi CHECK(tap.pending.ticks == 1); } -TEST_CASE("A forgotten parameter is not written to again", "[telemetry][audio]") +TEST_CASE("A parameter not written to during a tick is not touched", "[telemetry][audio]") { - // The execution keeps an entry per parameter it ever wrote to, and writes - // to it at every commit: a parameter about to be destroyed must be dropped. + // The execution keeps an entry per parameter it ever wrote to; a parameter + // may be destroyed since, so only the ones written this tick are pushed. ossia::audio_protocol* proto{}; ossia::net::generic_device dev{ [&] { @@ -341,10 +341,11 @@ TEST_CASE("A forgotten parameter is not written to again", "[telemetry][audio]") st.insert(param, port); st.commit(); CHECK(tap.pending.ticks == 1); + st.commit(); - CHECK(tap.pending.ticks == 2); + CHECK(tap.pending.ticks == 1); - st.forget(param); + st.insert(param, port); st.commit(); CHECK(tap.pending.ticks == 2); param.meter = nullptr; From 7e4f52c75962070f14338b82f3e6c8a295d02fa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Micha=C3=ABl=20Celerier?= Date: Wed, 23 Sep 2026 20:18:47 -0400 Subject: [PATCH 18/19] telemetry, triple_buffer, audio: fixes from review - triple_buffer: the trivially copyable case has the in-place interface too, so that tuples of floats (tuplet) keep working; publish_if_unread replaces a value only if nobody read it. - telemetry: a frame the interface reads while the next one is being made is no longer delivered twice: the new frame carries the unread levels only if it replaces an unread frame. Frames are written in place, and only the channels in use are copied. RMS is computed per channel, over the frames that channel was there for, in double precision. - audio_protocol: reset_main_gain_ramp, so that a run starts at the master gain instead of ramping from the one of the previous run. - faust: a mono generator with a signal plugged into its inlet makes one channel per incoming channel again; with nothing plugged in, one. - graph: perf_map is called bench, which it is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HKbSZft9tvQ2sm9KeSchMX --- src/ossia/audio/audio_protocol.cpp | 4 +- src/ossia/audio/audio_protocol.hpp | 6 + src/ossia/dataflow/graph/graph.cpp | 6 +- .../dataflow/graph/graph_parallel_impl.hpp | 10 +- .../dataflow/nodes/faust/faust_utils.hpp | 9 +- src/ossia/dataflow/telemetry.cpp | 184 ++++++++++++------ src/ossia/dataflow/telemetry.hpp | 15 +- src/ossia/detail/triple_buffer.hpp | 38 +++- tests/Dataflow/MainGainTest.cpp | 15 ++ tests/Dataflow/TelemetryTest.cpp | 10 + tests/Dataflow/TripleBufferTest.cpp | 33 ++++ 11 files changed, 254 insertions(+), 76 deletions(-) diff --git a/src/ossia/audio/audio_protocol.cpp b/src/ossia/audio/audio_protocol.cpp index 8654fa1b367..dcc84251bda 100644 --- a/src/ossia/audio/audio_protocol.cpp +++ b/src/ossia/audio/audio_protocol.cpp @@ -86,8 +86,10 @@ void audio_protocol::setup_tree(int inputs, int outputs) void audio_protocol::apply_main_gain(const audio_tick_state& state) noexcept { - const float start = m_main_gain; const float target = main_audio_out ? main_audio_out->gain() : 1.f; + if(m_main_gain_reset.exchange(false, std::memory_order_relaxed)) + m_main_gain = target; + const float start = m_main_gain; m_main_gain = target; const auto frames = state.frames; diff --git a/src/ossia/audio/audio_protocol.hpp b/src/ossia/audio/audio_protocol.hpp index 0d36f396151..54b1485702d 100644 --- a/src/ossia/audio/audio_protocol.hpp +++ b/src/ossia/audio/audio_protocol.hpp @@ -3,6 +3,8 @@ #include #include +#include + namespace ossia { class audio_parameter; @@ -26,6 +28,9 @@ class OSSIA_EXPORT audio_protocol final : public ossia::net::protocol_base // tick has been summed into them: /out/main is the master, whichever address // a signal was sent to. A gain change ramps over the buffer. void apply_main_gain(const audio_tick_state& state) noexcept; + //! The next apply_main_gain starts at the gain of /out/main instead of + //! ramping from the one of its previous call, which may be long ago. + void reset_main_gain_ramp() noexcept { m_main_gain_reset = true; } bool pull(ossia::net::parameter_base&) override; bool push(const ossia::net::parameter_base&, const ossia::value& v) override; @@ -78,6 +83,7 @@ class OSSIA_EXPORT audio_protocol final : public ossia::net::protocol_base ports m_live; bool m_deferred{}; float m_main_gain{1.f}; + std::atomic_bool m_main_gain_reset{true}; }; } diff --git a/src/ossia/dataflow/graph/graph.cpp b/src/ossia/dataflow/graph/graph.cpp index 7ab2dea38ef..113da125cf7 100644 --- a/src/ossia/dataflow/graph/graph.cpp +++ b/src/ossia/dataflow/graph/graph.cpp @@ -176,7 +176,7 @@ make_graph_par_impl(const ossia::graph_setup_options& opt) auto g = std::make_shared(opt); g->update_fun.logger = opt.log; - g->update_fun.perf_map = opt.bench; + g->update_fun.bench = opt.bench; return g; } @@ -188,7 +188,7 @@ make_graph_par_impl(const ossia::graph_setup_options& opt) auto g = std::make_shared(opt); g->update_fun.logger = opt.log; - g->update_fun.perf_map = opt.bench; + g->update_fun.bench = opt.bench; return g; } @@ -200,7 +200,7 @@ make_graph_par_impl(const ossia::graph_setup_options& opt) auto g = std::make_shared(opt); g->update_fun.logger = opt.log; - g->update_fun.perf_map = opt.bench; + g->update_fun.bench = opt.bench; return g; } diff --git a/src/ossia/dataflow/graph/graph_parallel_impl.hpp b/src/ossia/dataflow/graph/graph_parallel_impl.hpp index 9902e8f3b78..978bb004d68 100644 --- a/src/ossia/dataflow/graph/graph_parallel_impl.hpp +++ b/src/ossia/dataflow/graph/graph_parallel_impl.hpp @@ -204,7 +204,7 @@ struct custom_parallel_update { public: std::shared_ptr logger; - std::shared_ptr perf_map; + std::shared_ptr bench; template custom_parallel_update(Graph_T& g, const ossia::graph_setup_options& opt) @@ -223,10 +223,10 @@ struct custom_parallel_update if(logger) { - if(perf_map) + if(bench) { executor.set_task_executor( - node_exec_logger_bench{cur_state, *perf_map, *logger}); + node_exec_logger_bench{cur_state, *bench, *logger}); for(auto node : topo_order) { flow_nodes[node] = flow_graph.emplace(*node); @@ -241,9 +241,9 @@ struct custom_parallel_update } } } - else if(perf_map) + else if(bench) { - executor.set_task_executor(node_exec_bench{cur_state, *perf_map}); + executor.set_task_executor(node_exec_bench{cur_state, *bench}); for(auto node : topo_order) { flow_nodes[node] = flow_graph.emplace(*node); diff --git a/src/ossia/dataflow/nodes/faust/faust_utils.hpp b/src/ossia/dataflow/nodes/faust/faust_utils.hpp index 921a2a795f7..7e4553a4296 100644 --- a/src/ossia/dataflow/nodes/faust/faust_utils.hpp +++ b/src/ossia/dataflow/nodes/faust/faust_utils.hpp @@ -479,9 +479,12 @@ struct faust_node_utils = self.root_outputs()[0]->template cast(); const int64_t n_in = audio_in.channels(); - // One clone per incoming channel; a generator has none to follow and - // makes one. - const int64_t n_chans = dsp.getNumInputs() == 0 ? 1 : n_in; + // One clone per incoming channel; a generator with nothing plugged in has + // none to follow and makes one. + const auto& inlet = *self.root_inputs()[0]; + const bool plugged = !inlet.sources.empty() || bool(inlet.address); + const int64_t n_chans + = dsp.getNumInputs() == 0 ? (plugged ? std::max(1, n_in) : 1) : n_in; audio_out.set_channels(n_chans); while(self.clones.size() < n_chans) { diff --git a/src/ossia/dataflow/telemetry.cpp b/src/ossia/dataflow/telemetry.cpp index 0b77a302768..80d4ec40963 100644 --- a/src/ossia/dataflow/telemetry.cpp +++ b/src/ossia/dataflow/telemetry.cpp @@ -9,34 +9,64 @@ void meter_levels::clear() noexcept { const std::size_t n = std::min(channels, max_meter_channels); std::fill_n(peak.begin(), n, 0.f); - std::fill_n(sum_squares.begin(), n, 0.f); + std::fill_n(sum_squares.begin(), n, 0.); + std::fill_n(channel_frames.begin(), n, 0u); clipped.fill(0); channels = 0; ticks = 0; frames = 0; } -void meter_levels::merge(const meter_levels& other) noexcept +namespace +{ +// A channel that appears mid-way starts from silence. +void grow_to(meter_levels& m, std::size_t chans) noexcept { - const std::size_t n = std::min( - std::max(channels, other.channels), max_meter_channels); - for(std::size_t c = channels; c < n; c++) + chans = std::min(chans, max_meter_channels); + for(std::size_t c = m.channels; c < chans; c++) { - peak[c] = 0.f; - sum_squares[c] = 0.f; + m.peak[c] = 0.f; + m.sum_squares[c] = 0.; + m.channel_frames[c] = 0; } - for(std::size_t c = 0; c < std::min(other.channels, n); c++) + m.channels = std::max(m.channels, chans); +} + +uint32_t saturating_add(uint32_t a, uint64_t b) noexcept +{ + const uint64_t r = uint64_t(a) + b; + return r > 0xFFFFFFFFu ? 0xFFFFFFFFu : uint32_t(r); +} +} + +void meter_levels::merge(const meter_levels& other) noexcept +{ + grow_to(*this, other.channels); + for(std::size_t c = 0; c < std::min(other.channels, max_meter_channels); + c++) { peak[c] = std::max(peak[c], other.peak[c]); sum_squares[c] += other.sum_squares[c]; + channel_frames[c] = saturating_add(channel_frames[c], other.channel_frames[c]); } for(std::size_t i = 0; i < clipped.size(); i++) clipped[i] |= other.clipped[i]; - channels = n; ticks += other.ticks; frames += other.frames; } +void meter_levels::assign(const meter_levels& other) noexcept +{ + const std::size_t n = std::min(other.channels, max_meter_channels); + channels = other.channels; + ticks = other.ticks; + frames = other.frames; + std::copy_n(other.peak.begin(), n, peak.begin()); + std::copy_n(other.sum_squares.begin(), n, sum_squares.begin()); + std::copy_n(other.channel_frames.begin(), n, channel_frames.begin()); + clipped = other.clipped; +} + namespace { template @@ -44,32 +74,21 @@ void accumulate_channel( meter_levels& m, std::size_t c, const Sample* samples, std::size_t n) noexcept { float p = m.peak[c]; - float s = 0.f; + double s = 0.; bool clip = false; for(std::size_t i = 0; i < n; i++) { const float x = std::abs(float(samples[i])); p = std::max(p, x); - s += x * x; + s += double(x) * x; clip |= x >= 1.f; } m.peak[c] = p; m.sum_squares[c] += s; + m.channel_frames[c] = saturating_add(m.channel_frames[c], n); if(clip) m.clipped[c / 32] |= 1u << (c % 32); } - -// A channel that appears mid-way starts from silence. -void grow_to(meter_levels& m, std::size_t chans) noexcept -{ - chans = std::min(chans, max_meter_channels); - for(std::size_t c = m.channels; c < chans; c++) - { - m.peak[c] = 0.f; - m.sum_squares[c] = 0.f; - } - m.channels = std::max(m.channels, chans); -} } void meter_levels::accumulate(const ossia::audio_vector& chans) noexcept @@ -97,9 +116,9 @@ void meter_levels::accumulate( float meter_levels::rms(std::size_t chan) const noexcept { - if(chan >= channels || frames == 0) + if(chan >= channels || chan >= max_meter_channels || channel_frames[chan] == 0) return 0.f; - return std::sqrt(sum_squares[chan] / float(frames)); + return float(std::sqrt(sum_squares[chan] / double(channel_frames[chan]))); } namespace @@ -119,10 +138,11 @@ arena::arena( std::size_t playhead_capacity) : m_sources(meter_capacity) , m_unread(meter_capacity) + , m_delta(meter_capacity) , m_benchSources(bench_capacity) , m_benchUnread(bench_capacity) + , m_benchDelta(bench_capacity) , m_playheadSources(playhead_capacity) - , m_staging{make_frame(meter_capacity, bench_capacity, playhead_capacity)} , m_buffer{make_frame(meter_capacity, bench_capacity, playhead_capacity)} , m_read{make_frame(meter_capacity, bench_capacity, playhead_capacity)} { @@ -196,57 +216,103 @@ void arena::tick(std::size_t frames, int sample_rate) noexcept publish(); } -void arena::publish() noexcept +void arena::stage(bool with_unread) noexcept { - // A frame still waiting in the buffer has not been read: fold what came - // since into it rather than replace it. - const bool unread = m_buffer.has_new_data(); - - m_unreadFrames = (unread ? m_unreadFrames : 0) + m_frames_since_publish; - m_frames_since_publish = 0; - + auto& f = m_buffer.write_buffer(); for(std::size_t i = 0; i < m_benchSources.size(); i++) { - auto& src = m_benchSources[i]; - auto& acc = m_benchUnread[i].levels; - if(!unread) - acc.clear(); - if(src.tap) - { - acc.merge(src.tap->pending); - src.tap->pending.clear(); - } - m_staging.benches[i] = m_benchUnread[i]; + auto& slot = f.benches[i]; + slot.generation = m_benchUnread[i].generation; + slot.levels = with_unread ? m_benchUnread[i].levels : bench_levels{}; + slot.levels.merge(m_benchDelta[i]); } for(std::size_t i = 0; i < m_sources.size(); i++) { - auto& src = m_sources[i]; - auto& acc = m_unread[i].levels; - if(!unread) - acc.clear(); - if(src.tap) + auto& slot = f.meters[i]; + slot.generation = m_unread[i].generation; + if(with_unread) { - acc.merge(src.tap->pending); - src.tap->pending.clear(); + slot.levels.assign(m_unread[i].levels); + slot.levels.merge(m_delta[i]); + } + else + { + slot.levels.assign(m_delta[i]); } - m_staging.meters[i].generation = m_unread[i].generation; - m_staging.meters[i].levels = acc; } for(std::size_t i = 0; i < m_playheadSources.size(); i++) { const auto& src = m_playheadSources[i]; - auto& slot = m_staging.playheads[i]; + auto& slot = f.playheads[i]; slot.generation = src.generation; slot.date = src.tap ? src.tap->date : 0; slot.running = src.tap && src.tap->running; } - m_staging.publish_seq = ++m_seq; - m_staging.frames = m_frames; - m_staging.window_frames = m_unreadFrames; - m_staging.sample_rate = m_sample_rate; - m_buffer.produce(m_staging); + f.publish_seq = ++m_seq; + f.frames = m_frames; + f.window_frames = (with_unread ? m_unreadFrames : 0) + m_frames_since_publish; + f.sample_rate = m_sample_rate; +} + +void arena::publish() noexcept +{ + // What came since the previous publication. + for(std::size_t i = 0; i < m_benchSources.size(); i++) + { + auto& d = m_benchDelta[i]; + d.clear(); + if(auto& tap = m_benchSources[i].tap) + { + d = tap->pending; + tap->pending.clear(); + } + } + for(std::size_t i = 0; i < m_sources.size(); i++) + { + auto& d = m_delta[i]; + d.clear(); + if(auto& tap = m_sources[i].tap) + { + d.assign(tap->pending); + tap->pending.clear(); + } + } + + // A frame still waiting has not been read: this one replaces it, so it + // carries what it had too. Whether it was read is only known for sure when + // replacing it; if the interface took it meanwhile, only the new part goes. + bool folded = false; + if(m_buffer.has_new_data()) + { + stage(true); + folded = m_buffer.publish_if_unread(); + } + if(!folded) + { + stage(false); + m_buffer.publish(); + } + + // What the interface has not read yet, as of this frame. + for(std::size_t i = 0; i < m_sources.size(); i++) + { + auto& acc = m_unread[i].levels; + if(folded) + acc.merge(m_delta[i]); + else + acc.assign(m_delta[i]); + } + for(std::size_t i = 0; i < m_benchSources.size(); i++) + { + auto& acc = m_benchUnread[i].levels; + if(!folded) + acc.clear(); + acc.merge(m_benchDelta[i]); + } + m_unreadFrames = (folded ? m_unreadFrames : 0) + m_frames_since_publish; + m_frames_since_publish = 0; } } diff --git a/src/ossia/dataflow/telemetry.hpp b/src/ossia/dataflow/telemetry.hpp index 5009c644db9..457d031ed76 100644 --- a/src/ossia/dataflow/telemetry.hpp +++ b/src/ossia/dataflow/telemetry.hpp @@ -36,11 +36,15 @@ struct OSSIA_EXPORT meter_levels uint32_t ticks{}; uint64_t frames{}; std::array peak{}; - std::array sum_squares{}; + //! Per channel, over the frames that channel was there for. + std::array sum_squares{}; + std::array channel_frames{}; std::array clipped{}; void clear() noexcept; void merge(const meter_levels& other) noexcept; + //! Copies what is in use of `other`: its channels, not the whole capacity. + void assign(const meter_levels& other) noexcept; void accumulate(const ossia::audio_vector& chans) noexcept; void accumulate(const float* const* chans, int count, std::size_t frames) noexcept; @@ -202,6 +206,9 @@ class OSSIA_EXPORT arena private: void publish() noexcept; + //! Fills the buffer's write slot with what came since the previous + //! publication, plus what was not read yet if `with_unread`. + void stage(bool with_unread) noexcept; struct source { @@ -216,11 +223,14 @@ class OSSIA_EXPORT arena uint32_t generation{}; }; - // Audio thread only. + // Audio thread only. What came since the previous publication, and what the + // interface has not read yet besides. std::vector m_sources; std::vector m_unread; + std::vector m_delta; std::vector m_benchSources; std::vector m_benchUnread; + std::vector m_benchDelta; struct playhead_source { @@ -229,7 +239,6 @@ class OSSIA_EXPORT arena }; std::vector m_playheadSources; uint64_t m_unreadFrames{}; - frame m_staging; uint64_t m_frames{}; uint64_t m_frames_since_publish{}; uint64_t m_seq{}; diff --git a/src/ossia/detail/triple_buffer.hpp b/src/ossia/detail/triple_buffer.hpp index 3a3472f0026..04f974c624a 100644 --- a/src/ossia/detail/triple_buffer.hpp +++ b/src/ossia/detail/triple_buffer.hpp @@ -84,8 +84,8 @@ class triple_buffer } // The producer's slot, to be written in place and then published. It holds - // whatever was written into it two publications ago, so containers in it - // keep their capacity and assigning to them does not allocate. + // an older value, so containers in it keep the capacity they had and + // assigning to them does not allocate once every slot was written. T& write_buffer() noexcept { return m_buffers[m_write_idx].data; } void publish() noexcept @@ -95,6 +95,22 @@ class triple_buffer m_write_idx = old_mid & index_mask; } + // Publishes the write buffer only if the value published before it was not + // consumed yet, which it then replaces. Returns false, publishing nothing, + // when it was consumed: the producer can then publish something else. + bool publish_if_unread() noexcept + { + uint8_t expected = m_mid_state.load(std::memory_order_acquire); + if(!(expected & dirty_bit)) + return false; + const uint8_t desired = static_cast(m_write_idx | dirty_bit); + if(!m_mid_state.compare_exchange_strong( + expected, desired, std::memory_order_acq_rel, std::memory_order_acquire)) + return false; + m_write_idx = expected & index_mask; + return true; + } + // Makes the latest published value the one read_buffer() gives, if there // is a new one; the consumer reads it in place. bool consume() noexcept @@ -213,6 +229,24 @@ class triple_buffer // Always valid T read() const noexcept { return m_last_read; } + // The same in-place interface as the general case. + T& write_buffer() noexcept { return m_buffers[m_write_idx].data; } + + void publish() noexcept + { + const uint8_t new_mid = static_cast(m_write_idx | dirty_bit); + const uint8_t old_mid = m_mid_state.exchange(new_mid, std::memory_order_acq_rel); + m_write_idx = old_mid & index_mask; + } + + bool consume() noexcept + { + T v; + return consume(v); + } + + const T& read_buffer() const noexcept { return m_last_read; } + bool has_new_data() const noexcept { return m_mid_state.load(std::memory_order_acquire) & dirty_bit; diff --git a/tests/Dataflow/MainGainTest.cpp b/tests/Dataflow/MainGainTest.cpp index 9328b30ee64..17778bcce0f 100644 --- a/tests/Dataflow/MainGainTest.cpp +++ b/tests/Dataflow/MainGainTest.cpp @@ -116,6 +116,21 @@ TEST_CASE("A main gain change ramps over one buffer", "[audio][main][gain]") CHECK(s == Catch::Approx(0.)); } +TEST_CASE("A new run starts at the main gain, without a ramp", "[audio][main][gain]") +{ + Card c; + auto write = [&] { c.proto->main_audio_out->push_value(Card::constant(1, 1.)); }; + c.tick(write); + CHECK(c.out_buffers[0][0] == Catch::Approx(1.)); + + // Changed while nothing played, then a new run. + c.proto->main_audio_out->set_value(0.f); + c.proto->reset_main_gain_ramp(); + c.tick(write); + for(float s : c.out_buffers[0]) + CHECK(s == Catch::Approx(0.)); +} + TEST_CASE("/in/main scales every input read", "[audio][main][gain]") { ossia::audio_protocol* proto{}; diff --git a/tests/Dataflow/TelemetryTest.cpp b/tests/Dataflow/TelemetryTest.cpp index b68c03dbfe4..2c253fc4402 100644 --- a/tests/Dataflow/TelemetryTest.cpp +++ b/tests/Dataflow/TelemetryTest.cpp @@ -139,6 +139,16 @@ TEST_CASE("A signal that widens and narrows keeps its widest count", "[telemetry CHECK(f.slot().levels.peak[2] == Catch::Approx(0.3)); } +TEST_CASE("A channel that appears mid-way reads its own level", "[telemetry]") +{ + Fixture f; + f.tick(constant({0.5})); + f.tick(constant({0.5, 0.5})); + REQUIRE(f.a.consume()); + CHECK(f.slot().levels.rms(0) == Catch::Approx(0.5)); + CHECK(f.slot().levels.rms(1) == Catch::Approx(0.5)); +} + TEST_CASE("A tap that did not run reads as not running", "[telemetry]") { Fixture f; diff --git a/tests/Dataflow/TripleBufferTest.cpp b/tests/Dataflow/TripleBufferTest.cpp index 0527051c88e..90da18cad07 100644 --- a/tests/Dataflow/TripleBufferTest.cpp +++ b/tests/Dataflow/TripleBufferTest.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -63,3 +64,35 @@ TEST_CASE("Writing in place stops allocating once every slot is warm", "[triple_ seen.erase(std::unique(seen.begin(), seen.end()), seen.end()); CHECK(seen.size() <= 3); } + +TEST_CASE("A trivially copyable value is written and read in place too", "[triple_buffer]") +{ + ossia::triple_buffer> b; + CHECK(!b.consume()); + b.write_buffer() = {1.f, 2.f, 3.f, 4.f}; + b.publish(); + b.write_buffer() = {5.f, 6.f, 7.f, 8.f}; + b.publish(); + REQUIRE(b.consume()); + CHECK(b.read_buffer()[0] == 5.f); + CHECK(b.read_buffer()[3] == 8.f); + CHECK(!b.consume()); + CHECK(b.read_buffer()[1] == 6.f); +} + +TEST_CASE("A value replaces the previous one only if it was not read", "[triple_buffer]") +{ + ossia::triple_buffer b; + write(b, 1.f, 1); + + // Not read yet: replaced. + std::get<0>(b.write_buffer()) = 2.f; + CHECK(b.publish_if_unread()); + REQUIRE(b.consume()); + CHECK(std::get<0>(b.read_buffer()) == 2.f); + + // Read: nothing is published. + std::get<0>(b.write_buffer()) = 3.f; + CHECK(!b.publish_if_unread()); + CHECK(!b.consume()); +} From 51236ae45c7d5ac84996d12eb1eb1777fdf3a140 Mon Sep 17 00:00:00 2001 From: Edu Meneses Date: Mon, 12 Oct 2026 11:13:18 -0400 Subject: [PATCH 19/19] audio: /out/N and /in/N are the gains of their channel, whatever reaches it /out/N applied its gain to what was written to it, before the sum. Sound sent to /out/main or to a mapped port reached the channel without it, so a mixer's output fader did nothing to most of what its channel played. It is now applied with /out/main's, once the tick is summed (gain_stage::external), ramped over the buffer like the master. /in/N likewise only scaled what read /in/N. It now also trims its channel for /in/main and for the mapped input ports: audio_parameter::read_gain gives the gain of each channel a parameter reads, and the mixer meters with it. A mapped port's own gain still applies to what goes through that port only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lc7tYGHRn6TmaxMEfBTswW --- src/ossia/audio/audio_parameter.cpp | 29 +++++++++-- src/ossia/audio/audio_parameter.hpp | 9 +++- src/ossia/audio/audio_protocol.cpp | 67 ++++++++++++++++-------- src/ossia/audio/audio_protocol.hpp | 10 ++-- tests/Dataflow/MainGainTest.cpp | 81 +++++++++++++++++++++++++++-- 5 files changed, 163 insertions(+), 33 deletions(-) diff --git a/src/ossia/audio/audio_parameter.cpp b/src/ossia/audio/audio_parameter.cpp index 2e696b13568..25cabe1883c 100644 --- a/src/ossia/audio/audio_parameter.cpp +++ b/src/ossia/audio/audio_parameter.cpp @@ -34,14 +34,12 @@ void audio_parameter::clone_value(audio_vector& res_vec) const } } - double g = stage == gain_stage::pull ? gain() : 1.; - if(upstream) - g *= upstream->gain(); auto min_chan = std::min(res_vec.size(), audio.size()); for(std::size_t chan = 0; chan < min_chan; chan++) { auto& src = audio[chan]; auto& res = res_vec[chan]; + const double g = read_gain(chan); const std::size_t N = src.size(); @@ -53,6 +51,26 @@ void audio_parameter::clone_value(audio_vector& res_vec) const } } +int audio_parameter::hardware_channel(std::size_t chan) const noexcept +{ + return int(chan); +} + +double audio_parameter::read_gain(std::size_t chan) const noexcept +{ + double g = stage == gain_stage::pull ? gain() : 1.; + if(upstream) + g *= upstream->gain(); + if(hardware_inputs) + { + const int c = hardware_channel(chan); + if(c >= 0 && c < int(hardware_inputs->size())) + if(auto trim = (*hardware_inputs)[c]; trim && trim != this) + g *= trim->gain(); + } + return g; +} + void audio_parameter::push_value(const audio_port& port) { const double g = stage == gain_stage::push ? gain() : 1.; @@ -213,6 +231,11 @@ mapped_audio_parameter::mapped_audio_parameter( proto.register_parameter(*this); } +int mapped_audio_parameter::hardware_channel(std::size_t chan) const noexcept +{ + return !is_output && chan < mapping.size() ? mapping[chan] : -1; +} + mapped_audio_parameter::~mapped_audio_parameter() { auto& proto diff --git a/src/ossia/audio/audio_parameter.hpp b/src/ossia/audio/audio_parameter.hpp index b6cd841b152..165acbee6ba 100644 --- a/src/ossia/audio/audio_parameter.hpp +++ b/src/ossia/audio/audio_parameter.hpp @@ -8,6 +8,7 @@ #include #include +#include namespace ossia::telemetry { @@ -27,13 +28,17 @@ class OSSIA_EXPORT audio_parameter : public ossia::net::parameter_base { push, //!< on what the graph writes (outputs, virtual ports) pull, //!< on what the graph reads (inputs) - external, //!< by whoever owns the buffers, after the sum (/out/main) + external, //!< by whoever owns the buffers, after the sum (/out/main, /out/N) } stage{gain_stage::push}; //! A parameter whose gain also applies to this one: /in/main for the //! inputs. const audio_parameter* upstream{}; + const std::vector* hardware_inputs{}; + [[nodiscard]] virtual int hardware_channel(std::size_t chan) const noexcept; + [[nodiscard]] double read_gain(std::size_t chan) const noexcept; + explicit audio_parameter(ossia::net::node_base& n); //! Linear gain, readable from any thread. @@ -127,6 +132,8 @@ class OSSIA_EXPORT mapped_audio_parameter final : public audio_parameter bool is_output{false}; mapped_audio_parameter(bool output, audio_mapping m, ossia::net::node_base& n); + [[nodiscard]] int hardware_channel(std::size_t chan) const noexcept override; + virtual ~mapped_audio_parameter(); }; } diff --git a/src/ossia/audio/audio_protocol.cpp b/src/ossia/audio/audio_protocol.cpp index dcc84251bda..9bc42962685 100644 --- a/src/ossia/audio/audio_protocol.cpp +++ b/src/ossia/audio/audio_protocol.cpp @@ -3,6 +3,8 @@ #include #include +#include + namespace ossia { audio_protocol::audio_protocol() @@ -66,7 +68,11 @@ void audio_protocol::setup_tree(int inputs, int outputs) { audio_outs.push_back(ossia::net::find_parameter_or_create_node( root, "/out/" + std::to_string(i + 1))); + audio_outs.back()->stage = audio_parameter::gain_stage::external; } + m_out_gains.assign(outputs, 1.f); + m_main_gain_reset = true; + main_audio_in->hardware_inputs = &audio_ins; main_audio_in->audio.resize(inputs); for(int i = 0; i < inputs; i++) @@ -75,7 +81,12 @@ void audio_protocol::setup_tree(int inputs, int outputs) } for(auto p : in_mappings) + { p->upstream = main_audio_in; + p->hardware_inputs = &audio_ins; + } + for(auto p : out_mappings) + p->hardware_inputs = &audio_ins; main_audio_out->audio.resize(outputs); for(int i = 0; i < outputs; i++) @@ -84,39 +95,50 @@ void audio_protocol::setup_tree(int inputs, int outputs) } } -void audio_protocol::apply_main_gain(const audio_tick_state& state) noexcept +namespace +{ +void scale(float* out, std::size_t frames, float start, float target) noexcept { - const float target = main_audio_out ? main_audio_out->gain() : 1.f; - if(m_main_gain_reset.exchange(false, std::memory_order_relaxed)) - m_main_gain = target; - const float start = m_main_gain; - m_main_gain = target; - - const auto frames = state.frames; - if(frames == 0) - return; - if(start == target) { if(target == 1.f) return; - - for(int c = 0; c < state.n_out; c++) - { - float* out = state.outputs[c]; - for(std::size_t i = 0; i < frames; i++) - out[i] *= target; - } + for(std::size_t i = 0; i < frames; i++) + out[i] *= target; } else { const float step = (target - start) / float(frames); - for(int c = 0; c < state.n_out; c++) + for(std::size_t i = 0; i < frames; i++) + out[i] *= start + step * float(i + 1); + } +} +} + +void audio_protocol::apply_main_gain(const audio_tick_state& state) noexcept +{ + const float main = main_audio_out ? main_audio_out->gain() : 1.f; + const std::size_t channels = std::min(audio_outs.size(), m_out_gains.size()); + if(m_main_gain_reset.exchange(false, std::memory_order_relaxed)) + { + m_main_gain = main; + for(std::size_t c = 0; c < channels; c++) + m_out_gains[c] = audio_outs[c]->gain(); + } + const float main_start = std::exchange(m_main_gain, main); + + for(int c = 0; c < state.n_out; c++) + { + float start = main_start; + float target = main; + if(std::size_t(c) < channels) { - float* out = state.outputs[c]; - for(std::size_t i = 0; i < frames; i++) - out[i] *= start + step * float(i + 1); + const float g = audio_outs[c]->gain(); + start *= std::exchange(m_out_gains[c], g); + target *= g; } + if(state.frames > 0) + scale(state.outputs[c], state.frames, start, target); } } @@ -240,6 +262,7 @@ void erase_one(std::vector& v, T* p) void audio_protocol::register_parameter(mapped_audio_parameter& p) { + p.hardware_inputs = &audio_ins; if(p.is_output) { out_mappings.push_back(&p); diff --git a/src/ossia/audio/audio_protocol.hpp b/src/ossia/audio/audio_protocol.hpp index 54b1485702d..dac53b20a0e 100644 --- a/src/ossia/audio/audio_protocol.hpp +++ b/src/ossia/audio/audio_protocol.hpp @@ -24,11 +24,12 @@ class OSSIA_EXPORT audio_protocol final : public ossia::net::protocol_base // Drops every span pointing into the driver's buffers. void clear_buffers() noexcept; - // Scales every hardware output by the gain of /out/main, once the whole - // tick has been summed into them: /out/main is the master, whichever address - // a signal was sent to. A gain change ramps over the buffer. + // Scales every hardware output by the gain of /out/main and of its /out/N, + // once the whole tick has been summed into them: /out/main is the master and + // /out/N the channel, whichever address a signal was sent to. A gain change + // ramps over the buffer. void apply_main_gain(const audio_tick_state& state) noexcept; - //! The next apply_main_gain starts at the gain of /out/main instead of + //! The next apply_main_gain starts at the gains of /out/main and /out/N instead of //! ramping from the one of its previous call, which may be long ago. void reset_main_gain_ramp() noexcept { m_main_gain_reset = true; } @@ -83,6 +84,7 @@ class OSSIA_EXPORT audio_protocol final : public ossia::net::protocol_base ports m_live; bool m_deferred{}; float m_main_gain{1.f}; + std::vector m_out_gains; std::atomic_bool m_main_gain_reset{true}; }; diff --git a/tests/Dataflow/MainGainTest.cpp b/tests/Dataflow/MainGainTest.cpp index 17778bcce0f..66f7e82479f 100644 --- a/tests/Dataflow/MainGainTest.cpp +++ b/tests/Dataflow/MainGainTest.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -12,6 +13,7 @@ #include #include +#include #include namespace @@ -78,24 +80,52 @@ TEST_CASE("/out/main scales the root's output once", "[audio][main][gain]") CHECK(s == Catch::Approx(0.5)); } -TEST_CASE("/out/main scales what is sent straight to /out/N", "[audio][main][gain]") +TEST_CASE("/out/N scales everything its channel gets, after the sum", "[audio][main][gain]") { Card c; c.proto->main_audio_out->set_value(0.5f); c.proto->audio_outs[1]->set_value(0.5f); + auto& node = ossia::net::create_node(c.dev.get_root_node(), "/send"); + auto send = std::make_unique( + true, ossia::audio_mapping{1}, node); + auto& mapped = *send; + node.set_parameter(std::move(send)); + mapped.set_value(0.5f); + auto write = [&] { c.proto->main_audio_out->push_value(Card::constant(2, 1.)); c.proto->audio_outs[1]->push_value(Card::constant(1, 1.)); + mapped.push_value(Card::constant(1, 1.)); }; c.tick(write); c.tick(write); - // left: 1 * main; right: (1 + 1 * 0.5) * main for(float s : c.out_buffers[0]) CHECK(s == Catch::Approx(0.5)); for(float s : c.out_buffers[1]) - CHECK(s == Catch::Approx(0.75)); + CHECK(s == Catch::Approx((1. + 1. + 0.5) * 0.5 * 0.5)); +} + +TEST_CASE("A channel gain change ramps over one buffer", "[audio][main][gain]") +{ + Card c; + auto write = [&] { c.proto->main_audio_out->push_value(Card::constant(2, 1.)); }; + c.tick(write); + + c.proto->audio_outs[0]->set_value(0.f); + c.tick(write); + for(int i = 1; i < frames; i++) + CHECK(c.out_buffers[0][i] < c.out_buffers[0][i - 1]); + CHECK(c.out_buffers[0][frames - 1] == Catch::Approx(0.)); + for(float s : c.out_buffers[1]) + CHECK(s == Catch::Approx(1.)); + + c.proto->audio_outs[0]->set_value(1.f); + c.proto->reset_main_gain_ramp(); + c.tick(write); + for(float s : c.out_buffers[0]) + CHECK(s == Catch::Approx(1.)); } TEST_CASE("A main gain change ramps over one buffer", "[audio][main][gain]") @@ -163,6 +193,7 @@ TEST_CASE("/in/main scales every input read", "[audio][main][gain]") const auto main = read(*proto->main_audio_in); REQUIRE(main.size() == 2); CHECK(main[0][0] == Catch::Approx(0.5)); + CHECK(main[1][0] == Catch::Approx(0.25)); const auto first = read(*proto->audio_ins[0]); REQUIRE(first.size() == 1); @@ -171,3 +202,47 @@ TEST_CASE("/in/main scales every input read", "[audio][main][gain]") const auto second = read(*proto->audio_ins[1]); CHECK(second[0][0] == Catch::Approx(0.25)); } + +TEST_CASE("/in/N trims its channel for every reader", "[audio][main][gain]") +{ + ossia::audio_protocol* proto{}; + ossia::net::generic_device dev{ + [&] { + auto p = std::make_unique(); + proto = p.get(); + return p; + }(), + "audio"}; + proto->setup_tree(2, 0); + + auto& node = ossia::net::create_node(dev.get_root_node(), "/mic"); + auto mic = std::make_unique( + false, ossia::audio_mapping{1, 0, std::numeric_limits::max()}, node); + auto& mapped = *mic; + node.set_parameter(std::move(mic)); + + std::array l{}, r{}; + l.fill(1.f); + r.fill(1.f); + std::array inputs{l.data(), r.data()}; + proto->setup_buffers( + {.inputs = inputs.data(), .outputs = nullptr, .n_in = 2, .n_out = 0, + .frames = frames}); + + proto->audio_ins[1]->set_value(0.5f); + mapped.set_value(0.5f); + + ossia::audio_vector v; + mapped.clone_value(v); + REQUIRE(v.size() == 3); + CHECK(v[0][0] == Catch::Approx(0.25)); + CHECK(v[1][0] == Catch::Approx(0.5)); + CHECK(mapped.read_gain(2) == Catch::Approx(0.5)); + + proto->main_audio_in->set_value(0.5f); + CHECK(mapped.read_gain(0) == Catch::Approx(0.125)); + CHECK(proto->audio_ins[1]->read_gain(0) == Catch::Approx(0.25)); + + mapped.is_output = true; + CHECK(mapped.hardware_channel(0) == -1); +}