Loading...
Searching...
No Matches
VelToNote.hpp
1#pragma once
2// SPDX-License-Identifier: GPL-3.0-or-later
3#include "VelToNoteCore.hpp"
4
5#include <Fx/Types.hpp>
6
7#include <ossia/dataflow/exec_state_facade.hpp>
8#include <ossia/dataflow/token_request.hpp>
9#include <ossia/dataflow/value_port.hpp>
10
11#include <halp/controls.hpp>
12#include <halp/meta.hpp>
13#include <libremidi/message.hpp>
14
15#include <cmath>
16
17#include <array>
18#include <cstdint>
19#include <limits>
20#include <optional>
21#include <vector>
22
23namespace Nodes::PulseToNote
24{
25namespace detail
26{
28{
29 std::optional<double> operator()() const noexcept { return std::nullopt; }
30 std::optional<double> operator()(int x) const noexcept { return x; }
31 std::optional<double> operator()(char x) const noexcept { return x; }
32 std::optional<double> operator()(float x) const noexcept { return x; }
33 template <typename T>
34 std::optional<double> operator()(const T&) const noexcept
35 {
36 return std::nullopt;
37 }
38};
39
41{
42 int base_pitch{}, base_velocity{};
43 std::optional<DecodedNote> operator()() const noexcept { return std::nullopt; }
44 std::optional<DecodedNote> operator()(ossia::impulse) const noexcept
45 {
46 return decode_pair(base_pitch, base_velocity, false);
47 }
48 std::optional<DecodedNote> operator()(int pitch) const noexcept
49 {
50 return decode_pair(pitch, base_velocity, false);
51 }
52 std::optional<DecodedNote> operator()(char pitch) const noexcept
53 {
54 return decode_pair(pitch, base_velocity, false);
55 }
56 std::optional<DecodedNote> operator()(float pitch) const noexcept
57 {
58 return decode_pair(pitch, base_velocity, false);
59 }
60 template <typename T>
61 std::optional<DecodedNote> operator()(const T&) const noexcept
62 {
63 return std::nullopt;
64 }
65 std::optional<DecodedNote>
66 operator()(const std::vector<ossia::value>& list) const noexcept
67 {
68 if(list.empty())
69 return operator()(ossia::impulse{});
70 const auto pitch = list[0].apply(NumericValue{});
71 if(!pitch)
72 return std::nullopt;
73 if(list.size() == 1)
74 return decode_pair(*pitch, base_velocity, false);
75 const auto velocity = list[1].apply(NumericValue{});
76 if(!velocity)
77 return std::nullopt;
78 // Extra elements are ignored, but NEVER change how the first pair is decoded.
79 return decode_pair(*pitch, *velocity, list[1].get_type() == ossia::val_type::FLOAT);
80 }
81 template <std::size_t N>
82 std::optional<DecodedNote>
83 operator()(const std::array<float, N>& vector) const noexcept
84 {
85 if constexpr(N >= 2)
86 return decode_pair(vector[0], vector[1], true);
87 else if constexpr(N == 1)
88 return decode_pair(vector[0], base_velocity, false);
89 else
90 return operator()(ossia::impulse{});
91 }
92};
93
94// Recover the chooser's exact notated durations from its float round-trip.
95// Without this, e.g. a quarter at 90 BPM becomes 1.00000003 quarters and the
96// conservative duration-to-sample ceil schedules its release one sample late.
97// These are the 22 score QGraphicsTimeChooser detents, expressed in quarters.
98// Arbitrary automated values outside float rounding error are NOT snapped.
99inline double canonical_quarters(double q) noexcept
100{
101 constexpr double notes[]{1. / 16, 1. / 12, 1. / 8, 1. / 6, 3. / 16, 1. / 4,
102 1. / 3, 3. / 8, 1. / 2, 2. / 3, 3. / 4, 1.,
103 4. / 3, 3. / 2, 2., 8. / 3, 3., 4.,
104 6., 8., 12., 16.};
105 if(!std::isfinite(q) || q <= 0.)
106 return q;
107 for(const auto n : notes)
108 if(std::abs(q - n) <= 4. * std::numeric_limits<float>::epsilon() * n)
109 return n;
110 return q;
111}
112
113// One duration bound off its own chooser. Each bound syncs independently, so
114// each carries its own unit. A synchronized chooser with no usable tempo
115// yields no bound rather than a wrong one.
116inline Bound bound_from_time_chooser(double value, bool sync, double tempo) noexcept
117{
118 if(!std::isfinite(value) || value <= 0.)
119 return {};
120 if(!sync)
121 return {value, DurationUnit::model_seconds};
122 if(!(std::isfinite(tempo) && tempo > 0.))
123 return {};
124 return {canonical_quarters(value * tempo / 60.), DurationUnit::quarters};
125}
126
127// The ossia binding passes free values through. In synchronized mode it gives
128// q * 60 / tempo, where q is the selected quarter-note duration. Recover q ONCE.
129inline Settings
130settings_from_time_chooser(Settings s, double value, bool sync, double tempo) noexcept
131{
132 s.duration_unit = sync ? DurationUnit::quarters : DurationUnit::model_seconds;
133 s.duration = sync ? (std::isfinite(tempo) && tempo > 0.
134 ? canonical_quarters(value * tempo / 60.)
135 : 0.)
136 : value;
137 return sanitize(s);
138}
139} // namespace detail
140
141struct Node
142{
143 halp_meta(name, "Pulse to Midi")
144 halp_meta(c_name, "VelToNote")
145 halp_meta(category, "Midi")
146 halp_meta(author, "ossia score")
147 halp_meta(
148 manual_url,
149 "https://ossia.io/score-docs/processes/midi-utilities.html#pulse-to-note")
150 halp_meta(uuid, "2c6493c3-5449-4e52-ae04-9aee3be5fb6a")
151 halp_meta(process_exec, true)
152 // Aggregate already-produced slices until the graph's begin_execution boundary.
153 // Pending notes and future releases remain exclusively in engine_.
154 halp_meta(local_midi_tick_batch, true)
155 halp_meta(local_midi_tick_batch_reserve, detail::Engine<>::max_output_events)
156 halp_meta(
157 description,
158 "Impulse: default pitch and velocity. Scalar number: pitch. "
159 "[pitch, integer velocity]: MIDI velocity; float velocity: normalized 0..1. "
160 "Zero velocity releases owned notes for that source pitch. "
161 "Starts may be quantized; ends use a grid or a duration. "
162 "In quantized mode, min duration extends to the next grid point and "
163 "max duration caps the hold exactly. "
164 "Tightness blends from immediate (0) to the next grid point (1).")
165
166 struct
167 {
168 // Existing port order is retained. New controls are appended, not inserted.
169 ossia_port<"in", ossia::value_port, true> port{};
170 quant_selector<"Start quant."> start_quant;
171 halp::hslider_f32<"Tightness", halp::range{0.f, 1.f, 0.8f}> tightness;
172 quant_selector<"End quant."> end_quant;
173 midi_spinbox<"Default pitch"> basenote;
174 midi_spinbox<"Default vel."> basevel;
175 octave_slider<"Pitch shift", -5, 5> shift_note;
176 octave_slider<"Pitch random", 0, 2> note_random;
177 octave_slider<"Vel. random", 0, 2> vel_random;
178 midi_channel<"Channel"> channel;
179 halp::toggle<"Fixed duration"> fixed_duration;
180 halp::time_chooser<"Duration", halp::range{0., 10., 0.}> duration;
181 halp::enum_t<detail::PitchDirection, "Pitch direction"> pitch_direction;
182 // Quantized mode only; zero disables. Appended, like the rest.
183 halp::time_chooser<"Min duration", halp::range{0., 10., 0.}> min_duration;
184 halp::time_chooser<"Max duration", halp::range{0., 10., 0.}> max_duration;
185 } inputs;
186 struct
187 {
188 midi_out midi;
189 } outputs;
190
191 // score's Crousti::Executor injects this member. A native token plus the
192 // facade gives the EXACT executed sample span, including partial callbacks.
193 // Avendish explicitly supports using ossia::token_request as T::tick.
194 using tick = ossia::token_request;
195 ossia::exec_state_facade ossia_state{};
196 struct Setup
197 {
198 int frames{};
199 double rate{};
200 std::uint64_t instance{};
201 };
202
203 void prepare(Setup setup)
204 {
205 // Reserve the outer message buffer off the audio thread. Allocation-free
206 // publication additionally requires inline short-message storage in
207 // libremidi and adequately reserved buffers in the host binding.
208 outputs.midi.midi_messages.reserve(detail::Engine<>::max_output_events);
209 if(rate_ != setup.rate)
210 engine_.request_reset();
211 rate_ = setup.rate;
212 if(!seeded_)
213 {
214 engine_.seed(setup.instance ^ 0xa35371f99772b987ULL);
215 seeded_ = true;
216 }
217 }
218
219 // Processing-thread hooks only: never write, retain, or read output messages.
220 void start() noexcept
221 {
222 running_ = true;
223 engine_.request_reset();
224 }
225 void stop() noexcept
226 {
227 running_ = false;
228 engine_.request_reset();
229 }
230 void pause() noexcept { stop(); }
231 void resume() noexcept { start(); }
232 template <typename Time>
233 void transport(Time) noexcept
234 {
235 engine_.request_reset();
236 }
237
238 [[nodiscard]] const detail::Statistics& statistics() const noexcept
239 {
240 return engine_.statistics();
241 }
242 [[nodiscard]] bool needs_service() const noexcept { return engine_.needs_service(); }
243
244 // A host with no normal tick after stop/removal must provide one final writable
245 // cleanup tick before destroying the node or its route. This is a TICK API,
246 // not an out-of-band write from stop(). No output survives as deferred state.
247 void cleanup_tick(const tick& tk) noexcept
248 {
249 outputs.midi.midi_messages.clear();
250 engine_.begin_input();
251 engine_.request_reset();
252 if(const auto span = execution_span(tk); span && span->length > 0)
253 {
254 auto sink = [this](const detail::MidiEvent& e) noexcept { emit(e); };
255 engine_.drain_releases(0, static_cast<int>(span->length), sink);
256 }
257 }
258
259 void operator()(const tick& tk) noexcept
260 {
261 // A stop may leave graph tokens already requested by the interval. They
262 // may publish cleanup, but cannot trigger again until start or resume.
263 if(!running_)
264 {
265 cleanup_tick(tk);
266 return;
267 }
268 // Output ports are scratch space for this invocation only. Host code may
269 // clear/move/destroy their message contents immediately after publication.
270 outputs.midi.midi_messages.clear();
271 engine_.begin_input();
272 const auto span = execution_span(tk);
273 if(!span)
274 {
275 engine_.request_reset();
276 return;
277 }
278
279 const auto total = ossia_state.samplesSinceStart();
280 const auto size = ossia_state.bufferSize();
281 // In score's buffer_tick and precise_score_tick, the counter has already
282 // advanced to the END of the current buffer when a dataflow node runs.
283 if(total < std::numeric_limits<std::int64_t>::min() + size
284 || total - size > std::numeric_limits<std::int64_t>::max() - span->start_sample)
285 {
286 engine_.request_reset();
287 return;
288 }
289 const detail::Block b{
290 .frames = static_cast<int>(span->length),
291 .first_frame = total - size + span->start_sample,
292 .quarters_begin = tk.musical_start_position,
293 .quarters_end = tk.musical_end_position,
294 .numerator = tk.signature.upper,
295 .denominator = tk.signature.lower,
296 .bar_begin = tk.musical_start_last_bar,
297 .bar_end = tk.musical_end_last_bar,
298 .last_signature = tk.musical_start_last_signature,
299 .model_begin = tk.prev_date.impl,
300 .model_end = tk.date.impl,
301 .discontinuity = tk.start_discontinuous,
302 .end_discontinuity = tk.end_discontinuous};
303
304 const detail::ValueDecoder decoder{inputs.basenote.value, inputs.basevel.value};
305 if(inputs.port.value && b.frames > 0)
306 {
307 const auto& data = inputs.port.value->get_data();
308 // Bounded scanning, including malformed and out-of-slice inputs. A
309 // discarded suffix could contain a release; fail closed on overload.
310 if(data.size() > detail::Engine<>::max_inputs)
311 engine_.input_overflow();
312 else
313 for(const auto& input : data)
314 {
315 if(input.timestamp < span->start_sample
316 || input.timestamp - span->start_sample >= span->length)
317 continue;
318 if(const auto note = input.value.apply(decoder))
319 if(!engine_.push(
320 static_cast<int>(input.timestamp - span->start_sample), *note))
321 break;
322 }
323 }
324 detail::Settings settings{
325 .start_quant = inputs.start_quant.value,
326 .tightness = inputs.tightness.value,
327 .end_quant = inputs.end_quant.value,
328 .end_mode = inputs.fixed_duration.value ? detail::EndMode::duration
329 : detail::EndMode::quantized,
330 .channel = inputs.channel.value,
331 .pitch_shift = inputs.shift_note.value,
332 .pitch_random = inputs.note_random.value,
333 .velocity_random = inputs.vel_random.value,
334 .pitch_direction = inputs.pitch_direction.value};
335 settings.min_duration = detail::bound_from_time_chooser(
336 inputs.min_duration.value, inputs.min_duration.sync, tk.tempo);
337 settings.max_duration = detail::bound_from_time_chooser(
338 inputs.max_duration.value, inputs.max_duration.sync, tk.tempo);
339 settings = detail::settings_from_time_chooser(
340 settings, inputs.duration.value, inputs.duration.sync, tk.tempo);
341 auto sink = [this](const detail::MidiEvent& e) noexcept { emit(e); };
342 engine_.process(b, settings, sink);
343 }
344
345 // Public data preserve aggregate reflection. Only these local members survive
346 // invocations; none is a pointer/reference/iterator into a message port.
347 detail::Engine<> engine_{};
348 bool running_{true};
349 double rate_{};
350 bool seeded_{};
351
352 std::optional<ossia::exec_state_facade::sample_timings>
353 execution_span(const tick& tk) const noexcept
354 {
355 if(!ossia_state.impl || ossia_state.bufferSize() < 0)
356 return std::nullopt;
357 const auto size = ossia_state.bufferSize();
358 // Do not rescale the full model interval into a host-clipped physical span.
359 // A malformed carried span is rejected, leaving releases local for retry.
360 if(tk.start_sample >= 0 && tk.length_sample >= 0
361 && (tk.start_sample > size || tk.length_sample > size - tk.start_sample))
362 return std::nullopt;
363 const auto span = ossia_state.timings(tk);
364 if(span.start_sample < 0 || span.length < 0 || span.start_sample > size
365 || span.length > size - span.start_sample)
366 return std::nullopt;
367 return span;
368 }
369
370 void emit(const detail::MidiEvent& e) noexcept
371 {
372 auto& msg = outputs.midi.midi_messages.emplace_back();
373 msg.bytes.assign(
374 {static_cast<unsigned char>((e.on ? 0x90 : 0x80) | e.channel), e.pitch,
375 e.velocity});
376 // Slice-relative. Avendish's MIDI postprocessor adds the slice start once.
377 msg.timestamp = e.frame;
378 }
379};
380} // namespace Nodes::PulseToNote
Definition VelToNoteCore.hpp:298
STL namespace.
Definition VelToNote.hpp:197
Definition VelToNote.hpp:142
Definition VelToNote.hpp:28
Definition VelToNote.hpp:41
Definition Types.hpp:49
Definition Types.hpp:40