OSSIA
Open Scenario System for Interactive Application
Loading...
Searching...
No Matches
dataflow/nodes/midi.hpp
1#pragma once
2#include <ossia/dataflow/graph_node.hpp>
3#include <ossia/dataflow/node_process.hpp>
4#include <ossia/dataflow/port.hpp>
5#include <ossia/detail/flat_multiset.hpp>
6
7#include <libremidi/ump_events.hpp>
8
9#include <algorithm>
10
11namespace ossia::nodes
12{
13
14using midi_size_t = uint8_t;
15struct note_data
16{
17 time_value start{};
18 time_value duration{};
19
20 midi_size_t pitch{};
21 midi_size_t velocity{};
22};
23
24inline bool is_note_off(const libremidi::ump& m) noexcept
25{
26 return (m.get_status_code() & 0xF0) == 0x80;
27}
28
29inline time_value note_end(const note_data& n) noexcept
30{
31 return n.start + n.duration;
32}
33
34struct note_comparator
35{
36 using is_transparent = std::true_type;
37 bool operator()(const note_data& lhs, const note_data& rhs) const
38 {
39 return lhs.start < rhs.start;
40 }
41 bool operator()(const note_data& lhs, int64_t rhs) const
42 {
43 return lhs.start.impl < rhs;
44 }
45};
46
50{
51 using is_transparent = std::true_type;
52 bool operator()(const note_data& lhs, const note_data& rhs) const
53 {
54 return note_end(lhs) < note_end(rhs);
55 }
56 bool operator()(const note_data& lhs, int64_t rhs) const
57 {
58 return note_end(lhs).impl < rhs;
59 }
60};
61
62class midi final : public ossia::nonowning_graph_node
63{
64 ossia::midi_outlet midi_out;
65
66public:
67 using note_set = ossia::flat_multiset<note_data, note_comparator>;
68 using note_end_set = ossia::flat_multiset<note_data, note_end_comparator>;
69 explicit midi(int64_t notes)
70 {
71 m_outlets.push_back(&midi_out);
72 int64_t to_reserve = std::max(notes * 1.1, 128.);
73 m_notes.reserve(to_reserve);
74 m_orig_notes.reserve(to_reserve);
75 m_by_end.reserve(to_reserve);
76 m_playing_notes.reserve(to_reserve);
77 m_to_stop.reserve(64);
78 m_to_resume.reserve(64);
79 }
80
81 ~midi() override = default;
82
83 std::string label() const noexcept override { return "midi"; }
84
85 void set_channel(int c) { m_channel = c - 1; }
86
87 void add_note(note_data nd)
88 {
89 m_orig_notes.insert(nd);
90 m_by_end.insert(nd);
91 // The playing scan takes start >= t.prev_date, and m_prev_date is where the
92 // next tick starts: a note landing exactly there is still to be played.
93 // With a strict comparison it was dropped, and every note at date 0 with it.
94 if(nd.start >= m_prev_date)
95 {
96 m_notes.insert(nd);
97 }
98 }
99
100 // The comparators only order on one date: erasing by key would remove every
101 // note sharing it, and find() could return a note of another pitch.
102 template <typename Set>
103 static bool erase_exact(Set& set, const note_data& nd)
104 {
105 auto [first, last] = set.equal_range(nd);
106 for(auto it = first; it != last; ++it)
107 {
108 if(it->pitch == nd.pitch)
109 {
110 set.erase(it);
111 return true;
112 }
113 }
114 return false;
115 }
116
117 template <typename Set>
118 static bool contains_exact(const Set& set, const note_data& nd)
119 {
120 auto [first, last] = set.equal_range(nd);
121 for(auto it = first; it != last; ++it)
122 if(it->pitch == nd.pitch)
123 return true;
124 return false;
125 }
126
127 void remove_note(note_data nd)
128 {
129 erase_exact(m_orig_notes, nd);
130 erase_exact(m_by_end, nd);
131 erase_exact(m_notes, nd);
132 if(erase_exact(m_playing_notes, nd))
133 m_to_stop.insert(nd);
134 }
135
136 void replace_notes(note_set&& notes)
137 {
138 for(auto& note : m_playing_notes)
139 m_to_stop.insert(note);
140 m_playing_notes.clear();
141
142 m_to_resume.clear();
143
144 using namespace std;
145 swap(m_orig_notes, notes);
146 rebuild_end_index();
147 m_notes.clear();
148
149 auto start_it = m_orig_notes.lower_bound(m_prev_date.impl);
150 if(start_it != m_orig_notes.end())
151 {
152 m_notes.tree().get_sequence_ref().assign(start_it, m_orig_notes.end());
153 }
154 }
155
156 void transport(ossia::time_value date)
157 {
158 requestTransport = true;
159 m_transport_date = date;
160 }
161
162 void transport_impl(ossia::time_value date)
163 {
164 // Whatever was playing has to be released: the playhead no longer follows
165 // from it.
166 m_to_stop.insert(m_playing_notes.begin(), m_playing_notes.end());
167 m_playing_notes.clear();
168 m_to_resume.clear();
169
170 // Everything starting at or after the new position is to be played again,
171 // and a note the position lands inside of is resumed from its middle -
172 // the note-on scan can never pick those up, since it only matches notes
173 // starting at or after the beginning of a tick.
174 m_notes.clear();
175 for(const note_data& n : m_orig_notes)
176 {
177 if(n.start >= date)
178 m_notes.insert(n);
179 else if((n.start + n.duration) > date)
180 m_to_resume.insert(n);
181 }
182
183 m_prev_date = date;
184 }
185
186 void update_note(note_data oldNote, note_data newNote)
187 {
188 // OPTIMIZEME
189 remove_note(oldNote);
190 add_note(newNote);
191 }
192
193 void set_notes(note_set&& notes)
194 {
195 m_notes = std::move(notes);
196 m_orig_notes = m_notes;
197 rebuild_end_index();
198
199 auto max_it = m_notes.lower_bound({m_prev_date});
200 if(max_it != m_notes.begin()) // TODO handle the begin case correctly
201 m_notes.erase(m_notes.begin(), max_it);
202 }
203
204 bool mustStop{};
205 bool requestTransport{};
206
207private:
208 void rebuild_end_index()
209 {
210 m_by_end.clear();
211 m_by_end.insert(m_orig_notes.begin(), m_orig_notes.end());
212 }
213
216 void resume_notes(ossia::midi_port& mp, int64_t tick_start)
217 {
218 for(const note_data& note : m_to_resume)
219 {
220 mp.messages.push_back(
221 libremidi::from_midi1::note_on(m_channel, note.pitch, note.velocity));
222 mp.messages.back().timestamp = tick_start;
223 m_playing_notes.insert(note);
224 }
225 m_to_resume.clear();
226 }
227
231 void rewind(
232 const ossia::token_request& t, ossia::midi_port& mp, double samplesratio,
233 int64_t tick_start)
234 {
235 // physical_write_duration divides an absolute duration by a signed speed,
236 // so it comes out negative when rewinding.
237 const auto tick_end
238 = tick_start + std::abs(t.physical_write_duration(samplesratio));
239 const auto stamp = [&](time_value at) {
240 return std::clamp(
241 t.to_physical_time_in_tick(at, samplesratio), tick_start,
242 std::max(tick_start, tick_end - 1));
243 };
244
245 // Enter the notes whose end the playhead went back over, from their tail.
246 // ]date; prev_date] : an end landing exactly on prev_date was not sounding
247 // there, the interval of a note being [start; end[.
248 {
249 const note_data lo{t.date, 0_tv};
250 const note_data hi{t.prev_date, 0_tv};
251 for(auto it = m_by_end.upper_bound(lo), last = m_by_end.upper_bound(hi);
252 it != last; ++it)
253 {
254 if(contains_exact(m_playing_notes, *it))
255 continue;
256
257 mp.messages.push_back(
258 libremidi::from_midi1::note_on(m_channel, it->pitch, it->velocity));
259 mp.messages.back().timestamp = stamp(note_end(*it));
260
261 m_playing_notes.insert(*it);
262 erase_exact(m_notes, *it);
263 }
264 }
265
266 // Leave the ones whose start it went back over. After entering, so that a
267 // note falling entirely inside this tick is played then released, the same
268 // way the forward pass handles a note shorter than its tick.
269 for(auto it = m_playing_notes.begin(); it != m_playing_notes.end();)
270 {
271 if(it->start > t.date)
272 {
273 mp.messages.push_back(
274 libremidi::from_midi1::note_off(m_channel, it->pitch, 0));
275 mp.messages.back().timestamp = stamp(it->start);
276 it = m_playing_notes.erase(it);
277 }
278 else
279 {
280 ++it;
281 }
282 }
283
284 // Everything the playhead went back over the start of has to be playable
285 // again. Exactly once: m_notes is a multiset, and a note that had not been
286 // played yet is still in it.
287 const auto first = m_orig_notes.upper_bound({t.date});
288 const auto last = m_orig_notes.upper_bound({t.prev_date});
289 for(auto it = first; it != last; ++it)
290 {
291 erase_exact(m_notes, *it);
292 m_notes.insert(*it);
293 }
294 }
295
299 static int64_t stamp_in_tick(
300 const ossia::token_request& t, time_value at, double samplesratio,
301 int64_t tick_start) noexcept
302 {
303 const auto tick_end
304 = tick_start + std::abs(t.physical_write_duration(samplesratio));
305 return std::clamp(
306 t.to_physical_time_in_tick(at, samplesratio), tick_start,
307 std::max(tick_start, tick_end - 1));
308 }
309
314 void stop_finished_notes(
315 const ossia::token_request& t, ossia::midi_port& mp, double samplesratio,
316 int64_t tick_start)
317 {
318 for(auto it = m_playing_notes.begin(); it != m_playing_notes.end();)
319 {
320 const note_data& note = *it;
321 const auto end_time = note.start + note.duration;
322
323 if(end_time < t.date)
324 {
325 mp.messages.push_back(libremidi::from_midi1::note_off(m_channel, note.pitch, 0));
326 mp.messages.back().timestamp
327 = stamp_in_tick(t, end_time, samplesratio, tick_start);
328
329 it = m_playing_notes.erase(it);
330 }
331 else
332 {
333 ++it;
334 }
335 }
336 }
337
338 void run(const ossia::token_request& t, ossia::exec_state_facade e) noexcept override
339 {
340 ossia::midi_port& mp = *midi_out;
341
342 struct scope_guard
343 {
344 midi& self;
345 const ossia::token_request& t;
346 ossia::midi_port& mp;
347 std::size_t first_message;
348 ~scope_guard()
349 {
350 // Consumers (VST3 event lists, MIDI outputs...) expect events in
351 // chronological order, and a note-off must win over a note-on sharing
352 // its timestamp, else back-to-back notes on the same pitch cancel out.
353 auto begin = mp.messages.begin() + first_message;
354 std::stable_sort(begin, mp.messages.end(), [](const auto& lhs, const auto& rhs) {
355 if(lhs.timestamp != rhs.timestamp)
356 return lhs.timestamp < rhs.timestamp;
357 return is_note_off(lhs) && !is_note_off(rhs);
358 });
359
360 self.m_prev_date = t.date;
361
362 if(self.requestTransport)
363 {
364 self.transport_impl(self.m_transport_date);
365 self.requestTransport = false;
366 }
367 }
368 } guard{*this, t, mp, mp.messages.size()};
369
370 const auto samplesratio = e.modelToSamples();
371 const auto tick_start = t.physical_start(samplesratio);
372
373 if(t.end_discontinuous)
374 {
375 auto& mess = mp.messages;
376 for(auto note : m_playing_notes)
377 {
378 mess.push_back(libremidi::from_midi1::note_off(m_channel, note.pitch, 0));
379 mess.back().timestamp = tick_start;
380 }
381 for(auto note : m_to_stop)
382 {
383 mess.push_back(libremidi::from_midi1::note_off(m_channel, note.pitch, 0));
384 mess.back().timestamp = tick_start;
385 }
386 m_playing_notes.clear();
387 m_to_stop.clear();
388 return;
389 }
390
391 for(const note_data& note : m_to_stop)
392 {
393 mp.messages.push_back(libremidi::from_midi1::note_off(m_channel, note.pitch, 0));
394 mp.messages.back().timestamp = tick_start;
395 }
396 m_to_stop.clear();
397
398 if(mustStop)
399 {
400 for(auto& note : m_playing_notes)
401 {
402 mp.messages.push_back(libremidi::from_midi1::note_off(m_channel, note.pitch, 0));
403 mp.messages.back().timestamp = tick_start;
404 }
405
406 m_notes = m_orig_notes;
407 m_playing_notes.clear();
408 m_to_resume.clear();
409
410 mustStop = false;
411 }
412 else
413 {
414 // Not when rewinding: everything may well have been played already, and
415 // going back over it is exactly what has to bring it back.
416 if(!t.backward() && m_notes.empty() && m_playing_notes.empty()
417 && m_to_resume.empty())
418 return;
419
420 resume_notes(mp, tick_start);
421
422 if(t.backward())
423 {
424 rewind(t, mp, samplesratio, tick_start);
425 }
426 else if(t.forward())
427 {
428 stop_finished_notes(t, mp, samplesratio, tick_start);
429
430 // Look for all the messages
431 auto max_it = m_notes.lower_bound({t.date});
432 for(auto it = m_notes.begin(); it < max_it;)
433 {
434 note_data& note = const_cast<note_data&>(*it);
435 auto start_time = note.start;
436 if(start_time >= t.prev_date && start_time < t.date)
437 {
438 // Send note_on
439 mp.messages.push_back(
440 libremidi::from_midi1::note_on(m_channel, note.pitch, note.velocity));
441 mp.messages.back().timestamp
442 = stamp_in_tick(t, start_time, samplesratio, tick_start);
443
444 m_playing_notes.insert(note);
445 it = m_notes.erase(it);
446 max_it = std::lower_bound(
447 it, m_notes.end(), t.date.impl + 1, note_comparator{});
448 }
449 else
450 {
451 ++it;
452 }
453 }
454
455 // Notes short enough to begin and end inside this tick were not in
456 // m_playing_notes when the first pass ran: stop them here.
457 stop_finished_notes(t, mp, samplesratio, tick_start);
458 }
459 }
460 }
461
462 note_set m_notes;
463 note_set m_orig_notes;
464 note_set m_playing_notes;
465 note_set m_to_stop;
466 note_set m_to_resume;
467 note_end_set m_by_end;
468 time_value m_prev_date{};
469 time_value m_transport_date{};
470
471 int m_channel{};
472};
473
474class midi_node_process final : public ossia::node_process
475{
476public:
477 using ossia::node_process::node_process;
478
479 void transport_impl(ossia::time_value date) override
480 {
481 midi& n = *static_cast<midi*>(node.get());
482 n.transport(date);
483 }
484
485 void stop() override
486 {
487 midi& n = *static_cast<midi*>(node.get());
488 n.request(ossia::token_request{});
489 n.mustStop = true;
490 }
491};
492}
Forward playback finds the notes to start by their start date; going backwards it is their end date t...
Definition dataflow/nodes/midi.hpp:50