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