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_engine.cpp b/src/ossia/audio/audio_engine.cpp index 4611e93dcce..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,6 +89,42 @@ void audio_engine::sync() } } +void audio_engine::run_between_ticks(const std::function& f) +{ + if(!running()) + { + f(); + return; + } + + 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() { // try to make deallocations happen in the main thread as far as possible @@ -108,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 6484c975c5e..735566eca56 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,14 @@ class OSSIA_EXPORT audio_engine void gc(); void sync(); + //! 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); void load_audio_tick(); @@ -52,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 9d3f7222c6f..25cabe1883c 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 { @@ -36,6 +39,7 @@ void audio_parameter::clone_value(audio_vector& res_vec) const { auto& src = audio[chan]; auto& res = res_vec[chan]; + const double g = read_gain(chan); const std::size_t N = src.size(); @@ -43,12 +47,33 @@ 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; + } +} + +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.; auto min_chan = std::min(port.channels(), (std::size_t)audio.size()); for(std::size_t chan = 0; chan < min_chan; chan++) { @@ -57,7 +82,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 +108,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) @@ -153,8 +175,21 @@ 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(); auto min_chan = std::min(port.channels(), (std::size_t)audio.size()); for(std::size_t chan = 0; chan < min_chan; chan++) { @@ -164,9 +199,18 @@ 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); } } + + 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() @@ -182,10 +226,16 @@ 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); } +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 da338f6d1f7..165acbee6ba 100644 --- a/src/ossia/audio/audio_parameter.hpp +++ b/src/ossia/audio/audio_parameter.hpp @@ -7,6 +7,14 @@ #include #include +#include +#include + +namespace ossia::telemetry +{ +struct meter_tap; +} + namespace ossia { class OSSIA_EXPORT audio_parameter : public ossia::net::parameter_base @@ -14,10 +22,31 @@ 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, /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. + [[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 +67,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 @@ -48,6 +80,29 @@ 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) + { + 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(); @@ -61,6 +116,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(); }; @@ -72,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 651a7fcbbb7..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() @@ -53,16 +55,24 @@ 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; + audio_ins.back()->upstream = main_audio_in; } for(int i = 0; i < outputs; i++) { 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++) @@ -70,6 +80,14 @@ 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; + 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++) { @@ -77,6 +95,53 @@ void audio_protocol::setup_tree(int inputs, int outputs) } } +namespace +{ +void scale(float* out, std::size_t frames, float start, float target) noexcept +{ + if(start == target) + { + if(target == 1.f) + return; + for(std::size_t i = 0; i < frames; i++) + out[i] *= target; + } + else + { + const float step = (target - start) / float(frames); + 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) + { + 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); + } +} + void audio_protocol::advance_tick(std::size_t count) { if(main_audio_in) @@ -111,7 +176,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) { @@ -133,7 +198,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) { @@ -185,40 +250,69 @@ void audio_protocol::set_device(ossia::net::device_base& dev) m_dev = &dev; } -void audio_protocol::register_parameter(mapped_audio_parameter& p) +namespace { - if(p.is_output) - out_mappings.push_back(&p); - else - in_mappings.push_back(&p); +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::unregister_parameter(mapped_audio_parameter& p) +void audio_protocol::register_parameter(mapped_audio_parameter& p) { + p.hardware_inputs = &audio_ins; if(p.is_output) { - auto it = ossia::find(out_mappings, &p); - if(it != out_mappings.end()) - out_mappings.erase(it); + out_mappings.push_back(&p); } else { - auto it = ossia::find(in_mappings, &p); - if(it != in_mappings.end()) - in_mappings.erase(it); + 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) +{ + // 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) @@ -232,7 +326,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); } @@ -244,7 +338,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++) @@ -272,7 +366,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 9b907c3b14e..dac53b20a0e 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; @@ -22,6 +24,15 @@ 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 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 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; } + 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; @@ -37,18 +48,44 @@ 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; protected: ossia::net::device_base* m_dev{}; + +private: + 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/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; }; } 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/execution/local_state_execution_policy.cpp b/src/ossia/dataflow/execution/local_state_execution_policy.cpp index b233ca9a369..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()) diff --git a/src/ossia/dataflow/graph/graph.cpp b/src/ossia/dataflow/graph/graph.cpp index cd5b0d29cfe..113da125cf7 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 @@ -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_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..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,13 +223,12 @@ 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) { - (*perf_map)[node] = std::nullopt; flow_nodes[node] = flow_graph.emplace(*node); } } @@ -242,6 +241,14 @@ struct custom_parallel_update } } } + else if(bench) + { + executor.set_task_executor(node_exec_bench{cur_state, *bench}); + 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/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"; } diff --git a/src/ossia/dataflow/nodes/faust/faust_utils.hpp b/src/ossia/dataflow/nodes/faust/faust_utils.hpp index 2440f11a1ae..7e4553a4296 100644 --- a/src/ossia/dataflow/nodes/faust/faust_utils.hpp +++ b/src/ossia/dataflow/nodes/faust/faust_utils.hpp @@ -479,8 +479,14 @@ 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 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) { self.clones.emplace_back(dsp.clone(), self.clones[0]); } @@ -505,15 +511,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 +546,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); 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..e25f1da94ef 100644 --- a/src/ossia/dataflow/port.cpp +++ b/src/ossia/dataflow/port.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -262,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; @@ -276,119 +283,78 @@ 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()) + if(upmix != upmix_mode::none) { - case 0: - return; - - case 1: - process_audio_out_mono(*this); - break; - - default: - process_audio_out_general(*this); - break; + 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.); + } + } } -} - -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++) + // TODO pan inlet + const std::size_t C = data.channels(); + if(C == 0) { - o_ptr[sample] = i_ptr[sample] * g; + if(meter) + meter->pending.accumulate(data.get()); + return; } -} - -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.); + 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; - ensure_vector_sizes(i.get(), audio_out.data.get()); - - for(auto chan = 0U; chan < C; chan++) + for(std::size_t c = 0; c < C; c++) { - auto N = i.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 i_ptr = i.channel(chan).data(); - 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.) + auto& chan = data.channel(c); + const std::size_t N = chan.size(); + double* samples = chan.data(); + if(from == to) { - for(std::size_t sample = 0; sample < N; sample++) - { - o_ptr[sample] = i_ptr[sample]; - } + if(to == 1.) + continue; + for(std::size_t i = 0; i < N; i++) + samples[i] *= to; } - else + else if(N > 0) { - for(std::size_t sample = 0; sample < N; sample++) - { - o_ptr[sample] = i_ptr[sample] * vol; - } + const double step = (to - from) / double(N); + for(std::size_t i = 0; i < N; i++) + samples[i] *= from + step * double(i + 1); } } -} -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.) - return; - - const auto N = o.channel(0).size(); - const auto o_ptr = o.channel(0).data(); - - for(std::size_t sample = 0; sample < N; sample++) - { - o_ptr[sample] *= g; - } + if(meter) + meter->pending.accumulate(data.get()); } -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(); - - auto o_ptr = o.channel(chan).data(); +midi_inlet::~midi_inlet() = default; - const auto vol = audio_out.pan[chan] * g; - if(vol == 1.) - continue; - for(std::size_t sample = 0; sample < N; sample++) - { - o_ptr[sample] *= vol; - } - } -} +midi_outlet::~midi_outlet() = default; } diff --git a/src/ossia/dataflow/port.hpp b/src/ossia/dataflow/port.hpp index 6207e94f5df..06d5972b111 100644 --- a/src/ossia/dataflow/port.hpp +++ b/src/ossia/dataflow/port.hpp @@ -8,6 +8,10 @@ #include #include +#include +#include +#include + namespace ossia { struct OSSIA_EXPORT port @@ -150,6 +154,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; @@ -178,7 +187,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 @@ -244,19 +259,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); - -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(); } @@ -293,6 +295,19 @@ 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.}; pan_weight pan{1., 1.}; @@ -300,9 +315,24 @@ struct OSSIA_EXPORT audio_outlet : public ossia::outlet ossia::value_inlet pan_inlet; ossia::audio_port data; - bool has_gain{}; + + //! 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 + // 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/src/ossia/dataflow/telemetry.cpp b/src/ossia/dataflow/telemetry.cpp new file mode 100644 index 00000000000..80d4ec40963 --- /dev/null +++ b/src/ossia/dataflow/telemetry.cpp @@ -0,0 +1,318 @@ +#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.); + std::fill_n(channel_frames.begin(), n, 0u); + clipped.fill(0); + channels = 0; + ticks = 0; + frames = 0; +} + +namespace +{ +// 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.; + m.channel_frames[c] = 0; + } + 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]; + 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 +void accumulate_channel( + meter_levels& m, std::size_t c, const Sample* samples, std::size_t n) noexcept +{ + float p = m.peak[c]; + 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 += 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); +} +} + +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 || chan >= max_meter_channels || channel_frames[chan] == 0) + return 0.f; + return float(std::sqrt(sum_squares[chan] / double(channel_frames[chan]))); +} + +namespace +{ +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, + 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_buffer{make_frame(meter_capacity, bench_capacity, playhead_capacity)} + , m_read{make_frame(meter_capacity, bench_capacity, playhead_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::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::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) + { + 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::stage(bool with_unread) noexcept +{ + auto& f = m_buffer.write_buffer(); + for(std::size_t i = 0; i < m_benchSources.size(); 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& slot = f.meters[i]; + slot.generation = m_unread[i].generation; + if(with_unread) + { + slot.levels.assign(m_unread[i].levels); + slot.levels.merge(m_delta[i]); + } + else + { + slot.levels.assign(m_delta[i]); + } + } + + for(std::size_t i = 0; i < m_playheadSources.size(); i++) + { + const auto& src = m_playheadSources[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; + } + + 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 new file mode 100644 index 00000000000..457d031ed76 --- /dev/null +++ b/src/ossia/dataflow/telemetry.hpp @@ -0,0 +1,253 @@ +#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{}; + //! 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; + + [[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; +}; + +//! 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; +}; + +//! 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, + 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 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; + std::vector playheads; + + //! 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, std::size_t bench_capacity = 0, + std::size_t playhead_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(); + } + [[nodiscard]] std::size_t playhead_capacity() const noexcept + { + return m_playheadSources.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; + //! 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; + + //! 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; + //! 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 + { + std::shared_ptr tap; + uint32_t generation{}; + tap_kind kind{}; + }; + + struct bench_source + { + std::shared_ptr tap; + uint32_t generation{}; + }; + + // 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 + { + std::shared_ptr tap; + uint32_t generation{}; + }; + std::vector m_playheadSources; + uint64_t m_unreadFrames{}; + 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/detail/triple_buffer.hpp b/src/ossia/detail/triple_buffer.hpp index 4adc03b7bdb..04f974c624a 100644 --- a/src/ossia/detail/triple_buffer.hpp +++ b/src/ossia/detail/triple_buffer.hpp @@ -83,6 +83,48 @@ class triple_buffer m_write_idx = old_mid & index_mask; } + // The producer's slot, to be written in place and then published. It holds + // 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 + { + 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; + } + + // 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 + { + 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 @@ -187,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/src/ossia_sources.cmake b/src/ossia_sources.cmake index 8ce142c7b5e..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" @@ -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 98c097f0d4f..33e07d20b0b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -115,6 +115,11 @@ 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(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(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/IntervalGainTest.cpp b/tests/Dataflow/IntervalGainTest.cpp new file mode 100644 index 00000000000..dda42092c40 --- /dev/null +++ b/tests/Dataflow/IntervalGainTest.cpp @@ -0,0 +1,238 @@ +// The gain and pan of an interval's audio outlet scale what its parent receives, +// once. + +#include + +#include +#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::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, 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); + 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}); +} + +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); +} + +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); +} + +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()); +} diff --git a/tests/Dataflow/MainGainTest.cpp b/tests/Dataflow/MainGainTest.cpp new file mode 100644 index 00000000000..66f7e82479f --- /dev/null +++ b/tests/Dataflow/MainGainTest.cpp @@ -0,0 +1,248 @@ +// 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 +#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/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); + + for(float s : c.out_buffers[0]) + CHECK(s == Catch::Approx(0.5)); + for(float s : c.out_buffers[1]) + 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]") +{ + 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.)); +} + +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{}; + 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)); + CHECK(main[1][0] == Catch::Approx(0.25)); + + 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)); +} + +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); +} diff --git a/tests/Dataflow/TelemetryTest.cpp b/tests/Dataflow/TelemetryTest.cpp new file mode 100644 index 00000000000..2c253fc4402 --- /dev/null +++ b/tests/Dataflow/TelemetryTest.cpp @@ -0,0 +1,362 @@ +// 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 + +#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 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; + 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); +} + +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); +} + +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); +} + +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 parameter not written to during a tick is not touched", "[telemetry][audio]") +{ + // 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{ + [&] { + 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 == 1); + + st.insert(param, port); + st.commit(); + CHECK(tap.pending.ticks == 2); + param.meter = nullptr; +} diff --git a/tests/Dataflow/TripleBufferTest.cpp b/tests/Dataflow/TripleBufferTest.cpp new file mode 100644 index 00000000000..90da18cad07 --- /dev/null +++ b/tests/Dataflow/TripleBufferTest.cpp @@ -0,0 +1,98 @@ +// 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 +#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); +} + +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()); +}